feat(jts-02): groups.service.ts binden, Transaktionen tragfaehig machen, Absicherung sehend machen

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
This commit is contained in:
2026-09-09 15:01:24 +02:00
parent fd0b9f7d21
commit 7f08b27eea
6 changed files with 624 additions and 54 deletions
@@ -19,6 +19,22 @@ import { describe, expect, it } from 'vitest';
* 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, '..');
@@ -39,6 +55,18 @@ const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set([
'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];
@@ -48,6 +76,8 @@ interface FileAnalysis {
boundModels: Set<string>;
totalForTenantCalls: number;
assignmentFormCalls: number;
rawInteractiveTransactionCount: number;
matchedInteractiveTransactionCount: number;
}
function listTsFiles(dir: string): string[] {
@@ -100,12 +130,72 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis {
// 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,
};
}
@@ -256,4 +346,17 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
}
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([]);
});
});