Files
tessera-ctl/apps/api/src/module-registry/module-access.service.spec.ts
T
schalli 1c32543f58 feat(15-03): GET /modules/catalog — beide Statusflags in einer Antwort
- ModuleAccessService.getCatalogFlags(tenantId, userId, role) liefert je
  aktivem Modul isActiveForTenant + hasAccess in einer Auflösung
- ModuleRegistryController.findCatalog (GET /modules/catalog), erreichbar
  für jeden authentifizierten Benutzer wie GET /modules (D-08)
- ADMIN/SUPER_ADMIN: hasAccess immer wahr für aktive Module (D-03)
- 4 neue Tests für getCatalogFlags
2026-08-04 18:41:23 +02:00

263 lines
9.8 KiB
TypeScript

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();
});
});
describe('ModuleAccessService.getCatalogFlags — Marketplace-Katalog (D-08)', () => {
it('aktives und freigegebenes Modul: isActiveForTenant und hasAccess beide wahr', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1' }],
});
const service = new ModuleAccessService(prisma as any);
const flags = await service.getCatalogFlags('t1', 'user-1', 'USER');
expect(flags.get('mod-1')).toEqual({ isActiveForTenant: true, hasAccess: true });
});
it('aktives, aber nicht freigegebenes Modul: isActiveForTenant wahr, hasAccess falsch', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
});
const service = new ModuleAccessService(prisma as any);
const flags = await service.getCatalogFlags('t1', 'user-1', 'USER');
expect(flags.get('mod-1')).toEqual({ isActiveForTenant: true, hasAccess: false });
});
it('nicht aktiviertes Modul erscheint nicht in der Flag-Map (Controller mappt fehlenden Eintrag auf beide Flags falsch)', async () => {
const prisma = makeFakePrisma({ activations: [] });
const service = new ModuleAccessService(prisma as any);
const flags = await service.getCatalogFlags('t1', 'user-1', 'USER');
expect(flags.has('mod-1')).toBe(false);
});
it('D-03: als ADMIN ist jedes aktive Modul zugänglich, auch ohne Grant', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
});
const service = new ModuleAccessService(prisma as any);
const flags = await service.getCatalogFlags('t1', 'admin-1', 'ADMIN');
expect(flags.get('mod-1')).toEqual({ isActiveForTenant: true, hasAccess: true });
});
});