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
@@ -116,4 +116,70 @@ describe('ModuleGuard.canActivate', () => {
expect(result).toBe(true);
expect((request as any).moduleAccessIds).toBe(accessibleIds);
});
/**
* 260910-exd, Aufgabe 2 — nagelt die ABWESENHEIT eines unterscheidenden
* Signals fest: es gibt heute KEIN Signal, das "wirklich keine Freigabe"
* (USER hat tatsächlich keinen Grant) von "die Auflösung hat nichts
* gefunden" (z. B. eine nach dem Scharfschalten ungebunden gebliebene
* Abfrage) unterscheidet. Beide Aufrufe liefern eine leere Menge und
* damit DIESELBE ForbiddenException mit DERSELBEN Meldung — dieser Test
* hält die beiden Meldungen gegeneinander.
*
* DIESER TEST SOLL ROT WERDEN, sobald jemand ein unterscheidendes Signal
* einbaut (z. B. ein eigener Fehlercode oder eine unterschiedliche
* Meldung für "leer, weil keine Freigabe" vs. "leer, weil die Abfrage
* nichts gefunden hat"). Wird er rot, ist das kein Bug in diesem Test,
* sondern der Beleg, dass die Lücke geschlossen wurde — dann sind sowohl
* dieser Test als auch Abschnitt (m3) von
* docs/mandantentrennung-etappe2-fehlerrichtung.md nachzuziehen.
*/
it('liefert fuer "wirklich keine Freigabe" und fuer "die Aufloesung hat nichts gefunden" dieselbe Ausnahme mit derselben Meldung (kein unterscheidendes Signal)', async () => {
const moduleRegistryService = {
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
} as any;
// Fall A: der Benutzer hat tatsächlich keine Freigabe.
const moduleAccessServiceGenuinelyEmpty = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()),
} as any;
const guardA = new ModuleGuard(
makeReflector('domaincheck'),
moduleRegistryService,
moduleAccessServiceGenuinelyEmpty,
);
// Fall B: es gibt Freigaben, aber die Auflösung liefert (z. B. wegen
// einer ungebunden gebliebenen Abfrage) trotzdem eine leere Menge —
// aus Sicht des Wächters nicht von Fall A zu unterscheiden.
const moduleAccessServiceQueryFoundNothing = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()),
} as any;
const guardB = new ModuleGuard(
makeReflector('domaincheck'),
moduleRegistryService,
moduleAccessServiceQueryFoundNothing,
);
const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } };
let messageA = '';
let messageB = '';
try {
await guardA.canActivate(makeContext(request));
} catch (err) {
messageA = (err as ForbiddenException).message;
}
try {
await guardB.canActivate(makeContext(request));
} catch (err) {
messageB = (err as ForbiddenException).message;
}
expect(messageA).not.toBe('');
expect(
messageB,
'heute gibt es kein Signal, das diese beiden Faelle unterscheidet — beide Meldungen muessen identisch sein',
).toBe(messageA);
});
});