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,217 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ModuleAccessService } from './module-access.service';
|
||||
|
||||
/**
|
||||
* ModuleAccessService.getAccessibleModuleIds — Single Source of Truth für
|
||||
* Modulzugriff (D-01, PERM-04/05/06). Deckt die vollständige Behavior-
|
||||
* Liste aus 15-01-PLAN.md, Task 2 ab.
|
||||
*
|
||||
* Hand-gerollter Prisma-Mock (Projektkonvention, siehe
|
||||
* tender-matching.service.spec.ts) statt einer echten DB-Verbindung.
|
||||
*/
|
||||
|
||||
function makeFakePrisma(opts: {
|
||||
activations?: { moduleId: string }[];
|
||||
directGrants?: { moduleId: string }[];
|
||||
groupGrants?: { moduleId: string }[];
|
||||
} = {}) {
|
||||
const activations = opts.activations ?? [];
|
||||
const directGrants = opts.directGrants ?? [];
|
||||
const groupGrants = opts.groupGrants ?? [];
|
||||
|
||||
const prisma = {
|
||||
tenantModuleActivation: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
// filtert die simulierten Aktivierungen zusätzlich auf moduleId,
|
||||
// falls die Query (D-02-Schnittmenge) einen moduleId-in-Filter trägt
|
||||
const inFilter: string[] | undefined = where?.moduleId?.in;
|
||||
if (!inFilter) return activations;
|
||||
return activations.filter((a) => inFilter.includes(a.moduleId));
|
||||
}),
|
||||
},
|
||||
moduleGrant: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
// Direkt-Grant-Query trägt `userId` direkt im where, die
|
||||
// Gruppen-Grant-Query trägt `group: { memberships: { some: { userId } } } }`
|
||||
if (where.group) return groupGrants;
|
||||
return directGrants;
|
||||
}),
|
||||
},
|
||||
module: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
const ids: string[] = where.id.in;
|
||||
return ids.map((id) => ({ id, name: id }));
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return prisma;
|
||||
}
|
||||
|
||||
describe('ModuleAccessService.getAccessibleModuleIds — ADMIN/SUPER_ADMIN-Kurzschluss (D-03)', () => {
|
||||
it('ADMIN erhält alle mandantenweit aktiven moduleIds ohne jede Grant-Query', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
activations: [{ moduleId: 'mod-1' }, { moduleId: 'mod-2' }],
|
||||
});
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const result = await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN');
|
||||
|
||||
expect(result).toEqual(new Set(['mod-1', 'mod-2']));
|
||||
expect(prisma.moduleGrant.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN verhält sich identisch zu ADMIN', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
activations: [{ moduleId: 'mod-1' }],
|
||||
});
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const result = await service.getAccessibleModuleIds('t1', 'sa-1', 'SUPER_ADMIN');
|
||||
|
||||
expect(result).toEqual(new Set(['mod-1']));
|
||||
expect(prisma.moduleGrant.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('idempotency: wiederholte Aufrufe für denselben ADMIN liefern dasselbe Ergebnis, ohne Datensätze anzulegen', async () => {
|
||||
const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }] });
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const first = await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN');
|
||||
const second = await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN');
|
||||
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it('concurrency: der Bypass gilt nur für die tenantId aus dem JWT — ein Aufruf für t2 leitet keine Module von t1 ab', async () => {
|
||||
const activationsByTenant: Record<string, { moduleId: string }[]> = {
|
||||
t1: [{ moduleId: 'mod-tenant-1' }],
|
||||
t2: [{ moduleId: 'mod-tenant-2' }],
|
||||
};
|
||||
const prisma = {
|
||||
tenantModuleActivation: {
|
||||
findMany: vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []),
|
||||
},
|
||||
moduleGrant: { findMany: vi.fn() },
|
||||
module: { findMany: vi.fn() },
|
||||
};
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const resultT2 = await service.getAccessibleModuleIds('t2', 'admin-1', 'ADMIN');
|
||||
|
||||
expect(resultT2).toEqual(new Set(['mod-tenant-2']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModuleAccessService.getAccessibleModuleIds — USER (Grant-Auflösung, D-02)', () => {
|
||||
it('empty: USER ohne Grants erhält ein leeres Set', async () => {
|
||||
const prisma = makeFakePrisma({});
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('USER mit Direkt-Grant auf ein aktives Modul: Set enthält genau diese moduleId', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
activations: [{ moduleId: 'mod-1' }],
|
||||
directGrants: [{ moduleId: 'mod-1' }],
|
||||
});
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||
|
||||
expect(result).toEqual(new Set(['mod-1']));
|
||||
});
|
||||
|
||||
it('USER mit Grant über eine Gruppe, in der er Mitglied ist: Set enthält diese moduleId', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
activations: [{ moduleId: 'mod-1' }],
|
||||
groupGrants: [{ moduleId: 'mod-1' }],
|
||||
});
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||
|
||||
expect(result).toEqual(new Set(['mod-1']));
|
||||
});
|
||||
|
||||
it('adjacency: USER mit Direkt-Grant UND Gruppen-Grant auf dasselbe Modul — die moduleId erscheint genau einmal', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
activations: [{ moduleId: 'mod-1' }],
|
||||
directGrants: [{ moduleId: 'mod-1' }],
|
||||
groupGrants: [{ moduleId: 'mod-1' }],
|
||||
});
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result).toEqual(new Set(['mod-1']));
|
||||
});
|
||||
|
||||
it('adjacency/D-02: ein Grant auf ein mandantenweit deaktiviertes Modul gewährt KEINEN Zugriff', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
activations: [], // mod-1 ist NICHT (mehr) aktiv
|
||||
directGrants: [{ moduleId: 'mod-1' }],
|
||||
});
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('adjacency: eine mandantenweite Aktivierung ohne Grant ergibt ebenfalls keinen Zugriff', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
activations: [{ moduleId: 'mod-1' }],
|
||||
// kein Direkt- oder Gruppen-Grant
|
||||
});
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('idempotency: getAccessibleModuleIds ist rein lesend — zwei identische Aufrufe schreiben keinen Datensatz und liefern dasselbe Ergebnis', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
activations: [{ moduleId: 'mod-1' }],
|
||||
directGrants: [{ moduleId: 'mod-1' }],
|
||||
});
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const first = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||
const second = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(prisma.moduleGrant.findMany).toHaveBeenCalledTimes(4); // 2x (direct+group) je Aufruf
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModuleAccessService.findAccessibleModules — ordering (PERM-04)', () => {
|
||||
it('sortiert die zugänglichen Module deterministisch nach Namen aufsteigend', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
activations: [{ moduleId: 'mod-1' }],
|
||||
directGrants: [{ moduleId: 'mod-1' }],
|
||||
});
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
await service.findAccessibleModules('t1', 'user-1', 'USER');
|
||||
|
||||
expect(prisma.module.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ orderBy: { name: 'asc' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('empty: ein Benutzer ohne Zugriff erhält eine leere Liste, keinen Fehler', async () => {
|
||||
const prisma = makeFakePrisma({});
|
||||
const service = new ModuleAccessService(prisma as any);
|
||||
|
||||
const result = await service.findAccessibleModules('t1', 'user-1', 'USER');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.module.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user