a222711ad9
- 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>
523 lines
21 KiB
TypeScript
523 lines
21 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.
|
|
*
|
|
* Bindung an forTenant() (260910-exd, Aufgabe 2, Befund C uebertragen von
|
|
* `module-grants.service.spec.ts`, dem bereits umgestellten Nachbarn auf
|
|
* denselben Modellen `tenantModuleActivation`/`moduleGrant`): der gebundene
|
|
* Klient ist ein ZWEITES, von `prisma` unterscheidbares Objekt ueber
|
|
* DEMSELBEN Speicher, das protokolliert, welche Aufrufe ueber ihn liefen
|
|
* (Modellname, Methodenname, Mandantenkennung). Ein reiner Identitaets-Mock
|
|
* (`forTenant: vi.fn((p) => p)`) koennte einen vergessenen Bindungsaufruf
|
|
* nicht von einem ungebundenen Aufruf unterscheiden.
|
|
*
|
|
* `module` wird NICHT gewrappt — der Katalogzugriff laeuft bewusst ueber
|
|
* den ungebundenen Klienten (Befund E, Aufgabe 1): die Tabelle traegt heute
|
|
* keinen Zeilenschutz, eine Bindung waere heute wirkungslos.
|
|
*/
|
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
|
}));
|
|
|
|
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
|
|
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
|
|
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. */
|
|
const BOUND_MODEL_NAMES = ['tenantModuleActivation', 'moduleGrant'];
|
|
|
|
function makeFakePrisma(opts: {
|
|
activations?: { moduleId: string }[];
|
|
directGrants?: { moduleId: string; level?: string }[];
|
|
groupGrants?: { moduleId: string; level?: string }[];
|
|
} = {}) {
|
|
const activations = opts.activations ?? [];
|
|
const directGrants = opts.directGrants ?? [];
|
|
const groupGrants = opts.groupGrants ?? [];
|
|
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
|
|
|
const fake: any = {
|
|
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 }));
|
|
}),
|
|
},
|
|
// --- Bindungsnachweis (260910-exd, Befund C uebertragen) ---------------
|
|
__boundCallLog: boundCallLog,
|
|
__makeBoundClient(tenantId: string) {
|
|
const bound: any = { __isBoundClient: true, __tenantId: tenantId };
|
|
for (const modelName of BOUND_MODEL_NAMES) {
|
|
const model = fake[modelName];
|
|
const wrapped: any = {};
|
|
for (const method of Object.keys(model)) {
|
|
wrapped[method] = async (...args: any[]) => {
|
|
boundCallLog.push({ tenantId, model: modelName, method });
|
|
return model[method](...args);
|
|
};
|
|
}
|
|
bound[modelName] = wrapped;
|
|
}
|
|
return bound;
|
|
},
|
|
};
|
|
|
|
return fake;
|
|
}
|
|
|
|
/**
|
|
* Bindungsnachweis: mindestens ein Aufruf von `<tenantId>.<model>.<method>`
|
|
* lief ueber den gebundenen Client (nicht ueber den rohen, ungebundenen
|
|
* Fake). Ein vergessener `forTenant()`-Aufruf hinterlaesst hier KEINEN
|
|
* Eintrag und laesst den Test fehlschlagen.
|
|
*/
|
|
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
|
|
const found = prisma.__boundCallLog.some(
|
|
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
|
|
);
|
|
expect(
|
|
found,
|
|
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
|
).toBe(true);
|
|
}
|
|
|
|
/**
|
|
* Wachhund-Gegenprobe (Aufgabe 2): ein Modell darf im Bindungsprotokoll gar
|
|
* nicht vorkommen — das ist der Testfall, der jemanden erwischt, der den
|
|
* bewusst ungebundenen Katalogzugriff spaeter versehentlich bindet.
|
|
*/
|
|
function expectNeverBound(prisma: any, model: string) {
|
|
const found = prisma.__boundCallLog.some((c: any) => c.model === model);
|
|
expect(
|
|
found,
|
|
`Modell "${model}" darf nie im Bindungsprotokoll auftauchen (Katalog bleibt ungebunden): ${JSON.stringify(prisma.__boundCallLog)}`,
|
|
).toBe(false);
|
|
}
|
|
|
|
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 = makeFakePrisma();
|
|
prisma.tenantModuleActivation.findMany = vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []);
|
|
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.getModuleAccessLevels — Freigabestufe (261002-icv)', () => {
|
|
it('Direkt-Grant USE: Map {mod-1: USE}', async () => {
|
|
const prisma = makeFakePrisma({
|
|
activations: [{ moduleId: 'mod-1' }],
|
|
directGrants: [{ moduleId: 'mod-1', level: 'USE' }],
|
|
});
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
const result = await service.getModuleAccessLevels('t1', 'user-1', 'USER');
|
|
|
|
expect(result).toEqual(new Map([['mod-1', 'USE']]));
|
|
});
|
|
|
|
it('Direkt-Grant USE plus Gruppen-Grant MANAGE auf dasselbe Modul: MANAGE gewinnt (L-02)', async () => {
|
|
const prisma = makeFakePrisma({
|
|
activations: [{ moduleId: 'mod-1' }],
|
|
directGrants: [{ moduleId: 'mod-1', level: 'USE' }],
|
|
groupGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
|
|
});
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE');
|
|
});
|
|
|
|
it('Gruppen-Grant USE plus Direkt-Grant MANAGE: ebenfalls MANAGE (Reihenfolge egal)', async () => {
|
|
const prisma = makeFakePrisma({
|
|
activations: [{ moduleId: 'mod-1' }],
|
|
directGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
|
|
groupGrants: [{ moduleId: 'mod-1', level: 'USE' }],
|
|
});
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE');
|
|
});
|
|
|
|
it('MANAGE nur über eine Gruppe: MANAGE', async () => {
|
|
const prisma = makeFakePrisma({
|
|
activations: [{ moduleId: 'mod-1' }],
|
|
groupGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
|
|
});
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE');
|
|
});
|
|
|
|
it('MANAGE-Grant auf ein deaktiviertes Modul: fehlt in der Map (T-icv-05)', async () => {
|
|
const prisma = makeFakePrisma({
|
|
activations: [],
|
|
directGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
|
|
});
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).has('mod-1')).toBe(false);
|
|
});
|
|
|
|
it.each(['ADMIN', 'SUPER_ADMIN'] as const)(
|
|
'%s: jedes aktive Modul mit MANAGE, ohne Grant-Abfragen (L-03)',
|
|
async (role) => {
|
|
const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }, { moduleId: 'mod-2' }] });
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
const result = await service.getModuleAccessLevels('t1', 'a-1', role);
|
|
|
|
expect(result).toEqual(new Map([['mod-1', 'MANAGE'], ['mod-2', 'MANAGE']]));
|
|
expect(prisma.moduleGrant.findMany).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('ohne Grants: leere Map', async () => {
|
|
const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }] });
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
expect(await service.getModuleAccessLevels('t1', 'user-1', 'USER')).toEqual(new Map());
|
|
});
|
|
|
|
it('Zeilen ohne level-Feld (alte Mocks) zählen als USE', async () => {
|
|
const prisma = makeFakePrisma({
|
|
activations: [{ moduleId: 'mod-1' }],
|
|
directGrants: [{ moduleId: 'mod-1' }],
|
|
});
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('USE');
|
|
});
|
|
|
|
it('findAccessibleModules liefert canManage je Zeile', async () => {
|
|
const prisma = makeFakePrisma({
|
|
activations: [{ moduleId: 'mod-1' }, { moduleId: 'mod-2' }],
|
|
directGrants: [
|
|
{ moduleId: 'mod-1', level: 'MANAGE' },
|
|
{ moduleId: 'mod-2', level: 'USE' },
|
|
],
|
|
});
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
const rows = await service.findAccessibleModules('t1', 'user-1', 'USER');
|
|
|
|
expect(rows.find((r: any) => r.id === 'mod-1')?.canManage).toBe(true);
|
|
expect(rows.find((r: any) => r.id === 'mod-2')?.canManage).toBe(false);
|
|
});
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|
|
|
|
// --- Bindung an forTenant() (260910-exd, Aufgabe 2) -------------------------
|
|
|
|
describe('ModuleAccessService — Bindung an forTenant() (260910-exd)', () => {
|
|
it('Kurzschlusszweig (ADMIN) bindet seinen Aktivierungs-Lesezugriff an die Mandantenkennung aus dem Sitzungsnachweis', async () => {
|
|
const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }] });
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN');
|
|
|
|
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
|
|
});
|
|
|
|
it('USER-Zweig bindet BEIDE Freigabe-Lesezugriffe (Direktweg und Gruppenweg) UND den Schnittmengen-Lesezugriff an DIESELBE Mandantenkennung', async () => {
|
|
const prisma = makeFakePrisma({
|
|
activations: [{ moduleId: 'mod-1' }],
|
|
directGrants: [{ moduleId: 'mod-1' }],
|
|
groupGrants: [{ moduleId: 'mod-1' }],
|
|
});
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
|
|
|
const grantCalls = prisma.__boundCallLog.filter(
|
|
(c: any) => c.model === 'moduleGrant' && c.method === 'findMany',
|
|
);
|
|
expect(grantCalls.length).toBe(2);
|
|
expect(grantCalls.every((c: any) => c.tenantId === 't1')).toBe(true);
|
|
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
|
|
});
|
|
|
|
it('Vorgabezustand bleibt geschlossen und ueberlebt die Bindung: ohne Grants leeres Set, der Schnittmengen-Lesezugriff wird gar nicht erst ausgefuehrt', 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());
|
|
const activationCalls = prisma.__boundCallLog.filter(
|
|
(c: any) => c.model === 'tenantModuleActivation' && c.method === 'findMany',
|
|
);
|
|
expect(
|
|
activationCalls,
|
|
`der Schnittmengen-Lesezugriff auf tenantModuleActivation darf ohne Grants nicht stattfinden, gefunden: ${JSON.stringify(activationCalls)}`,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('Rollen-Kurzschluss waechst durch die Bindung nicht: eine Aufloesung fuer einen zweiten Mandanten leitet keine Module des ersten ab, die gebundene Kennung im Protokoll ist die des zweiten', async () => {
|
|
const activationsByTenant: Record<string, { moduleId: string }[]> = {
|
|
t1: [{ moduleId: 'mod-tenant-1' }],
|
|
t2: [{ moduleId: 'mod-tenant-2' }],
|
|
};
|
|
const prisma = makeFakePrisma();
|
|
prisma.tenantModuleActivation.findMany = vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []);
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
const resultT2 = await service.getAccessibleModuleIds('t2', 'admin-2', 'ADMIN');
|
|
|
|
expect(resultT2).toEqual(new Set(['mod-tenant-2']));
|
|
expectBoundCall(prisma, 't2', 'tenantModuleActivation', 'findMany');
|
|
const t1Calls = prisma.__boundCallLog.filter((c: any) => c.tenantId === 't1');
|
|
expect(t1Calls, `keine Bindung an t1 erwartet: ${JSON.stringify(t1Calls)}`).toEqual([]);
|
|
});
|
|
|
|
it('findAccessibleModules erreicht den Katalog ueber den UNGEBUNDENEN Klienten — der Katalogzugriff taucht im Bindungsprotokoll nicht auf', 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');
|
|
|
|
expectNeverBound(prisma, 'module');
|
|
expect(prisma.module.findMany).toHaveBeenCalled();
|
|
});
|
|
|
|
it('getCatalogFlags bindet seinen eigenen Aktivierungs-Lesezugriff, und die geschachtelte Aufloesung erzeugt ihren eigenen gebundenen Klienten mit derselben Mandantenkennung', async () => {
|
|
const prisma = makeFakePrisma({
|
|
activations: [{ moduleId: 'mod-1' }],
|
|
directGrants: [{ moduleId: 'mod-1' }],
|
|
});
|
|
const service = new ModuleAccessService(prisma as any);
|
|
|
|
await service.getCatalogFlags('t1', 'user-1', 'USER');
|
|
|
|
const activationCalls = prisma.__boundCallLog.filter(
|
|
(c: any) => c.model === 'tenantModuleActivation' && c.method === 'findMany',
|
|
);
|
|
// Ein Aufruf fuer getCatalogFlags selbst, ein zweiter aus der
|
|
// geschachtelten getAccessibleModuleIds-Aufloesung — beide gebunden an
|
|
// denselben Mandanten, aber ueber je einen eigenen forTenant()-Aufruf
|
|
// (dieselbe Konvention wie module-grants.service.ts).
|
|
expect(activationCalls.length).toBe(2);
|
|
expect(activationCalls.every((c: any) => c.tenantId === 't1')).toBe(true);
|
|
expectNeverBound(prisma, 'module');
|
|
});
|
|
});
|