import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { forSystem, forTenant, withTenantTransaction } from './prisma-tenant.extension'; /** * Prueft ohne laufende Datenbank die FORM des Aufrufs, nicht seinen mit * Produktionscode erzeugten Inhalt (WINDOWS #20, Lehre aus dem * tautologischen Test in STATE.md — Pitfall "Tautologischer Test"): ein * vorgetaeuschter Client zeichnet auf, dass `$transaction` mit einem Feld * aus zwei Eintraegen aufgerufen wird, dass der Rueckgabewert der zweite * Eintrag ist, und dass `query` waehrend des Aufbaus dieses Feldes genau * einmal beruehrt wird. */ const EXTENSION_SOURCE_PATH = join(__dirname, 'prisma-tenant.extension.ts'); /** * Der Kopfkommentar der Datei erklaert absichtlich das ALTE, defekte * Verhalten (inklusive $executeRawUnsafe und der interaktiven * $transaction(async ...)-Form) als Beleg fuer die Reparatur. Eine * Volltextpruefung auf den Quelltext wuerde deshalb faelschlich fehlschlagen * — Block- und Zeilenkommentare muessen vor der Pruefung des tatsaechlichen * Codes herausgefiltert werden (gleiches Vorgehen wie in * auth-lookup-functions.spec.ts fuer die Migrations-SQL). */ function stripComments(source: string): string { return source .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/^\s*\/\/.*$/gm, ''); } /** * Schneidet den Quelltext einer einzelnen Top-Level-`export function` * heraus (bis zur naechsten `export function` oder zum Dateiende). Seit * 260909-jts (Aufgabe 2) enthaelt diese Datei ZWEI Funktionen mit * unterschiedlichem, jeweils gemessen begruendetem Transaktionsmuster — * die Array-Form-Garantie unten gilt nachweislich nur fuer `forTenant()` * selbst, nicht mehr fuer die gesamte Datei. */ function extractFunctionSource(source: string, functionName: string): string { const startMarker = `export function ${functionName}`; const startIdx = source.indexOf(startMarker); if (startIdx === -1) return ''; const rest = source.slice(startIdx); const nextExportIdx = rest.indexOf('\nexport function ', startMarker.length); return nextExportIdx === -1 ? rest : rest.slice(0, nextExportIdx); } describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => { it('ruft $transaction mit einem Feld aus genau zwei Eintraegen auf', async () => { const transactionCalls: unknown[] = []; const fakeQueryResult = { id: 'row-1' }; const fakePrisma: any = { $transaction: vi.fn((arg: unknown) => { transactionCalls.push(arg); expect(Array.isArray(arg)).toBe(true); expect((arg as unknown[]).length).toBe(2); return Promise.resolve(['set_config-result', fakeQueryResult]); }), $extends: (config: any) => { // Reproduziert nur den Teil der echten $extends-API, den // $allOperations braucht — kein echter Prisma-Client noetig. return { async __invoke(args: unknown, query: (args: unknown) => unknown) { return config.query.$allOperations({ args, query }); }, }; }, $executeRaw: vi.fn((_strings: TemplateStringsArray, ..._values: unknown[]) => { return 'set-config-promise'; }), }; const scoped = forTenant(fakePrisma, 'tenant-a') as any; let queryCallCount = 0; const query = (args: unknown) => { queryCallCount += 1; return fakeQueryResult; }; const result = await scoped.__invoke({ where: { id: 'row-1' } }, query); expect(fakePrisma.$transaction).toHaveBeenCalledTimes(1); expect(transactionCalls).toHaveLength(1); expect((transactionCalls[0] as unknown[]).length).toBe(2); // Rueckgabewert ist der ZWEITE Eintrag des Felds (der Query-Aufruf), // nicht das Ergebnis von set_config. expect(result).toBe(fakeQueryResult); // query() wurde beim Aufbau des Felds genau einmal beruehrt. expect(queryCallCount).toBe(1); }); it('setzt den Mandantenkontext ueber ein getaggtes $executeRaw-Template, nicht ueber zusammengebauten Text', async () => { const fakePrisma: any = { $transaction: vi.fn((arg: unknown) => Promise.resolve(['set-config-result', 'query-result'])), $extends: (config: any) => ({ async __invoke(args: unknown, query: (args: unknown) => unknown) { return config.query.$allOperations({ args, query }); }, }), $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { // Ein getaggtes Template liefert ein Array von Textstuecken plus die // interpolierten Werte getrennt — genau das ist der Beleg dafuer, // dass der Mandantenwert parametrisiert und nicht in den SQL-Text // eingebaut wird. expect(Array.isArray(strings)).toBe(true); expect(values).toContain('tenant-with-quote-\' OR 1=1'); return 'set-config-promise'; }), }; const scoped = forTenant(fakePrisma, "tenant-with-quote-' OR 1=1") as any; await scoped.__invoke({}, () => 'query-result'); expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1); }); it('verwendet im tatsaechlichen Code (ohne Kommentare) kein $executeRawUnsafe mehr', () => { const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8')); expect(source).not.toContain('$executeRawUnsafe'); }); it('nutzt im tatsaechlichen Code die Array-Form von $transaction (kein interaktiver async-Callback) — innerhalb von forTenant() selbst', () => { const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8')); const forTenantSource = extractFunctionSource(source, 'forTenant'); expect(forTenantSource).not.toBe(''); expect(forTenantSource).toMatch(/\$transaction\(\s*\[/); expect(forTenantSource).not.toMatch(/\$transaction\(\s*async/); }); // Benutzerdimension (Etappe 3b, 260911-nke): drei neue Tests fuer den // optionalen dritten Parameter `userId`. it('ohne userId: die Parameterliste des Templates enthaelt den Leerstring an zweiter Stelle, der Template-Text nennt app.current_user', async () => { const fakePrisma: any = { $transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])), $extends: (config: any) => ({ async __invoke(args: unknown, query: (args: unknown) => unknown) { return config.query.$allOperations({ args, query }); }, }), $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { expect(strings.join('')).toContain('app.current_user'); expect(values[1]).toBe(''); return 'set-config-promise'; }), }; const scoped = forTenant(fakePrisma, 'tenant-a') as any; await scoped.__invoke({}, () => 'query-result'); expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1); }); it('mit userId: der Wert geht als Template-PARAMETER (values), nicht im Text (T-02-05 bleibt gewahrt)', async () => { const fakePrisma: any = { $transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])), $extends: (config: any) => ({ async __invoke(args: unknown, query: (args: unknown) => unknown) { return config.query.$allOperations({ args, query }); }, }), $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { expect(Array.isArray(strings)).toBe(true); expect(strings.join('')).not.toContain("user-with-quote-' OR 1=1"); expect(values).toContain("user-with-quote-' OR 1=1"); return 'set-config-promise'; }), }; const scoped = forTenant(fakePrisma, 'tenant-a', "user-with-quote-' OR 1=1") as any; await scoped.__invoke({}, () => 'query-result'); expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1); }); it('mit userId: das $transaction-Feld behaelt weiterhin genau zwei Eintraege', async () => { const transactionCalls: unknown[] = []; const fakePrisma: any = { $transaction: vi.fn((arg: unknown) => { transactionCalls.push(arg); return Promise.resolve(['set-config-result', 'query-result']); }), $extends: (config: any) => ({ async __invoke(args: unknown, query: (args: unknown) => unknown) { return config.query.$allOperations({ args, query }); }, }), $executeRaw: vi.fn(() => 'set-config-promise'), }; const scoped = forTenant(fakePrisma, 'tenant-a', 'user-a') as any; await scoped.__invoke({}, () => 'query-result'); expect(transactionCalls).toHaveLength(1); expect((transactionCalls[0] as unknown[]).length).toBe(2); }); // Systemkontext (Etappe 3c, 260914-eym): forTenant() setzt app.system_context // AUSDRUECKLICH auf den Leerstring — als Literal im Template-Text, nicht als // Parameter (die Parameterliste bleibt [tenantId, userId ?? '']). it('setzt app.system_context im Template-Text ausdruecklich auf den Leerstring (kein Erben aus einem Systemkontext, 260914-eym)', async () => { const fakePrisma: any = { $transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])), $extends: (config: any) => ({ async __invoke(args: unknown, query: (args: unknown) => unknown) { return config.query.$allOperations({ args, query }); }, }), $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { const text = strings.join(''); expect(text).toContain("set_config('app.system_context', '', true)"); expect(values).toEqual(['tenant-a', '']); return 'set-config-promise'; }), }; const scoped = forTenant(fakePrisma, 'tenant-a') as any; await scoped.__invoke({}, () => 'query-result'); expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1); }); }); describe('forSystem() — Systemkontext fuer Hintergrunddienste (Etappe 3c, 260914-eym)', () => { it("setzt alle drei Variablen als Literale im Template-Text ('true'/''/''), values leer, $transaction-Feld mit genau zwei Eintraegen", async () => { const transactionCalls: unknown[] = []; const fakeQueryResult = [{ id: 'row-1' }]; const fakePrisma: any = { $transaction: vi.fn((arg: unknown) => { transactionCalls.push(arg); return Promise.resolve(['set-config-result', fakeQueryResult]); }), $extends: (config: any) => ({ async __invoke(args: unknown, query: (args: unknown) => unknown) { return config.query.$allOperations({ args, query }); }, }), $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { const text = strings.join(''); expect(text).toContain("set_config('app.system_context', 'true', true)"); expect(text).toContain("set_config('app.current_tenant', '', true)"); expect(text).toContain("set_config('app.current_user', '', true)"); expect(values).toEqual([]); return 'set-config-promise'; }), }; const system = forSystem(fakePrisma) as any; let queryCallCount = 0; const result = await system.__invoke({ where: { isActive: true } }, () => { queryCallCount += 1; return fakeQueryResult; }); expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1); expect(transactionCalls).toHaveLength(1); expect(Array.isArray(transactionCalls[0])).toBe(true); expect((transactionCalls[0] as unknown[]).length).toBe(2); expect(result).toBe(fakeQueryResult); expect(queryCallCount).toBe(1); }); it('nutzt im tatsaechlichen Code die Array-Form von $transaction — innerhalb von forSystem() selbst (WINDOWS-#20-Bauart)', () => { const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8')); const forSystemSource = extractFunctionSource(source, 'forSystem'); expect(forSystemSource).not.toBe(''); expect(forSystemSource).toMatch(/\$transaction\(\s*\[/); expect(forSystemSource).not.toMatch(/\$transaction\(\s*async/); expect(forSystemSource).not.toContain('$executeRawUnsafe'); }); }); describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebundenen Client (260909-jts, Aufgabe 1)', () => { it('nutzt im tatsaechlichen Code die interaktive Callback-Form auf tx, nicht die Array-Form (gemessen: einzige Form, die die Lastprobe bestand)', () => { const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8')); const withTenantTransactionSource = extractFunctionSource(source, 'withTenantTransaction'); expect(withTenantTransactionSource).not.toBe(''); expect(withTenantTransactionSource).toMatch(/\$transaction\(async/); }); it('setzt den Mandantenkontext als erste Anweisung DIREKT AUF tx, nicht auf dem aeusseren Client', async () => { const setConfigCalls: unknown[] = []; const fakeTx: any = { $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { setConfigCalls.push(values); return Promise.resolve(1); }), }; const fakePrisma: any = { $transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)), // Der aeussere Client darf NIE direkt fuer set_config oder die // eigentliche Abfrage herangezogen werden — nur $transaction selbst. $executeRaw: vi.fn(() => { throw new Error('set_config darf nicht auf dem aeusseren Client laufen'); }), }; const result = await withTenantTransaction(fakePrisma, 'tenant-a', async (tx) => { expect(tx).toBe(fakeTx); return 'fn-result'; }); expect(fakePrisma.$transaction).toHaveBeenCalledTimes(1); expect(fakeTx.$executeRaw).toHaveBeenCalledTimes(1); expect(setConfigCalls).toEqual([['tenant-a']]); expect(result).toBe('fn-result'); }); it('reicht denselben tx-Parameter an fn weiter, sodass mehrere Schritte auf derselben Verbindung laufen', async () => { const touchedByFn: unknown[] = []; const fakeTx: any = { $executeRaw: vi.fn(() => Promise.resolve(1)), group: { create: vi.fn(() => Promise.resolve({ id: 'g1' })) }, user: { findMany: vi.fn(() => Promise.resolve([])) }, }; const fakePrisma: any = { $transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)), }; await withTenantTransaction(fakePrisma, 'tenant-a', async (tx) => { touchedByFn.push(await tx.group.create({ data: {} })); touchedByFn.push(await tx.user.findMany({ where: {} })); return null; }); expect(fakeTx.group.create).toHaveBeenCalledTimes(1); expect(fakeTx.user.findMany).toHaveBeenCalledTimes(1); expect(touchedByFn).toEqual([{ id: 'g1' }, []]); }); it('setzt den Mandantenkontext ueber ein getaggtes Template, nicht ueber zusammengebauten Text (T-02-05)', async () => { const fakeTx: any = { $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { expect(Array.isArray(strings)).toBe(true); expect(values).toContain("tenant-with-quote-' OR 1=1"); return Promise.resolve(1); }), }; const fakePrisma: any = { $transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)), }; await withTenantTransaction(fakePrisma, "tenant-with-quote-' OR 1=1", async () => 'ok'); expect(fakeTx.$executeRaw).toHaveBeenCalledTimes(1); }); it('setzt app.system_context im Template-Text auf tx ausdruecklich auf den Leerstring (260914-eym)', async () => { const fakeTx: any = { $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { const text = strings.join(''); expect(text).toContain("set_config('app.current_tenant', "); expect(text).toContain("set_config('app.system_context', '', true)"); expect(values).toEqual(['tenant-a']); return Promise.resolve(1); }), }; const fakePrisma: any = { $transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)), }; await withTenantTransaction(fakePrisma, 'tenant-a', async () => 'ok'); expect(fakeTx.$executeRaw).toHaveBeenCalledTimes(1); }); });