3d645674f0
- Helfer forSystem(prisma) in prisma-tenant.extension.ts (Array-Form, setzt app.system_context='true' und die beiden anderen Variablen ausdruecklich leer); forTenant()/withTenantTransaction() setzen app.system_context='' als Literal (4 neue Spec-Tests) - Migration 20260914120000_rls_system_context_read: is_system_context() (COALESCE, STABLE) und system_read_policy FOR SELECT auf DkvModuleConfig, LdapConfig, LdapFieldMapping, TenderMatch, TenderSavedSearch — lokal angewendet (36 Migrationen, pg_proc 1, 5 system_read_policy, 34 Regeln) - migration-sql.spec.ts: describe-Block fuer die neue Migration (6 Tests) - rls-scratch-check.mjs: Funktion aus der Migration geschnitten, forSystemQuery/buildInlineSystemClient, Reset in forTenantQuery/ buildInlineExtendedClient, runSystemContextChecks (4 Funktionsfaelle + 9 Kennungen DkvModuleConfig) -> Alle 216 Pruefungen bestanden - rls-access-inventory.spec.ts: fuenfte Erkennungsform const X = forSystem(, Stand system-gebunden mit Vorrangregel, FORSYSTEM_ALLOWED_CALL_SITES (exakte Zahl je Datei, 3 Tests), Proben C/D/E - DKV: loadActiveConfigsForScheduler() ueber forSystem (findMany isActive, CONFIG_SAFE_SELECT, orderBy tenantId); DkvSchedulerService mit Auftrag je Mandant dkv-inbox-poll:<tenantId>, activeTenantId ersatzlos entfernt, setInterval/stopJob je Mandant, registeredTenantIds(); Controller stopJob(tenantId); neue dkv-scheduler.service.spec.ts (7 Tests), dkv.service.spec.ts Tests 6/7 umgestellt - Klassifikation: dkv.service.ts/dkvModuleConfig system-gebunden, Header mit fuenfter Erkennungsform und viertem Stand-Wert - Baseline: 63 Dateien / 1051 Tests, tsc 0, Werkzeug 216 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
371 lines
16 KiB
TypeScript
371 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|