feat(quick-260914-eym): forSystem(), is_system_context(), Systemleseregel auf fuenf Tabellen, DKV-Planer je Mandant — ein Pfad (WINDOWS #21)

- 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
This commit is contained in:
2026-09-14 11:32:54 +02:00
parent 02016e19eb
commit 3d645674f0
12 changed files with 1272 additions and 139 deletions
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { forTenant, withTenantTransaction } from './prisma-tenant.extension';
import { forSystem, forTenant, withTenantTransaction } from './prisma-tenant.extension';
/**
* Prueft ohne laufende Datenbank die FORM des Aufrufs, nicht seinen mit
@@ -199,6 +199,80 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
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)', () => {
@@ -273,6 +347,24 @@ describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebund
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);
});
});
+72 -2
View File
@@ -146,13 +146,83 @@ import { PrismaClient } from '@prisma/client';
* KEINEN dritten Parameter: kein Nutzer-CRUD-Aufrufer nutzt diese Funktion
* (nur `groups`, ein Verwaltungsweg) — ein unbenutzter Parameter waere
* Spekulation ohne heutigen Aufrufer.
*
* SYSTEMKONTEXT (Etappe 3c, 260914-eym):
*
* `forSystem(prisma)` ist ein SCHWESTERHELFER von `forTenant()`, kein
* vierter Parameter — die UMKEHRUNG der 3b-Begruendung oben, ausdruecklich
* so gewollt: der Systemkontext ist eine EIGENE Zugriffsklasse (liest ueber
* ALLE Mandanten), und genau deshalb bekommt der Detektor der
* Bestandsaufnahme (`rls-access-inventory.spec.ts`) fuer ihn eine EIGENE,
* fuenfte Erkennungsform (`const X = forSystem(`) mit dem Stand
* `system-gebunden`. Ein vierter Parameter an `forTenant()` haette diese
* Klasse fuer den Detektor UNSICHTBAR gemacht — ein ueber alle Mandanten
* lesender Zugriff waere als `gebunden` gezaehlt worden.
*
* Alle DREI Sitzungsvariablen werden in JEDER Form gesetzt:
* `forSystem()` setzt `app.system_context = 'true'` und AUSDRUECKLICH
* `app.current_tenant = ''` und `app.current_user = ''`; `forTenant()` und
* `withTenantTransaction()` setzen umgekehrt AUSDRUECKLICH
* `app.system_context = ''`. Kein Kontext darf vom anderen erben.
* `set_config(..., true)` (transaktionslokal) ist das ERSTE Netz — deshalb
* sieht `forTenant(A)` unmittelbar nach `forSystem` auf demselben Client
* nur A (gemessen im Werkzeug: `<slug>-fortenant-a-nach-systemkontext-nur-a`,
* `<slug>-is-system-context-unter-fortenant-false`). Der ausdrueckliche
* Reset ist das ZWEITE Netz fuer eine hypothetische `local=false`-Aenderung
* — durch Rueckbau falsifiziert (Reset entfernt UND local=false -> rot).
* Alle Werte von `forSystem()` stehen als LITERALE im Template-Text (es
* fliesst nichts Variables ein); in `forTenant()` bleibt die Parameterliste
* `[tenantId, userId ?? '']` unveraendert.
*
* Unter Systemkontext kann NUR GELESEN werden: die Regel
* `system_read_policy` (Migration 20260914120000_rls_system_context_read)
* ist `FOR SELECT`; permissive Regeln werden ODER-verknuepft, fuer
* INSERT/UPDATE/DELETE gilt weiter NUR die Mandantenregel, und unter
* Systemkontext ist `current_tenant_id()` der Leerstring — kein Mandant
* passt. Gemessen: INSERT -> SQLSTATE 42501, `updateMany`/`deleteMany` ->
* count 0, `update` per id -> P2025.
*
* Wer `forSystem()` rufen darf: AUSSCHLIESSLICH die in
* `FORSYSTEM_ALLOWED_CALL_SITES` (rls-access-inventory.spec.ts) genannten
* Stellen mit der dort genannten EXAKTEN Zahl je Datei. Jeder weitere
* Aufruf — in einer fremden Datei oder als zweiter in einer erlaubten —
* macht die Spec rot. Ein Anfrageweg darf diesen Helfer NIE rufen.
*/
export function forTenant(prisma: PrismaClient, tenantId: string, userId?: string) {
return prisma.$extends({
query: {
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
const setContext = (prisma as any)
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`;
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true), set_config('app.system_context', '', true)`;
return (prisma as any)
.$transaction([setContext, query(args)])
.then((results: any[]) => results[1]);
},
},
});
}
/**
* Systemkontext (Etappe 3c, 260914-eym): ein Client, der ueber ALLE
* Mandanten LIEST — fuer die Hintergrunddienste, die einmal ueber alles
* lesen und dann je Mandant gebunden handeln (DKV-Planer, ldap,
* tender-digest, tender-matching). Gleiche Array-Form-`$transaction`-Bauart
* wie `forTenant()` (Kontext und Abfrage auf EINER Verbindung, WINDOWS #20).
*
* EINE getaggte Anweisung setzt `app.system_context = 'true'` und
* AUSDRUECKLICH `app.current_tenant = ''` und `app.current_user = ''` —
* alle drei als Literale im Template-Text, es fliesst nichts Variables ein.
* Nur Lesen ist geoeffnet (`system_read_policy ... FOR SELECT`); jedes
* Schreiben scheitert an der Mandantenregel. Aufrufer: ausschliesslich die
* Stellen aus `FORSYSTEM_ALLOWED_CALL_SITES` (siehe Kopfkommentar).
*/
export function forSystem(prisma: PrismaClient) {
return prisma.$extends({
query: {
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
const setContext = (prisma as any)
.$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`;
return (prisma as any)
.$transaction([setContext, query(args)])
@@ -186,7 +256,7 @@ export function withTenantTransaction<T>(
fn: (tx: any) => Promise<T>,
): Promise<T> {
return (prisma as any).$transaction(async (tx: any) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.system_context', '', true)`;
return fn(tx);
});
}
+198 -12
View File
@@ -50,6 +50,23 @@ import { describe, expect, it } from 'vitest';
* 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, '..');
@@ -109,15 +126,33 @@ const INTERACTIVE_TRANSACTION_EXCEPTIONS = new Set<string>([]);
*/
const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill-tender-source.ts']);
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const;
/**
* 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. Aufgabe 1 traegt den
* ersten Aufrufer (DKV-Planer-Startpfad); Aufgabe 2 erweitert auf die vier
* Dateien der sechs Hintergrunddienst-Faelle.
*/
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
['apps/api/src/dkv/dkv.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;
@@ -303,9 +338,7 @@ interface RelationScanFrame {
function scanRelationKeys(
region: string,
initialContext: string,
isBound: boolean,
unboundModels: Set<string>,
boundModels: Set<string>,
targetModels: Set<string>,
): void {
const stack: RelationScanFrame[] = [{ context: initialContext, enteringKey: null }];
let pendingContext: string | null = null;
@@ -333,7 +366,7 @@ function scanRelationKeys(
const relTarget = keyName ? SCHEMA_RELATIONS.get(currentContext)?.get(keyName) : undefined;
if (keyName && relTarget) {
const clientName = lowerFirst(relTarget);
(isBound ? boundModels : unboundModels).add(clientName);
targetModels.add(clientName);
pendingContext = relTarget;
pendingKey = keyName;
} else if (keyName === '_count') {
@@ -344,7 +377,7 @@ function scanRelationKeys(
const relations = SCHEMA_RELATIONS.get(currentContext);
if (relations) {
for (const target of relations.values()) {
(isBound ? boundModels : unboundModels).add(lowerFirst(target));
targetModels.add(lowerFirst(target));
}
}
}
@@ -383,6 +416,20 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis {
// 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
@@ -460,6 +507,7 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis {
const allReceiverNames = new Set<string>([
'this.prisma',
...boundNames,
...systemNames,
...txBoundParams,
...txUnboundParams,
]);
@@ -478,7 +526,13 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis {
const modelClientName = m[2];
if (!receiver || !modelClientName || m.index === undefined) continue;
const isBound = boundReceiverNames.has(receiver);
// 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;
@@ -502,7 +556,7 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis {
const initialContext = CLIENT_NAME_TO_MODEL.get(modelClientName);
if (initialContext) {
scanRelationKeys(region, initialContext, isBound, unboundModels, boundModels);
scanRelationKeys(region, initialContext, targetModels);
}
}
@@ -515,8 +569,11 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis {
file: relPath,
unboundModels,
boundModels,
systemModels,
totalForTenantCalls,
assignmentFormCalls: assignmentMatches.length,
totalForSystemCalls,
systemAssignmentFormCalls: systemAssignmentMatches.length,
rawInteractiveTransactionCount,
matchedInteractiveTransactionCount: directInteractiveMatches.length,
rawRelationSpecCount,
@@ -548,7 +605,7 @@ interface AccessSite {
function findAccessSites(analyses: FileAnalysis[]): AccessSite[] {
const sites: AccessSite[] = [];
for (const a of analyses) {
const allModels = new Set([...a.unboundModels, ...a.boundModels]);
const allModels = new Set([...a.unboundModels, ...a.boundModels, ...a.systemModels]);
for (const model of allModels) {
sites.push({ file: a.file, model });
}
@@ -559,11 +616,19 @@ function findAccessSites(analyses: FileAnalysis[]): AccessSite[] {
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]);
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 stand: Stand = isBound && isUnbound ? 'gemischt' : isBound ? 'gebunden' : 'ungebunden';
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);
}
}
@@ -634,7 +699,7 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
expect(invalid, JSON.stringify(invalid)).toEqual([]);
});
it('jeder Eintrag traegt einen der drei gueltigen Stand-Werte', () => {
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([]);
});
@@ -700,6 +765,53 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
).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) {
@@ -911,4 +1023,78 @@ class ProbeService {
expect(unresolvedResult.unresolvedRelationSpecValues).toHaveLength(1);
expect(unresolvedResult.unresolvedRelationSpecValues[0]).toContain('IMPORTED_SELECT');
});
it('Probe C (260914-eym, Systemkontext): `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 systemPrisma = forSystem(this.prisma) as any;
return systemPrisma.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 systemPrisma = forSystem(this.prisma) as any;
return systemPrisma.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 systemPrisma = forSystem(this.prisma) as any;
return systemPrisma.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);
});
});