feat(15-01): ModuleAccessService as single source of truth for module access (D-01)

- ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role):
  ADMIN/SUPER_ADMIN bypass (D-03) via one query, otherwise a single
  Promise.all of direct + group ModuleGrant lookups intersected against
  active TenantModuleActivation (D-02) — no N+1 over the user's groups
- findAccessibleModules() adds the name-asc sort for stable sidebar order
- ModuleGuard now resolves userId/role from request.user (JWT-sourced,
  never body/params) and calls getAccessibleModuleIds instead of the
  tenant-only isModuleActive check; caches the result on
  request.moduleAccessIds for same-request reuse (D-09, no cross-request
  caching)
- ModuleRegistryController.findActive delegates to
  ModuleAccessService.findAccessibleModules instead of
  findActiveForTenant, which stays untouched for Plan 15-03's
  tenant-wide marketplace catalog
- ModuleRegistryModule exports ModuleAccessService for Plan 15-03/15-05
- module-access.service.spec.ts / module.guard.spec.ts cover every case
  in the plan's <behavior> list with a hand-rolled Prisma mock
- End-to-end verified against the running local API: a USER without a
  grant gets 403 on a @UseModule-protected endpoint and an empty
  /modules/active list; the same USER with a direct grant gets 200 plus
  the slug in the list; an ADMIN without any grant also gets 200 (D-03)
This commit is contained in:
2026-08-04 15:09:10 +02:00
parent c5c704bae9
commit 9a4ba8a33c
6 changed files with 493 additions and 19 deletions
@@ -0,0 +1,119 @@
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);
});
});