7f08b27eea
prisma-tenant.extension.ts bekommt withTenantTransaction(prisma, tenantId, fn) — die interaktive Transaktion auf dem UNgebundenen Client mit set_config als erster Anweisung direkt auf tx, die in Aufgabe 1 als einzige der drei gemessenen Formen sowohl die Einzelmessung als auch eine Lastprobe unter echter Nebenlaeufigkeit bestand (die Array-Form auf dem gebundenen Client verteilt jede Operation auf eine eigene Teiltransaktion; die interaktive Form auf dem gebundenen Client brach unter 40 parallelen Aufrufen mit P2028 ab). rls-access-inventory.spec.ts bekommt eine dritte Erkennung fuer Modellzugriffe ueber den Rueckgabeparameter einer interaktiven Transaktion (zwei Formen: direkter Empfaenger.$transaction(async...) und das neue Hilfsmittel withTenantTransaction(...)) — macht das Paar (groups.service.ts, tenantModuleActivation) erstmals sichtbar, das bislang keine Pruefung dieses Projekts je gesehen hat. groups.service.ts: alle zwoelf Methoden inklusive der drei Transaktionen (update() isDefault:true, reassignDefaultBeforeDelete(), ensureDefaultGroup()) laufen jetzt ueber den Mandantenkontext. Zaehler und Transaktion in ensureDefaultGroup() sind gemeinsam gebunden (T-JTS-05). addUserToDefaultGroup() prueft neu, dass der Zielbenutzer zum Mandanten gehoert (Befund E, T-JTS-02) — die Regel auf GroupMembership prueft nachweislich nur die Gruppenseite. groups.service.spec.ts bekommt zwei unterscheidbare Clients ueber demselben Speicher-Fake (Muster aus 260909-ipc, auf die interaktive Form uebertragen) und 13 neue Bindungsnachweise; alle 42 Bestandstests bleiben gruen. Klassifikationsdokument nachgezogen. 737 Tests und die Typpruefung gruen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
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.<Modell>` 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 <Name> = forTenant(` und sucht danach `<Name>.<Modell>`.
|
|
* 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.<Modell> ... })`) —
|
|
* weder `this.prisma.<Modell>` noch `<gebundener Client>.<Modell>` 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 `<empfaenger>.$transaction(
|
|
* async (tx) => ...)`, oder ueber das Hilfsmittel `withTenantTransaction(
|
|
* <empfaenger>, tenantId, async (tx) => ...)` aus
|
|
* `prisma-tenant.extension.ts`) und sucht danach `<tx>.<Modell>`. 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 <Name> = 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<string>([]);
|
|
|
|
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const;
|
|
type Stand = (typeof STAND_TOKENS)[number];
|
|
|
|
interface FileAnalysis {
|
|
file: string;
|
|
unboundModels: Set<string>;
|
|
boundModels: Set<string>;
|
|
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.<model>` 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<string>();
|
|
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<string>();
|
|
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(/(?<!function )forTenant\(/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
|
|
// 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: `<empfaenger>.$transaction(async (<param>) => ...)`. <empfaenger>
|
|
// 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(<empfaenger>, tenantId, async (<param>)
|
|
// => ...)` 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<string, Stand> {
|
|
const standByKey = new Map<string, Stand>();
|
|
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<string>();
|
|
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([]);
|
|
});
|
|
});
|