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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ModuleGuard } from './module.guard';
|
||||
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard, ModuleManage } from './module.guard';
|
||||
|
||||
/**
|
||||
* ModuleGuard.canActivate — deckt die vollständige Behavior-Liste aus
|
||||
@@ -18,26 +19,28 @@ function makeContext(request: any) {
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeReflector(slug: string | undefined) {
|
||||
return { getAllAndOverride: vi.fn(() => slug) } 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 = { getAccessibleModuleIds: 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.getAccessibleModuleIds).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 = { getAccessibleModuleIds: 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(
|
||||
@@ -47,7 +50,7 @@ describe('ModuleGuard.canActivate', () => {
|
||||
|
||||
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 moduleAccessService = { getModuleAccessLevels: vi.fn() } as any;
|
||||
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
||||
|
||||
await expect(
|
||||
@@ -57,7 +60,7 @@ describe('ModuleGuard.canActivate', () => {
|
||||
|
||||
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 moduleAccessService = { getModuleAccessLevels: vi.fn() } as any;
|
||||
const guard = new ModuleGuard(makeReflector('unknown-slug'), moduleRegistryService, moduleAccessService);
|
||||
|
||||
await expect(
|
||||
@@ -65,7 +68,7 @@ describe('ModuleGuard.canActivate', () => {
|
||||
makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }),
|
||||
),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled();
|
||||
expect(moduleAccessService.getModuleAccessLevels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('USER ohne Grant auf ein aktives Modul: wirft ForbiddenException', async () => {
|
||||
@@ -73,7 +76,7 @@ describe('ModuleGuard.canActivate', () => {
|
||||
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
|
||||
} as any;
|
||||
const moduleAccessService = {
|
||||
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()),
|
||||
getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
|
||||
} as any;
|
||||
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
||||
|
||||
@@ -89,7 +92,7 @@ describe('ModuleGuard.canActivate', () => {
|
||||
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
|
||||
} as any;
|
||||
const moduleAccessService = {
|
||||
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set(['mod-1'])),
|
||||
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' } };
|
||||
@@ -97,16 +100,16 @@ describe('ModuleGuard.canActivate', () => {
|
||||
const result = await guard.canActivate(makeContext(request));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN');
|
||||
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 Set(['mod-1']);
|
||||
const accessibleIds = new Map([['mod-1', 'USE']]);
|
||||
const moduleAccessService = {
|
||||
getAccessibleModuleIds: vi.fn().mockResolvedValue(accessibleIds),
|
||||
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' } };
|
||||
@@ -114,7 +117,8 @@ describe('ModuleGuard.canActivate', () => {
|
||||
const result = await guard.canActivate(makeContext(request));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect((request as any).moduleAccessIds).toBe(accessibleIds);
|
||||
expect((request as any).moduleAccessLevels).toBe(accessibleIds);
|
||||
expect([...(request as any).moduleAccessIds]).toEqual(['mod-1']);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -141,7 +145,7 @@ describe('ModuleGuard.canActivate', () => {
|
||||
|
||||
// Fall A: der Benutzer hat tatsächlich keine Freigabe.
|
||||
const moduleAccessServiceGenuinelyEmpty = {
|
||||
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()),
|
||||
getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
|
||||
} as any;
|
||||
const guardA = new ModuleGuard(
|
||||
makeReflector('domaincheck'),
|
||||
@@ -153,7 +157,7 @@ describe('ModuleGuard.canActivate', () => {
|
||||
// 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()),
|
||||
getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
|
||||
} as any;
|
||||
const guardB = new ModuleGuard(
|
||||
makeReflector('domaincheck'),
|
||||
@@ -183,3 +187,71 @@ describe('ModuleGuard.canActivate', () => {
|
||||
).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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user