dd54ec5d42
- Migration 20260924120000_dashboard_image_drop_data: Schutzpruefung (bricht ab, solange eine Zeile ohne storagePath existiert; row_security aus, damit ein Eigentuemer ohne BYPASSRLS nicht still 0 Zeilen sieht), dann NOT NULL, DROP COLUMN data, DROP POLICY system_read_policy - Dienst: Bootstrap-Umzug samt forSystem() und Selbstheilung aus data entfernt; Upload vergibt die UUID selbst, Zeile gleich mit Pfad - FORSYSTEM_ALLOWED_CALL_SITES, Tests, Zugriffsklassifikation (per Gate-Schleife gemessen: 61/213/6) nachgezogen - Betriebshandbuch Kap. 4: Hinweis und Wiederherstellungsweg bei Abbruch - Todo 2026-09-22 nach completed/ Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1150 lines
48 KiB
TypeScript
1150 lines
48 KiB
TypeScript
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { join, relative } from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
/**
|
|
* Ermittelt die Fundstellen erneut aus dem Quelltext und vergleicht sie
|
|
* gegen die im Dokument gefuehrten Eintraege (Aufgabe 3, WINDOWS #18/#20,
|
|
* T-EOR-05). Scheitert, sobald eine Fundstelle ohne Eintrag existiert oder
|
|
* ein Eintrag ohne Fundstelle. Vergleichsschluessel sind Datei UND
|
|
* Modellname — eine Zeilennummer traegt nicht, das ueberlebt das
|
|
* Verschieben einer Zeile.
|
|
*
|
|
* Erweitert in Aufgabe 2 (260909-ipc, Befund G): eine Umstellung auf
|
|
* `forTenant()` laesst `this.prisma.<Modell>` aus dem Quelltext
|
|
* verschwinden. Ohne eine zweite Erkennung fuer gebundene Zugriffe wuerde
|
|
* diese Pruefung eine Umstellung als "Fundstelle verschwunden" werten und
|
|
* zwingen, den Nachweis aus dem Dokument zu LOESCHEN statt ihn
|
|
* fortzuschreiben. Die zweite Erkennung sammelt je Datei die Zuweisungen
|
|
* der Form `const <Name> = forTenant(` und sucht danach `<Name>.<Modell>`.
|
|
* Aus beiden Mengen ergibt sich je Paar (Datei, Modell) ein Stand:
|
|
* `gebunden`, `ungebunden` oder `gemischt`.
|
|
*
|
|
* Erweitert in Aufgabe 2 (260909-jts, Befund B): Modellzugriffe koennen
|
|
* auch ueber den Rueckgabeparameter einer INTERAKTIVEN Transaktion laufen
|
|
* (`empfaenger.$transaction(async (tx) => { ... tx.<Modell> ... })`) —
|
|
* weder `this.prisma.<Modell>` noch `<gebundener Client>.<Modell>` sehen
|
|
* das, weil der Parametername (z. B. `tx`) weder mit `this.prisma`
|
|
* uebereinstimmt noch selbst aus einer `forTenant(`-Zuweisung stammt. Die
|
|
* dritte Erkennung sammelt je Datei die Empfaenger UND Parameternamen
|
|
* solcher Transaktionen (zwei Formen: direkt `<empfaenger>.$transaction(
|
|
* async (tx) => ...)`, oder ueber das Hilfsmittel `withTenantTransaction(
|
|
* <empfaenger>, tenantId, async (tx) => ...)` aus
|
|
* `prisma-tenant.extension.ts`) und sucht danach `<tx>.<Modell>`. Die
|
|
* Zuordnung richtet sich nach dem Empfaenger: eine bereits als gebunden
|
|
* erkannte Zuweisung ODER jeder Aufruf von `withTenantTransaction(` zaehlt
|
|
* als gebunden (das Hilfsmittel bindet den Kontext selbst, direkt auf dem
|
|
* Transaktionsparameter) — alles andere zaehlt als ungebunden.
|
|
*
|
|
* Erweitert in 260911-mkj (WINDOWS #27): keine der drei Formen oben sieht
|
|
* einen Zugriff, der ueber `include:`/`select:`/`_count:` aus einem
|
|
* erkannten Modellaufruf HERAUS in eine ZWEITE Tabelle reicht — Prisma
|
|
* rendert das als Unterabfrage/Join auf die zweite Tabelle unter DEREN
|
|
* Regel, aber weder `this.prisma.<Modell>` noch `<gebundener
|
|
* Client>.<Modell>` noch `<tx>.<Modell>` enthalten das Zielmodell als
|
|
* eigenen Text. Die vierte Erkennung loest Relationsfelder ueber
|
|
* `schema.prisma` (`parseSchemaRelations`, `SCHEMA_RELATIONS`) auf ihr
|
|
* Zielmodell auf und traegt das Zielmodell als eigene Fundstelle derselben
|
|
* Datei ein — gebunden, wenn der Empfaenger des Ankeraufrufs gebunden ist,
|
|
* sonst ungebunden. Ein Waechter (Rohzahl `include|select|_count` ueber den
|
|
* ganzen kommentarfreien Quelltext gegen die innerhalb erkannter Aufrufe
|
|
* gezaehlte Zahl) haelt die Grenze der Erkennung laut, nicht still — siehe
|
|
* `RELATION_SPEC_EXCEPTIONS` unten.
|
|
*
|
|
* Erweitert in 260914-eym (Etappe 3c, Systemkontext): die FUENFTE Erkennung
|
|
* sammelt je Datei die Zuweisungen der Form `const <Name> = forSystem(` und
|
|
* sucht danach `<Name>.<Modell>` — das ist die eigene Zugriffsklasse
|
|
* "liest ueber ALLE Mandanten" (Stand `system-gebunden`), die der
|
|
* Schwesterhelfer `forSystem()` aus `prisma-tenant.extension.ts` bildet.
|
|
* Relationsziele ueber `include`/`select` auf einem System-Klienten landen
|
|
* ebenfalls in `systemModels` (die vierte Erkennung bekommt dafuer die
|
|
* Zielmenge direkt statt eines `isBound`-Flags). Vorrang der Staende je
|
|
* Paar (Datei, Modell): ungebunden vorhanden UND anderes -> `gemischt`;
|
|
* nur ungebunden -> `ungebunden`; Systemkontext vorhanden und KEIN
|
|
* ungebundener Zugriff -> `system-gebunden` (auch wenn daneben
|
|
* mandantengebundene Zugriffe stehen — die Begruendungsspalte nennt sie);
|
|
* nur mandantengebunden -> `gebunden`. Der Wachhund
|
|
* `FORSYSTEM_ALLOWED_CALL_SITES` unten nennt je Datei die EXAKTE Zahl der
|
|
* `forSystem(`-Aufrufe — ein Anfrageweg, der `forSystem` ruft, laese an
|
|
* JEDER Mandantenregel vorbei (T-EYM-01).
|
|
*/
|
|
|
|
const API_SRC_DIR = join(__dirname, '..');
|
|
const REPO_ROOT = join(__dirname, '../../../..');
|
|
const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.md');
|
|
const SCHEMA_PATH = join(__dirname, '../../prisma/schema.prisma');
|
|
|
|
/**
|
|
* Dateien, in denen ein `forTenant(`-Aufruf bewusst NICHT der erkannten
|
|
* `const <Name> = forTenant(`-Zuweisungsform folgt.
|
|
*
|
|
* Bis 260911-e2s standen hier zwei Dateien (`tenant.middleware.ts`,
|
|
* `tenant.guard.ts`): beide veroeffentlichten einen gebundenen Client auf
|
|
* dem Anfrageobjekt statt ihn einer lokalen Konstante zuzuweisen — der Weg
|
|
* war die offene Architekturfrage aus
|
|
* docs/mandantentrennung-zugriffsklassifikation.md ("Was diese Etappe NICHT
|
|
* entscheidet").
|
|
*
|
|
* Die Frage ist mit 260911-e2s (Aufgabe 2) ENTSCHIEDEN: die
|
|
* dienst-interne Bindung (ein Klient je Methode, wie es alle neun vor
|
|
* diesem Bereich umgestellten Bereiche bereits vormachen) ist die
|
|
* Konvention; der Guard erzeugt ueberhaupt keinen Client mehr, die
|
|
* gleichlautende, nie verdrahtete Middleware ist geloescht. Diese Liste
|
|
* startet deshalb leer und bleibt es, bis ein begruendeter neuer
|
|
* Ausnahmefall auftritt — dieselbe Form wie
|
|
* `INTERACTIVE_TRANSACTION_EXCEPTIONS` unten. Der Test
|
|
* "keine veraltete Ausnahmeliste" unter dieser Datei stellt sicher, dass ein
|
|
* kuenftiger Eintrag nicht unbemerkt veraltet.
|
|
*/
|
|
const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set<string>([]);
|
|
|
|
/**
|
|
* Dateien, in denen eine interaktive Transaktion (`empfaenger.$transaction(
|
|
* async (tx) => ...)`) bewusst KEINER der beiden erkannten Empfaengerformen
|
|
* entspricht. Gemessen zur Planungszeit (260909-jts, Aufgabe 1) gibt es im
|
|
* gesamten API-Quelltext genau eine interaktive Transaktion, in
|
|
* `groups.service.ts` — nach deren Umstellung auf `withTenantTransaction(`
|
|
* (Aufgabe 2) entspricht sie der erkannten Hilfsmittel-Form. Die Liste
|
|
* startet deshalb leer und bleibt es, bis ein begruendeter Ausnahmefall
|
|
* auftritt.
|
|
*/
|
|
const INTERACTIVE_TRANSACTION_EXCEPTIONS = new Set<string>([]);
|
|
|
|
/**
|
|
* Dateien, in denen eine `include:`/`select:`/`_count:`-Angabe ausserhalb
|
|
* jedes von der vierten Erkennung erfassten Modellaufrufs liegt (260911-mkj,
|
|
* WINDOWS #27). Gemessen zur Planungszeit: `backfill-tender-source.ts` ist
|
|
* ein eigenstaendiges Skript mit eigenem `new PrismaClient()` — sein
|
|
* `select:` (Zeile 34) liegt auf der plattformglobalen Tabelle `Tender`
|
|
* (Migration 20260909140000, Gruppe b, kein Zeilenschutz); der Empfaenger
|
|
* `prisma` dieser Datei ist fuer KEINE der vier Erkennungsformen erreichbar
|
|
* (weder `this.prisma` noch eine `forTenant(`-Zuweisung noch ein
|
|
* Transaktionsparameter). Zusammen mit `tenders.seed.ts`
|
|
* (Funktionsparameter `prisma: PrismaService`, keine Relationsangabe,
|
|
* deshalb hier nicht gelistet) als eigener Ledger-Eintrag gefuehrt:
|
|
* WINDOWS #33.
|
|
*/
|
|
const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill-tender-source.ts']);
|
|
|
|
/**
|
|
* Erlaubnisliste fuer `forSystem(` (Etappe 3c, 260914-eym, T-EYM-01):
|
|
* Datei -> EXAKTE Zahl der `forSystem(`-Aufrufe. Der Systemkontext liest an
|
|
* JEDER Mandantenregel vorbei; ein Anfrageweg darf ihn nie rufen. Deshalb
|
|
* ist die Liste kein "mindestens", sondern ein "genau": jede Datei mit
|
|
* `forSystem(` ausserhalb der Liste, jede Abweichung der Zahl (auch ein
|
|
* ZWEITER Aufruf in einer erlaubten Datei) und jeder veraltete Eintrag
|
|
* (Datei weg oder Zahl gesunken) machen die Spec rot.
|
|
*
|
|
* Die sechs Faelle der Hintergrunddienst-Falle
|
|
* (docs/mandantentrennung-zugriffsklassifikation.md) und wo sie stehen:
|
|
* (1) DKV-Planer-Startpfad -> dkv.service.ts (1 Aufruf,
|
|
* `loadActiveConfigsForScheduler`); (2) Mailmodul-Startpfad -> NICHT in der
|
|
* Liste: der Startpfad ist ENTFERNT, `MailService` baut je Versand einen
|
|
* Transport gebunden ueber `getDecryptedSmtpConfig(tenantId)`
|
|
* (settings.service.ts/mail.service.ts rufen `forSystem` nie); (3) ldap ->
|
|
* ldap-config.service.ts (2 Aufrufe: `getAllActiveConfigs` und die
|
|
* Nachverschluesselung in `onApplicationBootstrap`, je eigene Methode);
|
|
* (4) tender-digest -> tender-digest.scheduler.ts (1, Kandidatenabfrage);
|
|
* (5) tender-matching -> tender-matching.service.ts (1, Profilabfrage);
|
|
* (6) admin-seed -> NICHT in der Liste: der einzige Lesezugriff ausserhalb
|
|
* der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration
|
|
* eine Regel traegt — kein Systemkontext noetig, Datei unveraendert.
|
|
* Summe: 4 Dateien, 5 Aufrufe.
|
|
*
|
|
* SIEBTER FALL (quick-260922-hk4): `dashboard-images.service.ts`, EIN
|
|
* Aufruf, ausschliesslich in `onApplicationBootstrap()` — der einmalige
|
|
* Umzug der Bilderrahmen-Bilder aus der Spalte `data` in den Dateibereich.
|
|
* Ein Startpfad hat keinen Mandanten im Ruecken und muss die noch nicht
|
|
* umgezogenen Zeilen ALLER Mandanten sehen; die passende Regel
|
|
* `system_read_policy ... FOR SELECT` auf "DashboardImage" legt die
|
|
* Migration 20260922120000 an. Dieselbe Datei bedient daneben Anfragewege
|
|
* (`list`/`upload`/`getBytes`/`remove`) — die bleiben ausnahmslos
|
|
* mandantengebunden, und auch der Umzug SCHREIBT je Zeile ueber
|
|
* `forTenant(prisma, row.tenantId, row.userId)`, nie ueber den
|
|
* Systemklienten. Praezedenz fuer "ein Dienst mit Anfrageweg UND
|
|
* systemgebundenem Startpfad": `ldap-config.service.ts`, dessen
|
|
* Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist.
|
|
* Summe neu: 5 Dateien, 6 Aufrufe.
|
|
*
|
|
* quick-260923-dhh (Aufgabe 4): eine sechste Datei kommt hinzu —
|
|
* `proxmox.service.ts`/`loadActiveServersForScheduler()`, derselbe
|
|
* Startpfad-Fall wie `dkv.service.ts`: der Planer liest beim Start ALLE
|
|
* aktiven `ProxmoxServer`-Zeilen aller Mandanten (`system_read_policy` auf
|
|
* `ProxmoxServer`, Migration 20260923140000), registriert je Mandant einen
|
|
* Cron-Auftrag, und schreibt danach ausschliesslich je Zeile gebunden ueber
|
|
* `forTenant()`. Summe neu: 6 Dateien, 7 Aufrufe.
|
|
*
|
|
* quick-260924-m4n: der SIEBTE FALL ist wieder ENTFERNT. Stufe 2 der
|
|
* Bilderrahmen-Umstellung (Migration 20260924120000_dashboard_image_drop_data)
|
|
* loescht die Spalte `data`; der Bootstrap-Umzug in
|
|
* `dashboard-images.service.ts` hat damit nichts mehr zu lesen und ist samt
|
|
* seinem `forSystem()`-Aufruf aus dem Dienst entfernt. Dieselbe Migration
|
|
* nimmt die `system_read_policy` auf "DashboardImage" zurueck. Summe neu:
|
|
* 5 Dateien, 6 Aufrufe.
|
|
*/
|
|
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
|
['apps/api/src/dkv/dkv.service.ts', 1],
|
|
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
|
['apps/api/src/proxmox/proxmox.service.ts', 1],
|
|
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
|
['apps/api/src/tenders/tender-matching.service.ts', 1],
|
|
]);
|
|
|
|
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt', 'system-gebunden'] as const;
|
|
type Stand = (typeof STAND_TOKENS)[number];
|
|
|
|
interface FileAnalysis {
|
|
file: string;
|
|
unboundModels: Set<string>;
|
|
boundModels: Set<string>;
|
|
systemModels: Set<string>;
|
|
totalForTenantCalls: number;
|
|
assignmentFormCalls: number;
|
|
totalForSystemCalls: number;
|
|
systemAssignmentFormCalls: number;
|
|
rawInteractiveTransactionCount: number;
|
|
matchedInteractiveTransactionCount: number;
|
|
rawRelationSpecCount: number;
|
|
matchedRelationSpecCount: number;
|
|
unresolvedRelationSpecValues: string[];
|
|
}
|
|
|
|
function listTsFiles(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
out.push(...listTsFiles(full));
|
|
} else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')) {
|
|
out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Filtert Kommentarzeilen (Zeilenkommentare und Blockkommentare) heraus,
|
|
* bevor nach `this.prisma.<model>` oder `forTenant(` gesucht wird — sonst
|
|
* zaehlt eine erklaerende Kopfzeile als Fundstelle mit.
|
|
*/
|
|
function stripComments(source: string): string {
|
|
return source
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.split('\n')
|
|
.filter((line) => !line.trim().startsWith('//'))
|
|
.join('\n');
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/**
|
|
* Sucht ab `openIndex` (der Position von `openChar`) die passende
|
|
* schliessende Klammer per Klammertiefe. Verwendet fuer sowohl `(`/`)`
|
|
* (Argumentbereich eines Ankeraufrufs) als auch `{`/`}` (Objektliteral
|
|
* einer aufgeloesten Konstante) — 260911-mkj, WINDOWS #27.
|
|
*/
|
|
function findMatchingBracket(text: string, openIndex: number, openChar: string, closeChar: string): number {
|
|
let depth = 0;
|
|
for (let i = openIndex; i < text.length; i++) {
|
|
const ch = text[i];
|
|
if (ch === openChar) depth++;
|
|
else if (ch === closeChar) {
|
|
depth -= 1;
|
|
if (depth === 0) return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Ersetzt den INHALT jedes Zeichenkettenliterals (einfach, doppelt,
|
|
* Backtick) durch nichts, die Anfuehrungszeichen bleiben — NUR fuer die
|
|
* vierte Erkennung (260911-mkj, WINDOWS #27): eine Zeichenkette wie
|
|
* `contains: '{('` darf die Klammertiefenzaehlung des Argumentbereichs
|
|
* nicht zerreissen. Die Formen 1-3 arbeiten weiter auf dem unveraenderten
|
|
* kommentarfreien Quelltext (`source`), damit eine Vorlagen-Interpolation
|
|
* wie `${tx.user.count()}` fuer sie sichtbar bleibt.
|
|
*/
|
|
function blankStringLiterals(text: string): string {
|
|
return text.replace(
|
|
/'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`/g,
|
|
(literal) => literal[0] + literal[literal.length - 1],
|
|
);
|
|
}
|
|
|
|
function lowerFirst(name: string): string {
|
|
return name.length > 0 ? name.charAt(0).toLowerCase() + name.slice(1) : name;
|
|
}
|
|
|
|
/**
|
|
* Liest `schema.prisma` zur TESTZEIT (dieselbe Idiomatik wie das Lesen der
|
|
* Migrationen in `rls-coverage.spec.ts`) und bildet je `model`-Block eine
|
|
* Map Feld -> Zielmodell, aber NUR fuer Felder, deren Typ selbst ein
|
|
* Modellname ist (260911-mkj, WINDOWS #27).
|
|
*
|
|
* Der Lookahead `(?=\s|$)` ist zwingend: eine Feldzeile wie
|
|
* `users User[]` in `Tenant` steht am ZEILENENDE. Ohne den Lookahead
|
|
* verliert das Muster jede Listenrelation, deren Typname direkt vor dem
|
|
* Zeilenende steht — der erste Fehler des Planungs-Prototyps, `Tenant`
|
|
* hatte damit scheinbar keine Relation. Nicht wiederholen.
|
|
*/
|
|
function parseSchemaRelations(schemaSource: string): Map<string, Map<string, string>> {
|
|
const modelBlockPattern = /^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm;
|
|
const blocks: Array<{ name: string; body: string }> = [];
|
|
for (const m of schemaSource.matchAll(modelBlockPattern)) {
|
|
if (m[1] !== undefined && m[2] !== undefined) {
|
|
blocks.push({ name: m[1], body: m[2] });
|
|
}
|
|
}
|
|
const modelNames = new Set(blocks.map((b) => b.name));
|
|
|
|
const fieldPattern = /^\s*(\w+)\s+(\w+)(?:\[\]|\?)?(?=\s|$)/gm;
|
|
const relations = new Map<string, Map<string, string>>();
|
|
for (const { name, body } of blocks) {
|
|
const fieldMap = new Map<string, string>();
|
|
for (const fm of body.matchAll(fieldPattern)) {
|
|
const fieldName = fm[1];
|
|
const fieldType = fm[2];
|
|
if (fieldName && fieldType && modelNames.has(fieldType)) {
|
|
fieldMap.set(fieldName, fieldType);
|
|
}
|
|
}
|
|
relations.set(name, fieldMap);
|
|
}
|
|
return relations;
|
|
}
|
|
|
|
const SCHEMA_RELATIONS = parseSchemaRelations(readFileSync(SCHEMA_PATH, 'utf-8'));
|
|
|
|
/**
|
|
* Kleiner Anfangsbuchstabe -> Modellname (`Tenant` -> `tenant`,
|
|
* `LdapFieldMapping` -> `ldapFieldMapping`) — derselbe Schluessel wie in
|
|
* der Spalte `Modell` der Bestandsaufnahme und in `this.prisma.<Modell>`
|
|
* (260911-mkj, WINDOWS #27).
|
|
*/
|
|
const CLIENT_NAME_TO_MODEL = new Map<string, string>(
|
|
[...SCHEMA_RELATIONS.keys()].map((modelName) => [lowerFirst(modelName), modelName]),
|
|
);
|
|
|
|
const RELATION_ANCHOR_OPERATIONS = [
|
|
'findMany',
|
|
'findFirst',
|
|
'findUnique',
|
|
'findFirstOrThrow',
|
|
'findUniqueOrThrow',
|
|
'create',
|
|
'createMany',
|
|
'createManyAndReturn',
|
|
'update',
|
|
'updateMany',
|
|
'updateManyAndReturn',
|
|
'upsert',
|
|
'delete',
|
|
'deleteMany',
|
|
'count',
|
|
'aggregate',
|
|
'groupBy',
|
|
];
|
|
const RELATION_ANCHOR_OPERATIONS_PATTERN = RELATION_ANCHOR_OPERATIONS.join('|');
|
|
|
|
/**
|
|
* Loest `select: NAME`/`include: NAME` gegen eine im selben Quelltext
|
|
* definierte Objektliteral-Konstante `const NAME = { ... }` auf
|
|
* (260911-mkj, WINDOWS #27, Waechter (b)). Findet sich keine, ist der
|
|
* Aufrufer dafuer zustaendig, die Kennung als unaufloesbar zu vermerken.
|
|
*/
|
|
function resolveConstantObjectLiteral(blank: string, name: string): string | null {
|
|
const declPattern = new RegExp(`\\bconst\\s+${name}\\b[^=]*=\\s*\\{`);
|
|
const m = declPattern.exec(blank);
|
|
if (!m) return null;
|
|
const braceStart = m.index + m[0].length - 1;
|
|
const braceEnd = findMatchingBracket(blank, braceStart, '{', '}');
|
|
if (braceEnd === -1) return null;
|
|
return blank.slice(braceStart, braceEnd + 1);
|
|
}
|
|
|
|
interface RelationScanFrame {
|
|
context: string;
|
|
enteringKey: string | null;
|
|
}
|
|
|
|
/**
|
|
* Laeuft mit einem Kontextstapel ueber den Argumentbereich eines erkannten
|
|
* Modellaufrufs (260911-mkj, WINDOWS #27, PLAN.md Aufgabe 1 Schritt 3(f)).
|
|
* Start-Kontext ist das Modell des Ankers. Ein Schluessel, der ein
|
|
* Relationsfeld des AKTUELLEN Kontextmodells ist, traegt das Zielmodell als
|
|
* eigene Fundstelle ein (gebunden/ungebunden nach dem Empfaenger des
|
|
* Ankers) und wird zum Kontext des naechsten `{`; `_count: true` direkt
|
|
* unter `include`/`select` traegt ALLE Relationen des aktuellen
|
|
* Kontextmodells ein. Jeder andere Schluessel laesst den Kontext
|
|
* unveraendert (`where`, `data`, `some`, `every`, `none`, `is`, `isNot`,
|
|
* `connect`, `create`, Operatoren wie `contains`, Skalare) — `{` schiebt
|
|
* den vorgemerkten (sonst den aktuellen) Kontext, `}` nimmt ihn zurueck,
|
|
* `,` loescht die Vormerkung.
|
|
*/
|
|
function scanRelationKeys(
|
|
region: string,
|
|
initialContext: string,
|
|
targetModels: Set<string>,
|
|
): void {
|
|
const stack: RelationScanFrame[] = [{ context: initialContext, enteringKey: null }];
|
|
let pendingContext: string | null = null;
|
|
let pendingKey: string | null = null;
|
|
|
|
const tokenPattern = /\{|\}|,|\b([A-Za-z_]\w*)\s*:/g;
|
|
let match: RegExpExecArray | null = tokenPattern.exec(region);
|
|
while (match !== null) {
|
|
const token = match[0];
|
|
if (token === '{') {
|
|
const currentContext = stack[stack.length - 1].context;
|
|
stack.push({ context: pendingContext ?? currentContext, enteringKey: pendingKey });
|
|
pendingContext = null;
|
|
pendingKey = null;
|
|
} else if (token === '}') {
|
|
if (stack.length > 1) stack.pop();
|
|
pendingContext = null;
|
|
pendingKey = null;
|
|
} else if (token === ',') {
|
|
pendingContext = null;
|
|
pendingKey = null;
|
|
} else {
|
|
const keyName = match[1] ?? null;
|
|
const currentContext = stack[stack.length - 1].context;
|
|
const relTarget = keyName ? SCHEMA_RELATIONS.get(currentContext)?.get(keyName) : undefined;
|
|
if (keyName && relTarget) {
|
|
const clientName = lowerFirst(relTarget);
|
|
targetModels.add(clientName);
|
|
pendingContext = relTarget;
|
|
pendingKey = keyName;
|
|
} else if (keyName === '_count') {
|
|
const parentKey = stack[stack.length - 1].enteringKey;
|
|
const afterColon = region.slice(match.index + match[0].length);
|
|
const isLiteralTrue = /^\s*true\b/.test(afterColon);
|
|
if (isLiteralTrue && (parentKey === 'include' || parentKey === 'select')) {
|
|
const relations = SCHEMA_RELATIONS.get(currentContext);
|
|
if (relations) {
|
|
for (const target of relations.values()) {
|
|
targetModels.add(lowerFirst(target));
|
|
}
|
|
}
|
|
}
|
|
pendingContext = currentContext;
|
|
pendingKey = keyName;
|
|
} else {
|
|
pendingContext = currentContext;
|
|
pendingKey = keyName;
|
|
}
|
|
}
|
|
match = tokenPattern.exec(region);
|
|
}
|
|
}
|
|
|
|
function analyzeSource(rawSource: string, relPath: string): FileAnalysis {
|
|
const source = stripComments(rawSource);
|
|
|
|
const unboundModels = new Set<string>();
|
|
for (const m of source.matchAll(/this\.prisma\.([a-zA-Z]+)/g)) {
|
|
if (m[1]) unboundModels.add(m[1]);
|
|
}
|
|
|
|
const assignmentMatches = [...source.matchAll(/const\s+(\w+)\s*=\s*forTenant\(/g)];
|
|
const boundNames = new Set(assignmentMatches.map((m) => m[1]).filter(Boolean) as string[]);
|
|
|
|
const boundModels = new Set<string>();
|
|
for (const name of boundNames) {
|
|
const re = new RegExp(`\\b${name}\\.([a-zA-Z]+)`, 'g');
|
|
for (const m of source.matchAll(re)) {
|
|
if (m[1]) boundModels.add(m[1]);
|
|
}
|
|
}
|
|
|
|
// Zaehlt Aufrufstellen von `forTenant(`, aber nicht die Funktionsdefinition
|
|
// selbst (`export function forTenant(...)` in prisma-tenant.extension.ts) —
|
|
// die Definition ist kein Aufruf und braucht keine Zuweisungsform.
|
|
const totalForTenantCalls = [...source.matchAll(/(?<!function )forTenant\(/g)].length;
|
|
|
|
// Fuenfte Erkennung (260914-eym, Etappe 3c): Zuweisungen `const <Name> =
|
|
// forSystem(` und danach `<Name>.<Modell>` — die Klasse "liest ueber ALLE
|
|
// Mandanten". Gezaehlt wie bei forTenant: Aufrufe, nicht die Definition.
|
|
const systemAssignmentMatches = [...source.matchAll(/const\s+(\w+)\s*=\s*forSystem\(/g)];
|
|
const systemNames = new Set(systemAssignmentMatches.map((m) => m[1]).filter(Boolean) as string[]);
|
|
const systemModels = new Set<string>();
|
|
for (const name of systemNames) {
|
|
const re = new RegExp(`\\b${name}\\.([a-zA-Z]+)`, 'g');
|
|
for (const m of source.matchAll(re)) {
|
|
if (m[1]) systemModels.add(m[1]);
|
|
}
|
|
}
|
|
const totalForSystemCalls = [...source.matchAll(/(?<!function )forSystem\(/g)].length;
|
|
|
|
// Dritte Erkennung (260909-jts, Befund B): Modellzugriffe ueber den
|
|
// Rueckgabeparameter einer interaktiven Transaktion. Rohzahl zuerst
|
|
// (jedes "<etwas>.$transaction(async" im Quelltext), danach die
|
|
// strukturierte Erkennung der beiden bekannten Empfaengerformen — die
|
|
// Differenz ist die offen gehaltene Grenze (siehe
|
|
// INTERACTIVE_TRANSACTION_EXCEPTIONS oben).
|
|
//
|
|
// prisma-tenant.extension.ts definiert `withTenantTransaction()` selbst
|
|
// und enthaelt deshalb dessen KANONISCHE interaktive `$transaction`-
|
|
// Anweisung (`(prisma as any).$transaction(async (tx) => ...)`) als
|
|
// Definition, nicht als Aufrufstelle, die klassifiziert werden muesste —
|
|
// dieselbe Ausnahme, die `totalForTenantCalls` oben fuer die Definition
|
|
// von `forTenant()` bereits macht.
|
|
const isPrismaTenantExtensionFile = relPath.endsWith(
|
|
'apps/api/src/prisma/prisma-tenant.extension.ts',
|
|
);
|
|
const rawInteractiveTransactionCount = isPrismaTenantExtensionFile
|
|
? 0
|
|
: [...source.matchAll(/\.\$transaction\(\s*async\b/g)].length;
|
|
|
|
// Sammelt je Datei die Parameternamen BEIDER interaktiver Transaktionsformen
|
|
// mit ihrer Bindung — 260911-mkj nutzt diese Mengen als zusaetzliche
|
|
// erkannte Empfaengerformen der vierten Erkennung (bislang wurden sie nur
|
|
// lokal verbraucht).
|
|
const txBoundParams: string[] = [];
|
|
const txUnboundParams: string[] = [];
|
|
|
|
// Form 1: `<empfaenger>.$transaction(async (<param>) => ...)`. <empfaenger>
|
|
// ist gebunden, wenn er in boundNames steht (aus der Zuweisungsform oben).
|
|
const directInteractiveMatches = [
|
|
...source.matchAll(
|
|
/([\w.]+)\.\$transaction\(\s*async\s*\(?\s*(\w+)(?:\s*:\s*[\w<>[\], ]+)?\s*\)?\s*=>/g,
|
|
),
|
|
];
|
|
for (const m of directInteractiveMatches) {
|
|
const receiver = m[1];
|
|
const param = m[2];
|
|
if (!param) continue;
|
|
const isBound = boundNames.has(receiver);
|
|
if (isBound) txBoundParams.push(param);
|
|
else txUnboundParams.push(param);
|
|
const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g');
|
|
for (const mm of source.matchAll(re)) {
|
|
if (!mm[1]) continue;
|
|
if (isBound) boundModels.add(mm[1]);
|
|
else unboundModels.add(mm[1]);
|
|
}
|
|
}
|
|
|
|
// Form 2: `withTenantTransaction(<empfaenger>, tenantId, async (<param>)
|
|
// => ...)` aus prisma-tenant.extension.ts — IMMER gebunden, unabhaengig
|
|
// vom Empfaenger: das Hilfsmittel bindet den Kontext selbst, direkt auf
|
|
// dem Transaktionsparameter (siehe dessen Kopfkommentar).
|
|
const withTenantTransactionMatches = [
|
|
...source.matchAll(
|
|
/\bwithTenantTransaction\(\s*[\w.]+\s*,[^,]*,\s*async\s*\(?\s*(\w+)(?:\s*:\s*[\w<>[\], ]+)?\s*\)?\s*=>/g,
|
|
),
|
|
];
|
|
for (const m of withTenantTransactionMatches) {
|
|
const param = m[1];
|
|
if (!param) continue;
|
|
txBoundParams.push(param);
|
|
const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g');
|
|
for (const mm of source.matchAll(re)) {
|
|
if (mm[1]) boundModels.add(mm[1]);
|
|
}
|
|
}
|
|
|
|
// Vierte Erkennung (260911-mkj, WINDOWS #27): Relationszugriffe. `blank`
|
|
// ist NUR fuer diese Form — Zeichenkettenliteral-Inhalte sind entfernt,
|
|
// damit eine Zeichenkette wie `contains: '{('` die Klammertiefenzaehlung
|
|
// nicht zerreisst.
|
|
const blank = blankStringLiterals(source);
|
|
|
|
const allReceiverNames = new Set<string>([
|
|
'this.prisma',
|
|
...boundNames,
|
|
...systemNames,
|
|
...txBoundParams,
|
|
...txUnboundParams,
|
|
]);
|
|
const boundReceiverNames = new Set<string>([...boundNames, ...txBoundParams]);
|
|
const receiverAlternation = [...allReceiverNames].map(escapeRegExp).join('|');
|
|
const anchorPattern = new RegExp(
|
|
`\\b(${receiverAlternation})\\.([a-zA-Z]+)\\.(?:${RELATION_ANCHOR_OPERATIONS_PATTERN})\\(`,
|
|
'g',
|
|
);
|
|
|
|
const unresolvedRelationSpecValues: string[] = [];
|
|
let matchedRelationSpecCount = 0;
|
|
|
|
for (const m of blank.matchAll(anchorPattern)) {
|
|
const receiver = m[1];
|
|
const modelClientName = m[2];
|
|
if (!receiver || !modelClientName || m.index === undefined) continue;
|
|
|
|
// Zielmenge nach dem Empfaenger des Ankers: System-Klient -> systemModels,
|
|
// gebundener Klient/Transaktionsparameter -> boundModels, sonst unboundModels.
|
|
const targetModels = systemNames.has(receiver)
|
|
? systemModels
|
|
: boundReceiverNames.has(receiver)
|
|
? boundModels
|
|
: unboundModels;
|
|
const openIndex = m.index + m[0].length - 1;
|
|
const closeIndex = findMatchingBracket(blank, openIndex, '(', ')');
|
|
if (closeIndex === -1) continue;
|
|
|
|
let region = blank.slice(openIndex, closeIndex + 1);
|
|
matchedRelationSpecCount += (region.match(/\b(?:include|select|_count)\s*:/g) || []).length;
|
|
|
|
// Wertform (Waechter (b)): eine Kennung als include:/select:-Wert wird
|
|
// gegen eine gleichnamige, in derselben Datei definierte
|
|
// Objektliteral-Konstante aufgeloest, sonst als unaufloesbar vermerkt.
|
|
region = region.replace(
|
|
/\b(include|select)\s*:\s*([A-Za-z_]\w*)\b(?!\s*[.(])/g,
|
|
(full: string, keyword: string, ident: string) => {
|
|
if (ident === 'true' || ident === 'false') return full;
|
|
const resolved = resolveConstantObjectLiteral(blank, ident);
|
|
if (resolved) return `${keyword}: ${resolved}`;
|
|
unresolvedRelationSpecValues.push(`${relPath}: ${keyword}: ${ident}`);
|
|
return full;
|
|
},
|
|
);
|
|
|
|
const initialContext = CLIENT_NAME_TO_MODEL.get(modelClientName);
|
|
if (initialContext) {
|
|
scanRelationKeys(region, initialContext, targetModels);
|
|
}
|
|
}
|
|
|
|
// Rohzahl ueber den GANZEN kommentarfreien Quelltext (nicht `blank`) — eine
|
|
// Angabe in einer Vorlagen-Interpolation soll raw zaehlen und damit laut
|
|
// werden, nicht still verschwinden (Waechter (a)).
|
|
const rawRelationSpecCount = (source.match(/\b(?:include|select|_count)\s*:/g) || []).length;
|
|
|
|
return {
|
|
file: relPath,
|
|
unboundModels,
|
|
boundModels,
|
|
systemModels,
|
|
totalForTenantCalls,
|
|
assignmentFormCalls: assignmentMatches.length,
|
|
totalForSystemCalls,
|
|
systemAssignmentFormCalls: systemAssignmentMatches.length,
|
|
rawInteractiveTransactionCount,
|
|
matchedInteractiveTransactionCount: directInteractiveMatches.length,
|
|
rawRelationSpecCount,
|
|
matchedRelationSpecCount,
|
|
unresolvedRelationSpecValues,
|
|
};
|
|
}
|
|
|
|
function analyzeFile(absPath: string, relPath: string): FileAnalysis {
|
|
const rawSource = readFileSync(absPath, 'utf-8');
|
|
return analyzeSource(rawSource, relPath);
|
|
}
|
|
|
|
function analyzeAllFiles(): FileAnalysis[] {
|
|
const files = listTsFiles(API_SRC_DIR);
|
|
return files
|
|
.map((absPath) => {
|
|
const relPath = relative(REPO_ROOT, absPath).split('\\').join('/');
|
|
return analyzeFile(absPath, relPath);
|
|
})
|
|
.sort((a, b) => a.file.localeCompare(b.file));
|
|
}
|
|
|
|
interface AccessSite {
|
|
file: string;
|
|
model: string;
|
|
}
|
|
|
|
function findAccessSites(analyses: FileAnalysis[]): AccessSite[] {
|
|
const sites: AccessSite[] = [];
|
|
for (const a of analyses) {
|
|
const allModels = new Set([...a.unboundModels, ...a.boundModels, ...a.systemModels]);
|
|
for (const model of allModels) {
|
|
sites.push({ file: a.file, model });
|
|
}
|
|
}
|
|
return sites.sort((a, b) => (a.file + a.model).localeCompare(b.file + b.model));
|
|
}
|
|
|
|
function computeStandByKey(analyses: FileAnalysis[]): Map<string, Stand> {
|
|
const standByKey = new Map<string, Stand>();
|
|
for (const a of analyses) {
|
|
const allModels = new Set([...a.unboundModels, ...a.boundModels, ...a.systemModels]);
|
|
for (const model of allModels) {
|
|
const isBound = a.boundModels.has(model);
|
|
const isUnbound = a.unboundModels.has(model);
|
|
const isSystem = a.systemModels.has(model);
|
|
// Vorrang (260914-eym): ungebunden + anderes -> gemischt; nur ungebunden
|
|
// -> ungebunden; system ohne ungebunden -> system-gebunden (auch neben
|
|
// gebundenen Zugriffen); sonst gebunden.
|
|
let stand: Stand;
|
|
if (isUnbound && (isBound || isSystem)) stand = 'gemischt';
|
|
else if (isUnbound) stand = 'ungebunden';
|
|
else if (isSystem) stand = 'system-gebunden';
|
|
else stand = 'gebunden';
|
|
standByKey.set(`${a.file}::${model}`, stand);
|
|
}
|
|
}
|
|
return standByKey;
|
|
}
|
|
|
|
const CLASS_TOKENS = [
|
|
'muss-mandantengebunden',
|
|
'bewusst-uebergreifend',
|
|
'keine-mandantengebundene-tabelle',
|
|
'beides',
|
|
];
|
|
|
|
interface DocEntry {
|
|
file: string;
|
|
model: string;
|
|
klasse: string;
|
|
stand: string;
|
|
}
|
|
|
|
function parseDocEntries(): DocEntry[] {
|
|
const raw = readFileSync(DOC_PATH, 'utf-8');
|
|
const entries: DocEntry[] = [];
|
|
|
|
// Nur Zeilen aus der Bestandsaufnahme-Tabelle:
|
|
// `| Datei | Modell | Klasse | Stand | Begruendung |`
|
|
const rowPattern =
|
|
/^\|\s*(apps\/api\/src\/[^\s|]+\.ts)\s*\|\s*([a-zA-Z]+)\s*\|\s*([a-z-]+)\s*\|\s*([a-z-]+)\s*\|/gm;
|
|
|
|
for (const match of raw.matchAll(rowPattern)) {
|
|
const [, file, model, klasse, stand] = match;
|
|
entries.push({ file, model, klasse, stand });
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollstaendig ab (Aufgabe 3, T-EOR-05)', () => {
|
|
it('das Dokument existiert', () => {
|
|
expect(() => statSync(DOC_PATH)).not.toThrow();
|
|
});
|
|
|
|
const analyses = analyzeAllFiles();
|
|
const sourceSites = findAccessSites(analyses);
|
|
const standByKey = computeStandByKey(analyses);
|
|
const docEntries = parseDocEntries();
|
|
const docKeys = new Set(docEntries.map((e) => `${e.file}::${e.model}`));
|
|
const sourceKeys = new Set(sourceSites.map((s) => `${s.file}::${s.model}`));
|
|
|
|
it('die Bestandsaufnahme-Tabelle enthaelt mindestens einen Eintrag', () => {
|
|
expect(docEntries.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('jede im Quelltext gefundene (Datei, Modell)-Fundstelle ist im Dokument eingetragen', () => {
|
|
const missing = sourceSites
|
|
.map((s) => `${s.file}::${s.model}`)
|
|
.filter((key) => !docKeys.has(key));
|
|
expect(missing, `Fehlende Eintraege im Dokument:\n${missing.join('\n')}`).toEqual([]);
|
|
});
|
|
|
|
it('jeder im Dokument gefuehrte Eintrag hat eine tatsaechliche Fundstelle im Quelltext (gebunden oder ungebunden)', () => {
|
|
const stale = [...docKeys].filter((key) => !sourceKeys.has(key));
|
|
expect(stale, `Eintraege im Dokument ohne Fundstelle im Quelltext:\n${stale.join('\n')}`).toEqual([]);
|
|
});
|
|
|
|
it('jeder Eintrag traegt eine der vier gueltigen Klassen', () => {
|
|
const invalid = docEntries.filter((e) => !CLASS_TOKENS.includes(e.klasse));
|
|
expect(invalid, JSON.stringify(invalid)).toEqual([]);
|
|
});
|
|
|
|
it('jeder Eintrag traegt einen der vier gueltigen Stand-Werte (gebunden, ungebunden, gemischt, system-gebunden)', () => {
|
|
const invalid = docEntries.filter((e) => !STAND_TOKENS.includes(e.stand as Stand));
|
|
expect(invalid, JSON.stringify(invalid)).toEqual([]);
|
|
});
|
|
|
|
it('der eingetragene Stand stimmt mit dem im Quelltext gemessenen ueberein', () => {
|
|
const mismatches: string[] = [];
|
|
for (const e of docEntries) {
|
|
const measured = standByKey.get(`${e.file}::${e.model}`);
|
|
if (measured && measured !== e.stand) {
|
|
mismatches.push(
|
|
`${e.file}::${e.model} — dokumentiert=${e.stand}, gemessen=${measured}`,
|
|
);
|
|
}
|
|
}
|
|
expect(mismatches, `Abweichender Stand (Dokument vs. Quelltext):\n${mismatches.join('\n')}`).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('keine doppelten (Datei, Modell)-Eintraege in der Tabelle', () => {
|
|
const seen = new Set<string>();
|
|
const duplicates: string[] = [];
|
|
for (const e of docEntries) {
|
|
const key = `${e.file}::${e.model}`;
|
|
if (seen.has(key)) duplicates.push(key);
|
|
seen.add(key);
|
|
}
|
|
expect(duplicates).toEqual([]);
|
|
});
|
|
|
|
it('jedes forTenant(-Vorkommen entspricht der erkannten Zuweisungsform `const X = forTenant(` oder steht in der begruendeten Ausnahmeliste', () => {
|
|
const violations: string[] = [];
|
|
for (const a of analyses) {
|
|
const unmatched = a.totalForTenantCalls - a.assignmentFormCalls;
|
|
if (unmatched > 0 && !FORTENANT_ASSIGNMENT_EXCEPTIONS.has(a.file)) {
|
|
violations.push(
|
|
`${a.file}: ${unmatched} forTenant(-Aufruf(e) ausserhalb der erkannten Zuweisungsform`,
|
|
);
|
|
}
|
|
}
|
|
expect(violations, violations.join('\n')).toEqual([]);
|
|
});
|
|
|
|
it('keine veraltete Ausnahmeliste: jede Datei in [...FORTENANT_ASSIGNMENT_EXCEPTIONS] existiert und traegt tatsaechlich mindestens einen forTenant(-Aufruf ausserhalb der Zuweisungsform (260911-e2s, Aufgabe 2)', () => {
|
|
const staleEntries: string[] = [];
|
|
const analysesByFile = new Map(analyses.map((a) => [a.file, a]));
|
|
for (const file of [...FORTENANT_ASSIGNMENT_EXCEPTIONS]) {
|
|
if (!existsSync(join(REPO_ROOT, file))) {
|
|
staleEntries.push(`${file}: Datei existiert nicht mehr`);
|
|
continue;
|
|
}
|
|
const analysis = analysesByFile.get(file);
|
|
const unmatched = analysis ? analysis.totalForTenantCalls - analysis.assignmentFormCalls : 0;
|
|
if (unmatched <= 0) {
|
|
staleEntries.push(
|
|
`${file}: enthaelt keinen forTenant(-Aufruf ausserhalb der erkannten Zuweisungsform mehr — die Ausnahme ist ueberholt und gehoert entfernt`,
|
|
);
|
|
}
|
|
}
|
|
expect(
|
|
staleEntries,
|
|
`Eine Ausnahmeliste, die Dateien nennt, die es nicht gibt oder die keinen Ausnahmefall mehr enthalten, ist dieselbe tote Verdrahtung, die 260911-e2s im Guard entfernt hat:\n${staleEntries.join('\n')}`,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('FORSYSTEM_ALLOWED_CALL_SITES: jede Datei mit forSystem(-Aufrufen steht in der Erlaubnisliste und die Zahl stimmt EXAKT (260914-eym, T-EYM-01)', () => {
|
|
const violations: string[] = [];
|
|
for (const a of analyses) {
|
|
if (a.totalForSystemCalls === 0) continue;
|
|
const allowed = FORSYSTEM_ALLOWED_CALL_SITES.get(a.file);
|
|
if (allowed === undefined) {
|
|
violations.push(
|
|
`${a.file}: ${a.totalForSystemCalls} forSystem(-Aufruf(e), Datei steht NICHT in FORSYSTEM_ALLOWED_CALL_SITES — ein Anfrageweg darf den Systemkontext nie rufen`,
|
|
);
|
|
} else if (allowed !== a.totalForSystemCalls) {
|
|
violations.push(
|
|
`${a.file}: gemessen ${a.totalForSystemCalls} forSystem(-Aufruf(e), erlaubt sind genau ${allowed}`,
|
|
);
|
|
}
|
|
}
|
|
expect(violations, violations.join('\n')).toEqual([]);
|
|
});
|
|
|
|
it('keine veraltete FORSYSTEM_ALLOWED_CALL_SITES: jede Datei existiert und traegt genau die genannte Zahl forSystem(-Aufrufe (260914-eym)', () => {
|
|
const staleEntries: string[] = [];
|
|
const analysesByFile = new Map(analyses.map((a) => [a.file, a]));
|
|
for (const [file, allowed] of FORSYSTEM_ALLOWED_CALL_SITES) {
|
|
if (!existsSync(join(REPO_ROOT, file))) {
|
|
staleEntries.push(`${file}: Datei existiert nicht mehr`);
|
|
continue;
|
|
}
|
|
const measured = analysesByFile.get(file)?.totalForSystemCalls ?? 0;
|
|
if (measured !== allowed) {
|
|
staleEntries.push(
|
|
`${file}: Erlaubnisliste nennt ${allowed}, gemessen ${measured} — der Eintrag ist ueberholt`,
|
|
);
|
|
}
|
|
}
|
|
expect(staleEntries, staleEntries.join('\n')).toEqual([]);
|
|
});
|
|
|
|
it('jedes forSystem(-Vorkommen folgt der Zuweisungsform `const X = forSystem(` — ohne Ausnahmeliste (260914-eym)', () => {
|
|
const violations: string[] = [];
|
|
for (const a of analyses) {
|
|
const unmatched = a.totalForSystemCalls - a.systemAssignmentFormCalls;
|
|
if (unmatched > 0) {
|
|
violations.push(`${a.file}: ${unmatched} forSystem(-Aufruf(e) ausserhalb der Zuweisungsform`);
|
|
}
|
|
}
|
|
expect(violations, violations.join('\n')).toEqual([]);
|
|
});
|
|
|
|
it('jede interaktive Transaktion (empfaenger.$transaction(async ...)) entspricht einer der erkannten Empfaengerformen oder steht in der begruendeten Ausnahmeliste (260909-jts, Befund B)', () => {
|
|
const violations: string[] = [];
|
|
for (const a of analyses) {
|
|
const unmatched = a.rawInteractiveTransactionCount - a.matchedInteractiveTransactionCount;
|
|
if (unmatched > 0 && !INTERACTIVE_TRANSACTION_EXCEPTIONS.has(a.file)) {
|
|
violations.push(
|
|
`${a.file}: ${unmatched} interaktive Transaktion(en) ausserhalb der erkannten Empfaengerformen`,
|
|
);
|
|
}
|
|
}
|
|
expect(violations, violations.join('\n')).toEqual([]);
|
|
});
|
|
|
|
it('jede include:/select:/_count:-Angabe liegt innerhalb eines erkannten Modellaufrufs oder die Datei steht in der begruendeten Ausnahmeliste (260911-mkj, WINDOWS #27)', () => {
|
|
const violations: string[] = [];
|
|
for (const a of analyses) {
|
|
const unmatched = a.rawRelationSpecCount - a.matchedRelationSpecCount;
|
|
if (unmatched > 0 && !RELATION_SPEC_EXCEPTIONS.has(a.file)) {
|
|
violations.push(
|
|
`${a.file}: ${unmatched} include:/select:/_count:-Angabe(n) ausserhalb eines erkannten Modellaufrufs`,
|
|
);
|
|
}
|
|
}
|
|
expect(violations, violations.join('\n')).toEqual([]);
|
|
});
|
|
|
|
it('keine veraltete RELATION_SPEC_EXCEPTIONS-Liste: jede Datei existiert und traegt tatsaechlich einen Ueberschuss include:/select:/_count: ausserhalb eines erkannten Modellaufrufs (260911-mkj)', () => {
|
|
const staleEntries: string[] = [];
|
|
const analysesByFile = new Map(analyses.map((a) => [a.file, a]));
|
|
for (const file of [...RELATION_SPEC_EXCEPTIONS]) {
|
|
if (!existsSync(join(REPO_ROOT, file))) {
|
|
staleEntries.push(`${file}: Datei existiert nicht mehr`);
|
|
continue;
|
|
}
|
|
const analysis = analysesByFile.get(file);
|
|
const unmatched = analysis ? analysis.rawRelationSpecCount - analysis.matchedRelationSpecCount : 0;
|
|
if (unmatched <= 0) {
|
|
staleEntries.push(
|
|
`${file}: enthaelt keinen Ueberschuss include:/select:/_count: mehr ausserhalb eines erkannten Modellaufrufs — die Ausnahme ist ueberholt und gehoert entfernt`,
|
|
);
|
|
}
|
|
}
|
|
expect(staleEntries, staleEntries.join('\n')).toEqual([]);
|
|
});
|
|
|
|
it('unresolvedRelationSpecValues ist ueberall leer: jeder include:/select:-Wert ist ein Objektliteral, `true` oder eine in derselben Datei definierte Konstante (260911-mkj)', () => {
|
|
const unresolved = analyses.flatMap((a) => a.unresolvedRelationSpecValues);
|
|
expect(unresolved, unresolved.join('\n')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('vierte Erkennung: Relationszugriffe (WINDOWS #27, 260911-mkj)', () => {
|
|
it('SCHEMA_RELATIONS pinnt die drei gemessenen Kern-Relationen', () => {
|
|
expect(SCHEMA_RELATIONS.get('Tenant')?.get('users')).toBe('User');
|
|
expect(SCHEMA_RELATIONS.get('Group')?.get('memberships')).toBe('GroupMembership');
|
|
expect(SCHEMA_RELATIONS.get('LdapConfig')?.get('fieldMappings')).toBe('LdapFieldMapping');
|
|
});
|
|
|
|
it('jedes Relationsziel in SCHEMA_RELATIONS ist ein Modellname, und SCHEMA_RELATIONS deckt alle `model`-Bloecke des Schemas ab', () => {
|
|
const modelNames = new Set(SCHEMA_RELATIONS.keys());
|
|
for (const [, fields] of SCHEMA_RELATIONS) {
|
|
for (const target of fields.values()) {
|
|
expect(modelNames.has(target)).toBe(true);
|
|
}
|
|
}
|
|
const schemaSource = readFileSync(SCHEMA_PATH, 'utf-8');
|
|
const modelLineCount = (schemaSource.match(/^model\s+\w+\s*\{/gm) || []).length;
|
|
expect(SCHEMA_RELATIONS.size).toBe(modelLineCount);
|
|
});
|
|
|
|
it('Probe A (WINDOWS #27, ungebunden): `include: { _count: { select: { users: true } } }` auf this.prisma.tenant liefert unboundModels mit tenant UND user, user NICHT in boundModels', () => {
|
|
const probe = `
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async run() {
|
|
return this.prisma.tenant.findMany({
|
|
include: { _count: { select: { users: true } } },
|
|
});
|
|
}
|
|
}
|
|
`;
|
|
const result = analyzeSource(probe, 'apps/api/src/probe/probe-a.service.ts');
|
|
expect([...result.unboundModels]).toEqual(expect.arrayContaining(['tenant', 'user']));
|
|
expect(result.boundModels.has('user')).toBe(false);
|
|
});
|
|
|
|
it('Probe B (WINDOWS #27, gebunden): derselbe Aufruf auf einem forTenant(-Klienten liefert boundModels mit tenant UND user, user/tenant NICHT in unboundModels', () => {
|
|
const probe = `
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async run(tenantId: string) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
|
return tenantPrisma.tenant.findMany({
|
|
include: { _count: { select: { users: true } } },
|
|
});
|
|
}
|
|
}
|
|
`;
|
|
const result = analyzeSource(probe, 'apps/api/src/probe/probe-b.service.ts');
|
|
expect([...result.boundModels]).toEqual(expect.arrayContaining(['tenant', 'user']));
|
|
expect(result.unboundModels.has('user')).toBe(false);
|
|
expect(result.unboundModels.has('tenant')).toBe(false);
|
|
});
|
|
|
|
it('reale ldap-Form: `include: { tenant: true, fieldMappings: true }` auf this.prisma.ldapConfig.findMany liefert unboundModels mit ldapConfig, tenant UND ldapFieldMapping', () => {
|
|
const probe = `
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async getAllActiveConfigs() {
|
|
return this.prisma.ldapConfig.findMany({
|
|
where: { isActive: true },
|
|
include: { tenant: true, fieldMappings: true },
|
|
});
|
|
}
|
|
}
|
|
`;
|
|
const result = analyzeSource(probe, 'apps/api/src/probe/probe-ldap.service.ts');
|
|
expect([...result.unboundModels]).toEqual(
|
|
expect.arrayContaining(['ldapConfig', 'tenant', 'ldapFieldMapping']),
|
|
);
|
|
});
|
|
|
|
it('verschachtelte where-Kette auf einem forTenant(-Klienten liefert boundModels mit moduleGrant, group UND groupMembership', () => {
|
|
const probe = `
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async run(tenantId: string, userId: string) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
|
return tenantPrisma.moduleGrant.findMany({
|
|
where: { tenantId, group: { memberships: { some: { userId } } } },
|
|
});
|
|
}
|
|
}
|
|
`;
|
|
const result = analyzeSource(probe, 'apps/api/src/probe/probe-chain.service.ts');
|
|
expect([...result.boundModels]).toEqual(
|
|
expect.arrayContaining(['moduleGrant', 'group', 'groupMembership']),
|
|
);
|
|
});
|
|
|
|
it('Negativprobe (skalarer select + Zeichenkette mit Klammern): liefert unboundModels GENAU {group} — kein Relationsmodell, die Klammern in der Zeichenkette zerreissen den Argumentbereich nicht', () => {
|
|
const probe = `
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async run() {
|
|
return this.prisma.group.findMany({
|
|
select: { id: true, name: true },
|
|
where: { name: { contains: '{(' } },
|
|
});
|
|
}
|
|
}
|
|
`;
|
|
const result = analyzeSource(probe, 'apps/api/src/probe/probe-negative.service.ts');
|
|
expect([...result.unboundModels].sort()).toEqual(['group']);
|
|
});
|
|
|
|
it('`_count: true` liefert ALLE Relationen von Tenant (user, ldapConfig, group, moduleGrant)', () => {
|
|
const probe = `
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async run() {
|
|
return this.prisma.tenant.findMany({ include: { _count: true } });
|
|
}
|
|
}
|
|
`;
|
|
const result = analyzeSource(probe, 'apps/api/src/probe/probe-count-true.service.ts');
|
|
expect([...result.unboundModels]).toEqual(
|
|
expect.arrayContaining(['user', 'ldapConfig', 'group', 'moduleGrant']),
|
|
);
|
|
});
|
|
|
|
it('unbekannter Empfaenger (Funktionsparameter) faellt in Waechter (a): rawRelationSpecCount 1, matchedRelationSpecCount 0, kein user in beiden Mengen', () => {
|
|
const probe = `
|
|
class ProbeService {
|
|
async run(client: any) {
|
|
return client.tenant.findMany({ include: { users: true } });
|
|
}
|
|
}
|
|
`;
|
|
const result = analyzeSource(probe, 'apps/api/src/probe/probe-unknown.service.ts');
|
|
expect(result.rawRelationSpecCount).toBe(1);
|
|
expect(result.matchedRelationSpecCount).toBe(0);
|
|
expect(result.unboundModels.has('user')).toBe(false);
|
|
expect(result.boundModels.has('user')).toBe(false);
|
|
});
|
|
|
|
it('Konstante als select-Wert wird aufgeloest; eine nicht definierte Kennung landet in unresolvedRelationSpecValues', () => {
|
|
const resolvedProbe = `
|
|
const SAFE = { id: true, users: true };
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async run() {
|
|
return this.prisma.tenant.findMany({ select: SAFE });
|
|
}
|
|
}
|
|
`;
|
|
const resolvedResult = analyzeSource(resolvedProbe, 'apps/api/src/probe/probe-constant.service.ts');
|
|
expect(resolvedResult.unboundModels.has('user')).toBe(true);
|
|
|
|
const unresolvedProbe = `
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async run() {
|
|
return this.prisma.tenant.findMany({ select: IMPORTED_SELECT });
|
|
}
|
|
}
|
|
`;
|
|
const unresolvedResult = analyzeSource(unresolvedProbe, 'apps/api/src/probe/probe-unresolved.service.ts');
|
|
expect(unresolvedResult.unresolvedRelationSpecValues).toHaveLength(1);
|
|
expect(unresolvedResult.unresolvedRelationSpecValues[0]).toContain('IMPORTED_SELECT');
|
|
});
|
|
it('Probe C (260914-eym, Systemkontext, Empfaengername absichtlich nicht systemPrisma): `include: { fieldMappings: true }` auf einem forSystem(-Klienten liefert systemModels mit ldapConfig UND ldapFieldMapping, beide weder in bound noch unbound, Stand system-gebunden', () => {
|
|
const probe = `
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async getAllActiveConfigs() {
|
|
const sysPrisma = forSystem(this.prisma) as any;
|
|
return sysPrisma.ldapConfig.findMany({
|
|
where: { isActive: true },
|
|
include: { fieldMappings: true },
|
|
});
|
|
}
|
|
}
|
|
`;
|
|
const result = analyzeSource(probe, 'apps/api/src/probe/probe-c.service.ts');
|
|
expect([...result.systemModels].sort()).toEqual(['ldapConfig', 'ldapFieldMapping']);
|
|
expect(result.boundModels.size).toBe(0);
|
|
expect(result.unboundModels.size).toBe(0);
|
|
expect(result.totalForSystemCalls).toBe(1);
|
|
expect(result.systemAssignmentFormCalls).toBe(1);
|
|
const stand = computeStandByKey([result]);
|
|
expect(stand.get('apps/api/src/probe/probe-c.service.ts::ldapConfig')).toBe('system-gebunden');
|
|
expect(stand.get('apps/api/src/probe/probe-c.service.ts::ldapFieldMapping')).toBe('system-gebunden');
|
|
});
|
|
|
|
it('Probe D (260914-eym, Vorrang): system + forTenant auf demselben Modell bleibt system-gebunden; system + this.prisma auf demselben Modell wird gemischt', () => {
|
|
const systemPlusBound = `
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async readAll() {
|
|
const sysPrisma = forSystem(this.prisma) as any;
|
|
return sysPrisma.ldapConfig.findMany();
|
|
}
|
|
async writeOne(tenantId: string) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
|
return tenantPrisma.ldapConfig.update({ where: { id: 'x' }, data: {} });
|
|
}
|
|
}
|
|
`;
|
|
const r1 = analyzeSource(systemPlusBound, 'apps/api/src/probe/probe-d1.service.ts');
|
|
expect(computeStandByKey([r1]).get('apps/api/src/probe/probe-d1.service.ts::ldapConfig')).toBe(
|
|
'system-gebunden',
|
|
);
|
|
|
|
const systemPlusUnbound = `
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async readAll() {
|
|
const sysPrisma = forSystem(this.prisma) as any;
|
|
return sysPrisma.ldapConfig.findMany();
|
|
}
|
|
async readRaw() {
|
|
return this.prisma.ldapConfig.findMany();
|
|
}
|
|
}
|
|
`;
|
|
const r2 = analyzeSource(systemPlusUnbound, 'apps/api/src/probe/probe-d2.service.ts');
|
|
expect(computeStandByKey([r2]).get('apps/api/src/probe/probe-d2.service.ts::ldapConfig')).toBe(
|
|
'gemischt',
|
|
);
|
|
});
|
|
|
|
it('Probe E (260914-eym, Zuweisungsform): `forSystem(this.prisma).x.findMany()` ohne Zuweisung zaehlt totalForSystemCalls 1, systemAssignmentFormCalls 0', () => {
|
|
const probe = `
|
|
class ProbeService {
|
|
constructor(private readonly prisma: any) {}
|
|
async run() {
|
|
return forSystem(this.prisma).ldapConfig.findMany();
|
|
}
|
|
}
|
|
`;
|
|
const result = analyzeSource(probe, 'apps/api/src/probe/probe-e.service.ts');
|
|
expect(result.totalForSystemCalls).toBe(1);
|
|
expect(result.systemAssignmentFormCalls).toBe(0);
|
|
});
|
|
});
|