feat(quick-260910-exd): ModuleAccessService an forTenant() binden

- module-access.service.ts: getAccessibleModuleIds (Kurzschlusszweig,
  Direktweg, Gruppenweg, Schnittmenge) und getCatalogFlags' eigener
  Aktivierungs-Lesezugriff laufen ueber forTenant(), EIN Klient je Methode
  unter dem Namen tenantPrisma; der Katalogzugriff in findAccessibleModules
  bleibt bewusst ungebunden (Modulkatalog traegt keine Regel), mit Kommentar
  der Messung und Bedingung trennt
- Bestehende where-Filter mit tenantId bleiben als zweites Netz stehen
- module-access.service.spec.ts: Zwei-Klienten-Nachweis ueber
  __makeBoundClient (Muster aus module-grants.service.spec.ts), alle 15
  bestehenden Faelle erhalten, neue Faelle fuer jede in <behavior> genannte
  Bindungseigenschaft inkl. Wachhund gegen eine kuenftige Katalogbindung
- module.guard.spec.ts: ein Fall, der die Abwesenheit eines
  unterscheidenden Signals fuer "keine Freigabe" vs. "Abfrage fand nichts"
  festnagelt
- Falsifizierungsnachweis durchgefuehrt: Gruppenweg-Bindung probeweise
  zurueckgebaut, Test "USER-Zweig bindet BEIDE Freigabe-Lesezugriffe..."
  wurde rot ("expected 1 to be 2"), Ruecknahme bestaetigt wieder gruen
- mandantentrennung-zugriffsklassifikation.md: Stand fuer
  module-access.service.ts/moduleGrant und /tenantModuleActivation auf
  gebunden nachgezogen (Rule 3 — sonst waere rls-access-inventory.spec.ts
  rot geblieben); die uebrigen vier Bestandsaufnahme-Stellen bleiben
  Aufgabe 3 vorbehalten
- 817 Tests gruen (55 Dateien), Typpruefung sauber, Wegwerf-Werkzeug 66/66

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-10 11:22:32 +02:00
parent 7d45e2fffd
commit 3df72687c1
4 changed files with 269 additions and 21 deletions
@@ -6,9 +6,27 @@ import { ModuleAccessService } from './module-access.service';
* Modulzugriff (D-01, PERM-04/05/06). Deckt die vollständige Behavior-
* Liste aus 15-01-PLAN.md, Task 2 ab.
*
* Hand-gerollter Prisma-Mock (Projektkonvention, siehe
* tender-matching.service.spec.ts) statt einer echten DB-Verbindung.
* Bindung an forTenant() (260910-exd, Aufgabe 2, Befund C uebertragen von
* `module-grants.service.spec.ts`, dem bereits umgestellten Nachbarn auf
* denselben Modellen `tenantModuleActivation`/`moduleGrant`): der gebundene
* Klient ist ein ZWEITES, von `prisma` unterscheidbares Objekt ueber
* DEMSELBEN Speicher, das protokolliert, welche Aufrufe ueber ihn liefen
* (Modellname, Methodenname, Mandantenkennung). Ein reiner Identitaets-Mock
* (`forTenant: vi.fn((p) => p)`) koennte einen vergessenen Bindungsaufruf
* nicht von einem ungebundenen Aufruf unterscheiden.
*
* `module` wird NICHT gewrappt — der Katalogzugriff laeuft bewusst ueber
* den ungebundenen Klienten (Befund E, Aufgabe 1): die Tabelle traegt heute
* keinen Zeilenschutz, eine Bindung waere heute wirkungslos.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. */
const BOUND_MODEL_NAMES = ['tenantModuleActivation', 'moduleGrant'];
function makeFakePrisma(opts: {
activations?: { moduleId: string }[];
@@ -18,8 +36,9 @@ function makeFakePrisma(opts: {
const activations = opts.activations ?? [];
const directGrants = opts.directGrants ?? [];
const groupGrants = opts.groupGrants ?? [];
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
const prisma = {
const fake: any = {
tenantModuleActivation: {
findMany: vi.fn(async ({ where }: any) => {
// filtert die simulierten Aktivierungen zusätzlich auf moduleId,
@@ -43,9 +62,55 @@ function makeFakePrisma(opts: {
return ids.map((id) => ({ id, name: id }));
}),
},
// --- Bindungsnachweis (260910-exd, 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 prisma;
return fake;
}
/**
* 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);
}
/**
* Wachhund-Gegenprobe (Aufgabe 2): ein Modell darf im Bindungsprotokoll gar
* nicht vorkommen — das ist der Testfall, der jemanden erwischt, der den
* bewusst ungebundenen Katalogzugriff spaeter versehentlich bindet.
*/
function expectNeverBound(prisma: any, model: string) {
const found = prisma.__boundCallLog.some((c: any) => c.model === model);
expect(
found,
`Modell "${model}" darf nie im Bindungsprotokoll auftauchen (Katalog bleibt ungebunden): ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(false);
}
describe('ModuleAccessService.getAccessibleModuleIds — ADMIN/SUPER_ADMIN-Kurzschluss (D-03)', () => {
@@ -88,13 +153,8 @@ describe('ModuleAccessService.getAccessibleModuleIds — ADMIN/SUPER_ADMIN-Kurzs
t1: [{ moduleId: 'mod-tenant-1' }],
t2: [{ moduleId: 'mod-tenant-2' }],
};
const prisma = {
tenantModuleActivation: {
findMany: vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []),
},
moduleGrant: { findMany: vi.fn() },
module: { findMany: vi.fn() },
};
const prisma = makeFakePrisma();
prisma.tenantModuleActivation.findMany = vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []);
const service = new ModuleAccessService(prisma as any);
const resultT2 = await service.getAccessibleModuleIds('t2', 'admin-1', 'ADMIN');
@@ -260,3 +320,101 @@ describe('ModuleAccessService.getCatalogFlags — Marketplace-Katalog (D-08)', (
expect(flags.get('mod-1')).toEqual({ isActiveForTenant: true, hasAccess: true });
});
});
// --- Bindung an forTenant() (260910-exd, Aufgabe 2) -------------------------
describe('ModuleAccessService — Bindung an forTenant() (260910-exd)', () => {
it('Kurzschlusszweig (ADMIN) bindet seinen Aktivierungs-Lesezugriff an die Mandantenkennung aus dem Sitzungsnachweis', async () => {
const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }] });
const service = new ModuleAccessService(prisma as any);
await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN');
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
});
it('USER-Zweig bindet BEIDE Freigabe-Lesezugriffe (Direktweg und Gruppenweg) UND den Schnittmengen-Lesezugriff an DIESELBE Mandantenkennung', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1' }],
groupGrants: [{ moduleId: 'mod-1' }],
});
const service = new ModuleAccessService(prisma as any);
await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
const grantCalls = prisma.__boundCallLog.filter(
(c: any) => c.model === 'moduleGrant' && c.method === 'findMany',
);
expect(grantCalls.length).toBe(2);
expect(grantCalls.every((c: any) => c.tenantId === 't1')).toBe(true);
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
});
it('Vorgabezustand bleibt geschlossen und ueberlebt die Bindung: ohne Grants leeres Set, der Schnittmengen-Lesezugriff wird gar nicht erst ausgefuehrt', async () => {
const prisma = makeFakePrisma({});
const service = new ModuleAccessService(prisma as any);
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
expect(result).toEqual(new Set());
const activationCalls = prisma.__boundCallLog.filter(
(c: any) => c.model === 'tenantModuleActivation' && c.method === 'findMany',
);
expect(
activationCalls,
`der Schnittmengen-Lesezugriff auf tenantModuleActivation darf ohne Grants nicht stattfinden, gefunden: ${JSON.stringify(activationCalls)}`,
).toEqual([]);
});
it('Rollen-Kurzschluss waechst durch die Bindung nicht: eine Aufloesung fuer einen zweiten Mandanten leitet keine Module des ersten ab, die gebundene Kennung im Protokoll ist die des zweiten', async () => {
const activationsByTenant: Record<string, { moduleId: string }[]> = {
t1: [{ moduleId: 'mod-tenant-1' }],
t2: [{ moduleId: 'mod-tenant-2' }],
};
const prisma = makeFakePrisma();
prisma.tenantModuleActivation.findMany = vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []);
const service = new ModuleAccessService(prisma as any);
const resultT2 = await service.getAccessibleModuleIds('t2', 'admin-2', 'ADMIN');
expect(resultT2).toEqual(new Set(['mod-tenant-2']));
expectBoundCall(prisma, 't2', 'tenantModuleActivation', 'findMany');
const t1Calls = prisma.__boundCallLog.filter((c: any) => c.tenantId === 't1');
expect(t1Calls, `keine Bindung an t1 erwartet: ${JSON.stringify(t1Calls)}`).toEqual([]);
});
it('findAccessibleModules erreicht den Katalog ueber den UNGEBUNDENEN Klienten — der Katalogzugriff taucht im Bindungsprotokoll nicht auf', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1' }],
});
const service = new ModuleAccessService(prisma as any);
await service.findAccessibleModules('t1', 'user-1', 'USER');
expectNeverBound(prisma, 'module');
expect(prisma.module.findMany).toHaveBeenCalled();
});
it('getCatalogFlags bindet seinen eigenen Aktivierungs-Lesezugriff, und die geschachtelte Aufloesung erzeugt ihren eigenen gebundenen Klienten mit derselben Mandantenkennung', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1' }],
});
const service = new ModuleAccessService(prisma as any);
await service.getCatalogFlags('t1', 'user-1', 'USER');
const activationCalls = prisma.__boundCallLog.filter(
(c: any) => c.model === 'tenantModuleActivation' && c.method === 'findMany',
);
// Ein Aufruf fuer getCatalogFlags selbst, ein zweiter aus der
// geschachtelten getAccessibleModuleIds-Aufloesung — beide gebunden an
// denselben Mandanten, aber ueber je einen eigenen forTenant()-Aufruf
// (dieselbe Konvention wie module-grants.service.ts).
expect(activationCalls.length).toBe(2);
expect(activationCalls.every((c: any) => c.tenantId === 't1')).toBe(true);
expectNeverBound(prisma, 'module');
});
});