test(quick-260911-mkj): vierte Erkennungsform fuer Relationszugriffe, WINDOWS #27

- rls-access-inventory.spec.ts: analyzeFile in analyzeSource(source, relPath)
  herausgeloest, parseSchemaRelations() liest schema.prisma zur Testzeit,
  vierte Erkennung loest include:/select:/_count:/Relationsfilter ueber
  SCHEMA_RELATIONS auf das Zielmodell auf und traegt es als eigene
  Fundstelle (gebunden/ungebunden nach Empfaenger) ein
- drei Waechter (Rohzahl vs. erkannte Modellaufrufe, Stale-Check fuer
  RELATION_SPEC_EXCEPTIONS, unresolvedRelationSpecValues leer), zwei
  Schema-Tests, acht gepinnte Proben (WINDOWS #27 ungebunden/gebunden,
  reale ldap-Form, verschachtelte where-Kette, Negativprobe, _count: true,
  unbekannter Empfaenger, Konstantenaufloesung)
- Bestandsaufnahme (docs/mandantentrennung-zugriffsklassifikation.md): sieben
  neue Paare, drei fortgeschriebene Staende (davon ldapFieldMapping mit
  Klassenwechsel auf beides), Kopfabsatz "Erkennungsluecke GESCHLOSSEN"
  ersetzt den alten "seit 260911-e2s vermessen"-Absatz, 65 -> 72 Paare

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 16:45:27 +02:00
parent 926359b067
commit 5ad23d0537
2 changed files with 571 additions and 14 deletions
@@ -35,11 +35,27 @@ import { describe, expect, it } from 'vitest';
* 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.<Modell>` noch `<gebundener
* Client>.<Modell>` noch `<tx>.<Modell>` 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.
*/
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
@@ -77,6 +93,23 @@ const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set<string>([]);
*/
const INTERACTIVE_TRANSACTION_EXCEPTIONS = new Set<string>([]);
/**
* 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 #TBD-MKJ (Aufgabe 2 ersetzt den Platzhalter durch die vergebene
* Nummer).
*/
const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill-tender-source.ts']);
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const;
type Stand = (typeof STAND_TOKENS)[number];
@@ -88,6 +121,9 @@ interface FileAnalysis {
assignmentFormCalls: number;
rawInteractiveTransactionCount: number;
matchedInteractiveTransactionCount: number;
rawRelationSpecCount: number;
matchedRelationSpecCount: number;
unresolvedRelationSpecValues: string[];
}
function listTsFiles(dir: string): string[] {
@@ -116,8 +152,216 @@ function stripComments(source: string): string {
.join('\n');
}
function analyzeFile(absPath: string, relPath: string): FileAnalysis {
const source = stripComments(readFileSync(absPath, 'utf-8'));
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<string, Map<string, string>> {
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<string, Map<string, string>>();
for (const { name, body } of blocks) {
const fieldMap = new Map<string, string>();
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.<Modell>`
* (260911-mkj, WINDOWS #27).
*/
const CLIENT_NAME_TO_MODEL = new Map<string, string>(
[...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,
isBound: boolean,
unboundModels: Set<string>,
boundModels: Set<string>,
): 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);
(isBound ? boundModels : unboundModels).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()) {
(isBound ? boundModels : unboundModels).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<string>();
for (const m of source.matchAll(/this\.prisma\.([a-zA-Z]+)/g)) {
@@ -160,6 +404,13 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis {
? 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: `<empfaenger>.$transaction(async (<param>) => ...)`. <empfaenger>
// ist gebunden, wenn er in boundNames steht (aus der Zuweisungsform oben).
const directInteractiveMatches = [
@@ -172,6 +423,8 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis {
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;
@@ -192,12 +445,73 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis {
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<string>([
'this.prisma',
...boundNames,
...txBoundParams,
...txUnboundParams,
]);
const boundReceiverNames = new Set<string>([...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;
const isBound = boundReceiverNames.has(receiver);
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, isBound, unboundModels, boundModels);
}
}
// 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,
@@ -206,9 +520,17 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis {
assignmentFormCalls: assignmentMatches.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
@@ -391,4 +713,203 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
}
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');
});
});