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.` 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 = forTenant(` und sucht danach `.`. * 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. ... })`) — * weder `this.prisma.` noch `.` 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 `.$transaction( * async (tx) => ...)`, oder ueber das Hilfsmittel `withTenantTransaction( * , tenantId, async (tx) => ...)` aus * `prisma-tenant.extension.ts`) und sucht danach `.`. 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.` noch `.` noch `.` 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 = forSystem(` und * sucht danach `.` — 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 = 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([]); /** * 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([]); /** * 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(['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([ ['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; boundModels: Set; systemModels: Set; 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.` 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> { 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>(); for (const { name, body } of blocks) { const fieldMap = new Map(); 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.` * (260911-mkj, WINDOWS #27). */ const CLIENT_NAME_TO_MODEL = new Map( [...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, ): 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(); 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(); 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(/(? = // forSystem(` und danach `.` — 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(); 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(/(?.$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: `.$transaction(async () => ...)`. // 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(, tenantId, async () // => ...)` 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([ 'this.prisma', ...boundNames, ...systemNames, ...txBoundParams, ...txUnboundParams, ]); const boundReceiverNames = new Set([...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 { const standByKey = new Map(); 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(); 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); }); });