Files
tessera-ctl/apps/api/src/module-registry/module.guard.spec.ts
T
schalli a222711ad9 feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen
- Migration: ModuleGrant.level (USE/MANAGE), Bestand bleibt USE
- ModuleAccessService.getModuleAccessLevels als einzige Auflösung, MANAGE gewinnt
- @ModuleManage(slug) am ModuleGuard, GET /modules/active liefert canManage
- Kantinenabrechnung: Einstellungen für Benutzer mit Verwalten, Web-Hook useCanManageModule

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:28:24 +02:00

258 lines
12 KiB
TypeScript

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);
});
});