import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ModuleGrantsService } from './module-grants.service'; /** * ModuleGrantsService.spec — Beweis für PERM-03 (D-15/D-16), die * Entweder-oder-Regel (D-04) und die Mandanten-Gegenprüfung vor jedem * Grant-Insert (T-15-01). Hand-rolled In-Memory-Prisma-Fake im Stil von * groups.service.spec.ts / module-access.service.spec.ts — keine Live-DB, * P2002 wird exakt wie ein echter Postgres-Client über den Fehlercode * simuliert. * * Bindung an forTenant() (260909-jts, Aufgabe 3, Befund C uebertragen von * groups.service.spec.ts): derselbe Mock wie dort — der gebundene Client * ist ein ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN * Speicher, das protokolliert, welche Aufrufe ueber ihn liefen. Ein reiner * Identitaets-Mock (`forTenant: vi.fn((p) => p)`) koennte einen * vergessenen Bindungsaufruf nicht von einem ungebundenen Aufruf * unterscheiden. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), })); function makeFakePrisma() { const groups = new Map(); const users = new Map(); const memberships = new Map>(); // groupId -> Set const membershipSources = new Map(); // `${groupId}::${userId}` -> source const activations = new Map(); // key: tenantId::moduleId const boundCallLog: { tenantId: string; model: string; method: string }[] = []; const grants = new Map(); let grantCounter = 0; function throwUnique(): never { const err: any = new Error('Unique constraint failed'); err.code = 'P2002'; throw err; } function findGrant( tenantId: string, moduleId: string, groupId?: string | null, userId?: string | null, ) { return Array.from(grants.values()).find( (g) => g.tenantId === tenantId && g.moduleId === moduleId && (g.groupId ?? null) === (groupId ?? null) && (g.userId ?? null) === (userId ?? null), ); } const fake: any = { __seedGroup(group: { id: string; tenantId: string; name: string; internalName?: string | null }) { groups.set(group.id, { internalName: null, ...group }); }, __seedUser(user: { id: string; tenantId: string }) { users.set(user.id, user); }, __seedMembership(groupId: string, userId: string, source: string = 'MANUAL') { const set = memberships.get(groupId) ?? new Set(); set.add(userId); memberships.set(groupId, set); membershipSources.set(`${groupId}::${userId}`, source); }, __seedActivation(a: { tenantId: string; moduleId: string; isActive: boolean; module: { id: string; category: string; name: string }; }) { activations.set(`${a.tenantId}::${a.moduleId}`, a); }, __grantCount() { return grants.size; }, group: { findFirst: async ({ where }: any) => { return ( Array.from(groups.values()).find( (g) => g.id === where.id && g.tenantId === where.tenantId, ) ?? null ); }, findMany: async ({ where }: any) => { return Array.from(groups.values()) .filter((g) => g.tenantId === where.tenantId) .sort((a, b) => a.name.localeCompare(b.name)); }, }, user: { findFirst: async ({ where }: any) => { return ( Array.from(users.values()).find( (u) => u.id === where.id && u.tenantId === where.tenantId, ) ?? null ); }, }, tenantModuleActivation: { findUnique: async ({ where }: any) => { const { tenantId, moduleId } = where.tenantId_moduleId; return activations.get(`${tenantId}::${moduleId}`) ?? null; }, findMany: async ({ where }: any) => { return Array.from(activations.values()).filter( (a) => a.tenantId === where.tenantId && a.isActive === where.isActive, ); }, }, moduleGrant: { create: async ({ data }: any) => { if (findGrant(data.tenantId, data.moduleId, data.groupId, data.userId)) { throwUnique(); } grantCounter += 1; const record = { id: `grant-${grantCounter}`, createdAt: new Date(), ...data }; grants.set(record.id, record); return record; }, findFirst: async ({ where }: any) => { return findGrant(where.tenantId, where.moduleId, where.groupId, where.userId) ?? null; }, findMany: async ({ where }: any) => { let rows = Array.from(grants.values()).filter((g) => g.tenantId === where.tenantId); if (where.moduleId !== undefined) { rows = rows.filter((g) => g.moduleId === where.moduleId); } if (where.groupId?.not === null) { rows = rows.filter((g) => g.groupId !== null && g.groupId !== undefined); } if (where.group) { const userId = where.group.memberships.some.userId; rows = rows .filter((g) => g.groupId && memberships.get(g.groupId)?.has(userId)) .map((g) => ({ ...g, group: groups.get(g.groupId) })); } else if (where.userId !== undefined) { rows = rows.filter((g) => g.userId === where.userId); } return rows; }, deleteMany: async ({ where }: any) => { let count = 0; for (const [id, g] of grants.entries()) { if ( g.tenantId === where.tenantId && g.moduleId === where.moduleId && (where.groupId === undefined || g.groupId === where.groupId) && (where.userId === undefined || g.userId === where.userId) ) { grants.delete(id); count += 1; } } return { count }; }, }, groupMembership: { findMany: async ({ where }: any) => { const userId = where.userId; const tenantId = where.group.tenantId; const rows: any[] = []; for (const [groupId, memberSet] of memberships.entries()) { if (!memberSet.has(userId)) continue; const group = groups.get(groupId); if (!group || group.tenantId !== tenantId) continue; rows.push({ groupId, userId, source: membershipSources.get(`${groupId}::${userId}`) ?? 'MANUAL', group: { id: group.id, name: group.name, internalName: group.internalName ?? null }, }); } return rows; }, }, // --- Bindungsnachweis (260909-jts, 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; } /** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das * Herkunfts-Tenant protokollierenden Wrapper versieht. */ const BOUND_MODEL_NAMES = ['group', 'user', 'tenantModuleActivation', 'moduleGrant', 'groupMembership']; /** * Bindungsnachweis: mindestens ein Aufruf von `..` * 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); } function seedBase(prisma: ReturnType) { prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Gruppe A' }); prisma.__seedUser({ id: 'u1', tenantId: 't1' }); prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-1', isActive: true, module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' }, }); } describe('ModuleGrantsService.grant', () => { it('legt einen Gruppen-Grant an und gibt ihn zurück', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); const result = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); expect(result.moduleId).toBe('mod-1'); expect(result.groupId).toBe('g1'); expect(result.userId ?? null).toBeNull(); }); it('legt einen Direkt-Grant an und gibt ihn zurück', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); const result = await service.grant('t1', { moduleId: 'mod-1', userId: 'u1' }); expect(result.moduleId).toBe('mod-1'); expect(result.userId).toBe('u1'); expect(result.groupId ?? null).toBeNull(); }); it('wirft BadRequestException, wenn groupId UND userId gesetzt sind', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); await expect( service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', userId: 'u1' }), ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.__grantCount()).toBe(0); }); it('wirft BadRequestException, wenn weder groupId noch userId gesetzt sind', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); await expect(service.grant('t1', { moduleId: 'mod-1' })).rejects.toBeInstanceOf( BadRequestException, ); expect(prisma.__grantCount()).toBe(0); }); // Diese beiden Faelle (T-JTS-03, 260910-jab, Aufgabe 2) beweisen, dass // assertTargetBelongsToTenant() weiterhin im Anwendungscode scheitert — // nicht erst in der Datenbank. Seit // 20260910120000_rls_widen_membership_grant_and_platform_read zieht auch // die Datenbankregel dieselbe Grenze, aber erst NACH dem Scharfschalten // (#18 ist weiterhin aus). Wuerde assertTargetBelongsToTenant() im // Vertrauen auf "das macht jetzt die Datenbank" entfernt, werden GENAU // diese beiden Faelle rot: der Fake hier hat keine RLS-Policy, nur das // reale ModuleGrant/Group/User-Schema tut das. it('wirft NotFoundException für eine groupId aus einem anderen Mandanten und legt nichts an', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedGroup({ id: 'g-foreign', tenantId: 't2', name: 'Fremde Gruppe' }); const service = new ModuleGrantsService(prisma as any); await expect( service.grant('t1', { moduleId: 'mod-1', groupId: 'g-foreign' }), ).rejects.toBeInstanceOf(NotFoundException); expect(prisma.__grantCount()).toBe(0); }); it('wirft NotFoundException für eine userId aus einem anderen Mandanten und legt nichts an', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' }); const service = new ModuleGrantsService(prisma as any); await expect( service.grant('t1', { moduleId: 'mod-1', userId: 'u-foreign' }), ).rejects.toBeInstanceOf(NotFoundException); expect(prisma.__grantCount()).toBe(0); }); it('wirft BadRequestException, wenn keine aktive TenantModuleActivation für das Modul existiert', async () => { const prisma = makeFakePrisma(); prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Gruppe A' }); // keine Activation geseedet const service = new ModuleGrantsService(prisma as any); await expect( service.grant('t1', { moduleId: 'mod-unaktiviert', groupId: 'g1' }), ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.__grantCount()).toBe(0); }); it('idempotency: ein zweiter Grant auf dieselbe Kombination legt keinen zweiten Datensatz an und wirft nicht', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); const first = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); const second = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); expect(second.id).toBe(first.id); expect(prisma.__grantCount()).toBe(1); }); it('concurrency: zwei parallele Grant-Erstellungen für dieselbe Kombination führen zu genau einer Zeile, keine der beiden wirft', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); const [first, second] = await Promise.all([ service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }), service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }), ]); expect(first.groupId).toBe('g1'); expect(second.groupId).toBe('g1'); expect(prisma.__grantCount()).toBe(1); }); }); describe('ModuleGrantsService.revoke', () => { it('entfernt einen bestehenden Grant', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }); expect(prisma.__grantCount()).toBe(0); }); it('idempotency: ein zweites Entziehen eines bereits entzogenen Grants ist folgenlos und wirft nicht', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }); await expect( service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }), ).resolves.not.toThrow(); expect(prisma.__grantCount()).toBe(0); }); it('entfernt nichts, wenn die groupId aus einem anderen Mandanten stammt', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); await service.revoke('t2', { moduleId: 'mod-1', groupId: 'g1' }); expect(prisma.__grantCount()).toBe(1); }); }); describe('ModuleGrantsService.getMatrix', () => { it('liefert modules, groups und grants; Module nach category+name, Gruppen nach name sortiert', async () => { const prisma = makeFakePrisma(); prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-b', isActive: true, module: { id: 'mod-b', category: 'zzz', name: 'B-Modul' }, }); prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-a', isActive: true, module: { id: 'mod-a', category: 'aaa', name: 'A-Modul' }, }); prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Zeta' }); prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Alpha' }); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-a', groupId: 'g1' }); const matrix = await service.getMatrix('t1'); expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-a', 'mod-b']); expect(matrix.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Zeta']); expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1' }]); }); it('empty: ein Mandant ohne Gruppen liefert eine leere Gruppenliste und wirft nicht', async () => { const prisma = makeFakePrisma(); prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-1', isActive: true, module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' }, }); const service = new ModuleGrantsService(prisma as any); const matrix = await service.getMatrix('t1'); expect(matrix.groups).toEqual([]); expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-1']); }); it('ordering: die Matrix-Antwort liefert dieselbe Reihenfolge über wiederholte Aufrufe', async () => { const prisma = makeFakePrisma(); prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-b', isActive: true, module: { id: 'mod-b', category: 'zzz', name: 'B-Modul' }, }); prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-a', isActive: true, module: { id: 'mod-a', category: 'aaa', name: 'A-Modul' }, }); const service = new ModuleGrantsService(prisma as any); const first = await service.getMatrix('t1'); const second = await service.getMatrix('t1'); expect(first.modules.map((m: any) => m.id)).toEqual(second.modules.map((m: any) => m.id)); }); }); describe('ModuleGrantsService.getUserAccess', () => { it('liefert je aktivem Modul die geerbten Gruppen und den Direkt-Grant-Status', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedMembership('g1', 'u1'); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); const result = await service.getUserAccess('t1', 'u1'); expect(result.modules).toEqual([ { module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' }, viaGroups: ['Gruppe A'], direct: false, }, ]); expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]); }); it('adjacency: ein Direkt-Grant UND ein Gruppen-Grant auf dasselbe Modul erscheinen gleichzeitig, keiner verdrängt den anderen', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedMembership('g1', 'u1'); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); await service.grant('t1', { moduleId: 'mod-1', userId: 'u1' }); const result = await service.getUserAccess('t1', 'u1'); expect(result.modules[0].viaGroups).toEqual(['Gruppe A']); expect(result.modules[0].direct).toBe(true); }); it('wirft NotFoundException für eine userId aus einem anderen Mandanten', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' }); const service = new ModuleGrantsService(prisma as any); await expect(service.getUserAccess('t1', 'u-foreign')).rejects.toBeInstanceOf( NotFoundException, ); }); it('REGRESSION: Mitglied einer Gruppe ohne Modul-Freigabe bleibt sichtbar', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedMembership('g1', 'u1'); // Bewusst KEIN Grant für g1 auf mod-1. const service = new ModuleGrantsService(prisma as any); const result = await service.getUserAccess('t1', 'u1'); expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]); expect(result.modules.every((m: any) => m.viaGroups.length === 0)).toBe(true); }); it('Cross-Tenant: eine Mitgliedschaft in einer Gruppe eines fremden Mandanten erscheint nicht in groups', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedGroup({ id: 'g-foreign', tenantId: 't2', name: 'Fremde Gruppe' }); prisma.__seedMembership('g-foreign', 'u1'); const service = new ModuleGrantsService(prisma as any); const result = await service.getUserAccess('t1', 'u1'); expect(result.groups).toEqual([]); }); it('Herkunft: eine mit source LDAP geseedete Mitgliedschaft kommt mit source LDAP zurück', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedMembership('g1', 'u1', 'LDAP'); const service = new ModuleGrantsService(prisma as any); const result = await service.getUserAccess('t1', 'u1'); expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'LDAP' }]); }); it('ohne aktives Modul im Mandanten: modules ist leer, groups trotzdem befüllt', async () => { const prisma = makeFakePrisma(); prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Gruppe A' }); prisma.__seedUser({ id: 'u1', tenantId: 't1' }); // keine Activation geseedet prisma.__seedMembership('g1', 'u1'); const service = new ModuleGrantsService(prisma as any); const result = await service.getUserAccess('t1', 'u1'); expect(result.modules).toEqual([]); expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]); }); it('Sortierung: groups ist alphabetisch nach name stabil über wiederholte Aufrufe', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Alpha' }); prisma.__seedMembership('g1', 'u1'); prisma.__seedMembership('g2', 'u1'); const service = new ModuleGrantsService(prisma as any); const first = await service.getUserAccess('t1', 'u1'); const second = await service.getUserAccess('t1', 'u1'); expect(first.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Gruppe A']); expect(second.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Gruppe A']); }); }); describe('ModuleGrantsService.getUserAccess — Anzeigename mit Fallback (D-04)', () => { it('gesetzter internalName: beide Projektionen (groups[].name, modules[].viaGroups) liefern den internen Namen', async () => { const prisma = makeFakePrisma(); prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'AD-Rohname', internalName: 'Vertrieb' }); prisma.__seedUser({ id: 'u1', tenantId: 't1' }); prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-1', isActive: true, module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' }, }); prisma.__seedMembership('g1', 'u1'); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); const result = await service.getUserAccess('t1', 'u1'); expect(result.groups).toEqual([{ id: 'g1', name: 'Vertrieb', source: 'MANUAL' }]); expect(result.modules[0].viaGroups).toEqual(['Vertrieb']); }); it('kein internalName (null): beide Projektionen fallen auf name zurueck', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedMembership('g1', 'u1'); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); const result = await service.getUserAccess('t1', 'u1'); expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]); expect(result.modules[0].viaGroups).toEqual(['Gruppe A']); }); it('Sortierung laeuft ueber den angezeigten Namen, nicht ueber die Datenbankspalte name', async () => { const prisma = makeFakePrisma(); prisma.__seedUser({ id: 'u1', tenantId: 't1' }); // g1 traegt in der DB den Namen "Zebra-AD", zeigt aber "Alpha-Anzeige" an; // g2 traegt "Beta" ohne internalName. Alphabetisch nach ANGEZEIGTEM Namen // muesste g1 (Alpha-Anzeige) vor g2 (Beta) stehen, waehrend eine Sortierung // ueber die rohe name-Spalte g2 (Beta) vor g1 (Zebra-AD) haette gestellt. prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Zebra-AD', internalName: 'Alpha-Anzeige' }); prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Beta' }); prisma.__seedMembership('g1', 'u1'); prisma.__seedMembership('g2', 'u1'); const service = new ModuleGrantsService(prisma as any); const result = await service.getUserAccess('t1', 'u1'); expect(result.groups.map((g: any) => g.name)).toEqual(['Alpha-Anzeige', 'Beta']); }); }); describe('ModuleGrantsService — Logging (D-23)', () => { afterEach(() => { vi.restoreAllMocks(); }); it('grant schreibt eine Logzeile mit Mandant, Modul, Ziel und Aktion', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); expect(logSpy).toHaveBeenCalled(); const message = logSpy.mock.calls[0][0] as string; expect(message).toContain('t1'); expect(message).toContain('mod-1'); expect(message).toContain('g1'); }); it('revoke schreibt eine Logzeile mit Mandant, Modul, Ziel und Aktion', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); logSpy.mockClear(); await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }); expect(logSpy).toHaveBeenCalled(); const message = logSpy.mock.calls[0][0] as string; expect(message).toContain('t1'); expect(message).toContain('mod-1'); expect(message).toContain('g1'); }); }); // --- Bindung an forTenant() (260909-jts, Aufgabe 3) ------------------------- describe('ModuleGrantsService — Bindung an forTenant() (260909-jts)', () => { it('grant() bindet die Mandanten-Gegenpruefung, die Aktivierungspruefung und moduleGrant.create an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); expectBoundCall(prisma, 't1', 'group', 'findFirst'); expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findUnique'); expectBoundCall(prisma, 't1', 'moduleGrant', 'create'); }); it('grant() bindet auch die Mandanten-Gegenpruefung fuer eine userId und bleibt wirksam gegen einen fremden Benutzer (T-15-01)', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' }); const service = new ModuleGrantsService(prisma as any); await expect( service.grant('t1', { moduleId: 'mod-1', userId: 'u-foreign' }), ).rejects.toBeInstanceOf(NotFoundException); expectBoundCall(prisma, 't1', 'user', 'findFirst'); }); it('revoke() bindet moduleGrant.deleteMany an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }); expectBoundCall(prisma, 't1', 'moduleGrant', 'deleteMany'); }); it('getMatrix() bindet alle drei parallelen Teilabfragen (tenantModuleActivation, group, moduleGrant) an DENSELBEN gebundenen Mandanten', async () => { const prisma = makeFakePrisma(); seedBase(prisma); const service = new ModuleGrantsService(prisma as any); await service.getMatrix('t1'); expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany'); expectBoundCall(prisma, 't1', 'group', 'findMany'); expectBoundCall(prisma, 't1', 'moduleGrant', 'findMany'); }); it('getUserAccess() bindet alle vier parallelen Teilabfragen (tenantModuleActivation, moduleGrant x2, groupMembership) an DENSELBEN gebundenen Mandanten', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedMembership('g1', 'u1'); const service = new ModuleGrantsService(prisma as any); await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); await service.getUserAccess('t1', 'u1'); expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany'); expectBoundCall(prisma, 't1', 'moduleGrant', 'findMany'); expectBoundCall(prisma, 't1', 'groupMembership', 'findMany'); }); it('getUserAccess() bindet weiterhin die Mandanten-Gegenpruefung — sie wird durch die Bindung NICHT ersetzt', async () => { const prisma = makeFakePrisma(); seedBase(prisma); prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' }); const service = new ModuleGrantsService(prisma as any); await expect(service.getUserAccess('t1', 'u-foreign')).rejects.toBeInstanceOf( NotFoundException, ); expectBoundCall(prisma, 't1', 'user', 'findFirst'); }); });