feat(jts-03): module-grants.service.ts binden, beide Dokumente schliessen

Alle fuenf Methoden von ModuleGrantsService (assertTargetBelongsToTenant,
grant, revoke, getMatrix, getUserAccess) laufen jetzt ueber forTenant(); bei
den beiden Datenlieferungen teilen sich alle parallel abgesetzten
Teilabfragen denselben gebundenen Client. Die Mandanten-Gegenpruefung vor
jedem Erteilen bleibt ausdruecklich bestehen und bekommt einen Verweis auf
Befund F/T-JTS-03: die Regel auf ModuleGrant prueft nur die
Mandantenkennung der Zeile, nicht die referenzierte Gruppe. Der veraltete
Kommentar ueber der Mitgliedschaftsabfrage im Benutzer-Detail ("kein
forTenant hier") ist durch den neuen Stand ersetzt.

module-grants.service.spec.ts bekommt denselben Bindungsnachweis-Mock wie
groups.service.spec.ts (zwei unterscheidbare Clients ueber demselben
Speicher) und sechs neue Bindungsnachweise; alle 28 Bestandstests bleiben
gruen.

Beide Dokumente geschlossen: die Bereichsuebersicht fuer groups ist neu
gemessen (0 ungebunden, 31 gebunden — ein dokumentierter methodischer
Bodensatz, da die einfache Rohtrefferzaehlung die neun ueber `tx` gebundenen
Zugriffe innerhalb der drei Transaktionen nicht sieht), die
Klassen-Verteilung auf 62 Paare aktualisiert, und der als offen gefuehrte
Befund D aus dem ldap-Abschnitt der Fehlerrichtung ist mit Verweis auf
diesen Durchlauf als erledigt vermerkt (Nachtrag, nicht Neuschrieb). 743
Tests und die Typpruefung gruen; Schema, Migrationen und alle vier
Compose-Dateien unveraendert.

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:07:09 +02:00
parent 7f08b27eea
commit abb6c8bea3
4 changed files with 228 additions and 50 deletions
@@ -9,7 +9,18 @@ import { ModuleGrantsService } from './module-grants.service';
* groups.service.spec.ts / module-access.service.spec.ts — keine Live-DB,
* P2002 wird exakt wie ein echter Postgres-Client über den Fehlercode
* simuliert.
*
* Bindung an forTenant() (260909-jts, Aufgabe 3, Befund C uebertragen von
* groups.service.spec.ts): derselbe Mock wie dort — der gebundene Client
* ist ein ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN
* Speicher, das protokolliert, welche Aufrufe ueber ihn liefen. Ein reiner
* Identitaets-Mock (`forTenant: vi.fn((p) => p)`) koennte einen
* vergessenen Bindungsaufruf nicht von einem ungebundenen Aufruf
* unterscheiden.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
function makeFakePrisma() {
const groups = new Map<string, any>();
@@ -17,6 +28,7 @@ function makeFakePrisma() {
const memberships = new Map<string, Set<string>>(); // groupId -> Set<userId>
const membershipSources = new Map<string, string>(); // `${groupId}::${userId}` -> source
const activations = new Map<string, any>(); // key: tenantId::moduleId
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
const grants = new Map<string, any>();
let grantCounter = 0;
@@ -41,7 +53,7 @@ function makeFakePrisma() {
);
}
return {
const fake: any = {
__seedGroup(group: { id: string; tenantId: string; name: string; internalName?: string | null }) {
groups.set(group.id, { internalName: null, ...group });
},
@@ -166,7 +178,46 @@ function makeFakePrisma() {
return rows;
},
},
// --- Bindungsnachweis (260909-jts, Befund C uebertragen) ---------------
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string) {
const bound: any = { __isBoundClient: true, __tenantId: tenantId };
for (const modelName of BOUND_MODEL_NAMES) {
const model = fake[modelName];
const wrapped: any = {};
for (const method of Object.keys(model)) {
wrapped[method] = async (...args: any[]) => {
boundCallLog.push({ tenantId, model: modelName, method });
return model[method](...args);
};
}
bound[modelName] = wrapped;
}
return bound;
},
};
return fake;
}
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
* Herkunfts-Tenant protokollierenden Wrapper versieht. */
const BOUND_MODEL_NAMES = ['group', 'user', 'tenantModuleActivation', 'moduleGrant', 'groupMembership'];
/**
* Bindungsnachweis: mindestens ein Aufruf von `<tenantId>.<model>.<method>`
* lief ueber den gebundenen Client (nicht ueber den rohen, ungebundenen
* Fake). Ein vergessener `forTenant()`-Aufruf hinterlaesst hier KEINEN
* Eintrag und laesst den Test fehlschlagen.
*/
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
const found = prisma.__boundCallLog.some(
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
);
expect(
found,
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(true);
}
function seedBase(prisma: ReturnType<typeof makeFakePrisma>) {
@@ -595,3 +646,82 @@ describe('ModuleGrantsService — Logging (D-23)', () => {
expect(message).toContain('g1');
});
});
// --- Bindung an forTenant() (260909-jts, Aufgabe 3) -------------------------
describe('ModuleGrantsService — Bindung an forTenant() (260909-jts)', () => {
it('grant() bindet die Mandanten-Gegenpruefung, die Aktivierungspruefung und moduleGrant.create an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
expectBoundCall(prisma, 't1', 'group', 'findFirst');
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findUnique');
expectBoundCall(prisma, 't1', 'moduleGrant', 'create');
});
it('grant() bindet auch die Mandanten-Gegenpruefung fuer eine userId und bleibt wirksam gegen einen fremden Benutzer (T-15-01)', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' });
const service = new ModuleGrantsService(prisma as any);
await expect(
service.grant('t1', { moduleId: 'mod-1', userId: 'u-foreign' }),
).rejects.toBeInstanceOf(NotFoundException);
expectBoundCall(prisma, 't1', 'user', 'findFirst');
});
it('revoke() bindet moduleGrant.deleteMany an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' });
expectBoundCall(prisma, 't1', 'moduleGrant', 'deleteMany');
});
it('getMatrix() bindet alle drei parallelen Teilabfragen (tenantModuleActivation, group, moduleGrant) an DENSELBEN gebundenen Mandanten', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await service.getMatrix('t1');
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
expectBoundCall(prisma, 't1', 'group', 'findMany');
expectBoundCall(prisma, 't1', 'moduleGrant', 'findMany');
});
it('getUserAccess() bindet alle vier parallelen Teilabfragen (tenantModuleActivation, moduleGrant x2, groupMembership) an DENSELBEN gebundenen Mandanten', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
prisma.__seedMembership('g1', 'u1');
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
await service.getUserAccess('t1', 'u1');
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
expectBoundCall(prisma, 't1', 'moduleGrant', 'findMany');
expectBoundCall(prisma, 't1', 'groupMembership', 'findMany');
});
it('getUserAccess() bindet weiterhin die Mandanten-Gegenpruefung — sie wird durch die Bindung NICHT ersetzt', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' });
const service = new ModuleGrantsService(prisma as any);
await expect(service.getUserAccess('t1', 'u-foreign')).rejects.toBeInstanceOf(
NotFoundException,
);
expectBoundCall(prisma, 't1', 'user', 'findFirst');
});
});