3df72687c1
- 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
186 lines
7.8 KiB
TypeScript
186 lines
7.8 KiB
TypeScript
import { ForbiddenException } from '@nestjs/common';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { ModuleGuard } from './module.guard';
|
|
|
|
/**
|
|
* ModuleGuard.canActivate — deckt die vollständige Behavior-Liste aus
|
|
* 15-01-PLAN.md, Task 2 ab. ModuleAccessService und ModuleRegistryService
|
|
* sind gemockt (Guard-Unit-Test, kein DB-Zugriff).
|
|
*/
|
|
|
|
function makeContext(request: any) {
|
|
return {
|
|
getHandler: () => ({}),
|
|
getClass: () => ({}),
|
|
switchToHttp: () => ({
|
|
getRequest: () => request,
|
|
}),
|
|
} as any;
|
|
}
|
|
|
|
function makeReflector(slug: string | undefined) {
|
|
return { getAllAndOverride: vi.fn(() => slug) } as any;
|
|
}
|
|
|
|
describe('ModuleGuard.canActivate', () => {
|
|
it('gibt true zurück und ruft keinen Service auf, wenn kein @UseModule-Slug in den Metadaten steht', async () => {
|
|
const moduleRegistryService = { findBySlug: vi.fn() } as any;
|
|
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
|
|
const guard = new ModuleGuard(makeReflector(undefined), moduleRegistryService, moduleAccessService);
|
|
|
|
const result = await guard.canActivate(makeContext({}));
|
|
|
|
expect(result).toBe(true);
|
|
expect(moduleRegistryService.findBySlug).not.toHaveBeenCalled();
|
|
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('wirft ForbiddenException("No tenant context"), wenn weder request.tenantId noch request.user.tenantId gesetzt sind', async () => {
|
|
const moduleRegistryService = { findBySlug: vi.fn() } as any;
|
|
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
|
|
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
|
|
|
await expect(guard.canActivate(makeContext({ user: {} }))).rejects.toThrow(
|
|
new ForbiddenException('No tenant context'),
|
|
);
|
|
});
|
|
|
|
it('wirft ForbiddenException("No user context"), wenn tenantId gesetzt ist, aber userId/role fehlen', async () => {
|
|
const moduleRegistryService = { findBySlug: vi.fn() } as any;
|
|
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
|
|
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
|
|
|
await expect(
|
|
guard.canActivate(makeContext({ tenantId: 't1', user: {} })),
|
|
).rejects.toThrow(new ForbiddenException('No user context'));
|
|
});
|
|
|
|
it('wirft ForbiddenException bei unbekanntem Slug (findBySlug liefert null)', async () => {
|
|
const moduleRegistryService = { findBySlug: vi.fn().mockResolvedValue(null) } as any;
|
|
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
|
|
const guard = new ModuleGuard(makeReflector('unknown-slug'), moduleRegistryService, moduleAccessService);
|
|
|
|
await expect(
|
|
guard.canActivate(
|
|
makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }),
|
|
),
|
|
).rejects.toThrow(ForbiddenException);
|
|
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('USER ohne Grant auf ein aktives Modul: wirft ForbiddenException', async () => {
|
|
const moduleRegistryService = {
|
|
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
|
|
} as any;
|
|
const moduleAccessService = {
|
|
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()),
|
|
} as any;
|
|
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
|
|
|
await expect(
|
|
guard.canActivate(
|
|
makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }),
|
|
),
|
|
).rejects.toThrow(ForbiddenException);
|
|
});
|
|
|
|
it('ADMIN ohne Grant auf ein aktives Modul: gibt true zurück (D-03-Kurzschluss lebt in ModuleAccessService)', async () => {
|
|
const moduleRegistryService = {
|
|
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
|
|
} as any;
|
|
const moduleAccessService = {
|
|
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set(['mod-1'])),
|
|
} as any;
|
|
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
|
const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } };
|
|
|
|
const result = await guard.canActivate(makeContext(request));
|
|
|
|
expect(result).toBe(true);
|
|
expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN');
|
|
});
|
|
|
|
it('USER mit Zugriff: gibt true zurück und legt das Ergebnis auf request.moduleAccessIds ab (Per-Request-Memoisierung, D-09)', async () => {
|
|
const moduleRegistryService = {
|
|
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
|
|
} as any;
|
|
const accessibleIds = new Set(['mod-1']);
|
|
const moduleAccessService = {
|
|
getAccessibleModuleIds: vi.fn().mockResolvedValue(accessibleIds),
|
|
} as any;
|
|
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
|
const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } };
|
|
|
|
const result = await guard.canActivate(makeContext(request));
|
|
|
|
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);
|
|
});
|
|
});
|