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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user