import { 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. */ const API_SRC_DIR = join(__dirname, '..'); const REPO_ROOT = join(__dirname, '../../../..'); const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.md'); /** * Dateien, in denen ein `forTenant(`-Aufruf bewusst NICHT der erkannten * `const = forTenant(`-Zuweisungsform folgt. Beide veroeffentlichen * den gebundenen Client auf dem Anfrageobjekt (`req.tenantPrisma = ...`) * statt ihn einer lokalen Konstante zuzuweisen — genau dieser Weg ist die * offene Architekturfrage aus docs/mandantentrennung-zugriffsklassifikation.md * ("Was diese Etappe NICHT entscheidet"), hier bewusst offen gehalten statt * stillschweigend als Erkennungsluecke durchzurutschen. */ const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set([ 'apps/api/src/tenant/tenant.middleware.ts', 'apps/api/src/tenant/tenant.guard.ts', ]); /** * 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([]); const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const; type Stand = (typeof STAND_TOKENS)[number]; interface FileAnalysis { file: string; unboundModels: Set; boundModels: Set; totalForTenantCalls: number; assignmentFormCalls: number; rawInteractiveTransactionCount: number; matchedInteractiveTransactionCount: number; } 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 analyzeFile(absPath: string, relPath: string): FileAnalysis { const source = stripComments(readFileSync(absPath, 'utf-8')); 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(/(?.$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; // 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); 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; const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g'); for (const mm of source.matchAll(re)) { if (mm[1]) boundModels.add(mm[1]); } } return { file: relPath, unboundModels, boundModels, totalForTenantCalls, assignmentFormCalls: assignmentMatches.length, rawInteractiveTransactionCount, matchedInteractiveTransactionCount: directInteractiveMatches.length, }; } 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]); 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]); for (const model of allModels) { const isBound = a.boundModels.has(model); const isUnbound = a.unboundModels.has(model); const stand: Stand = isBound && isUnbound ? 'gemischt' : isBound ? 'gebunden' : 'ungebunden'; 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 drei gueltigen Stand-Werte', () => { 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('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([]); }); });