import { ForbiddenException } from '@nestjs/common'; import { GUARDS_METADATA } from '@nestjs/common/constants'; import { describe, expect, it, vi } from 'vitest'; import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard, ModuleManage } 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, manage = false) { return { getAllAndOverride: vi.fn((key: string) => (key === MODULE_MANAGE_KEY ? manage : 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 = { getModuleAccessLevels: 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.getModuleAccessLevels).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 = { getModuleAccessLevels: 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 = { getModuleAccessLevels: 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 = { getModuleAccessLevels: 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.getModuleAccessLevels).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 = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()), } 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 = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['mod-1', 'MANAGE']])), } 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.getModuleAccessLevels).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 Map([['mod-1', 'USE']]); const moduleAccessService = { getModuleAccessLevels: 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).moduleAccessLevels).toBe(accessibleIds); expect([...(request as any).moduleAccessIds]).toEqual(['mod-1']); }); /** * 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 = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()), } 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 = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()), } 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); }); }); describe('ModuleGuard — Freigabestufe (261002-icv)', () => { const registry = { findBySlug: vi.fn().mockImplementation(async (slug: string) => ({ id: `id-${slug}`, slug })), } as any; const userRequest = () => ({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }); it('@UseModule-Route + USE: erlaubt', async () => { const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'USE']])) } as any; const guard = new ModuleGuard(makeReflector('a'), registry, access); expect(await guard.canActivate(makeContext(userRequest()))).toBe(true); }); it('@ModuleManage-Route + USE: ForbiddenException mit Hinweis auf Verwalten', async () => { const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'USE']])) } as any; const guard = new ModuleGuard(makeReflector('a', true), registry, access); await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow( "Module 'a' requires manage permission", ); }); it('@ModuleManage-Route + MANAGE: erlaubt', async () => { const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any; const guard = new ModuleGuard(makeReflector('a', true), registry, access); expect(await guard.canActivate(makeContext(userRequest()))).toBe(true); }); it('Administrator (MANAGE auf allen aktiven Modulen): erlaubt', async () => { const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any; const guard = new ModuleGuard(makeReflector('a', true), registry, access); const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } }; expect(await guard.canActivate(makeContext(request))).toBe(true); expect(access.getModuleAccessLevels).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN'); }); it('@ModuleManage-Route ohne Freigabe: ForbiddenException (nicht zugänglich)', async () => { const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()) } as any; const guard = new ModuleGuard(makeReflector('a', true), registry, access); await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow( "Module 'a' is not accessible for this user", ); }); it('MANAGE auf Modul a gewährt nichts auf Modul b (T-icv-04)', async () => { const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any; const guard = new ModuleGuard(makeReflector('b', true), registry, access); await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow(ForbiddenException); }); it('ein zweiter Lauf auf demselben Request nutzt request.moduleAccessLevels und fragt den Dienst nicht erneut', async () => { const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any; const request = userRequest(); await new ModuleGuard(makeReflector('a'), registry, access).canActivate(makeContext(request)); await new ModuleGuard(makeReflector('a', true), registry, access).canActivate(makeContext(request)); expect(access.getModuleAccessLevels).toHaveBeenCalledTimes(1); }); it('ModuleManage(slug) setzt Slug, Manage-Schlüssel und den ModuleGuard', () => { class Probe { @ModuleManage('probe') handler() {} } const handler = Probe.prototype.handler; expect(Reflect.getMetadata(MODULE_SLUG_KEY, handler)).toBe('probe'); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, handler)).toBe(true); expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(ModuleGuard); }); });