diff --git a/apps/api/src/groups/module-grants.service.spec.ts b/apps/api/src/groups/module-grants.service.spec.ts index f46dae5..de666fb 100644 --- a/apps/api/src/groups/module-grants.service.spec.ts +++ b/apps/api/src/groups/module-grants.service.spec.ts @@ -15,6 +15,7 @@ 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 grants = new Map(); let grantCounter = 0; @@ -47,10 +48,11 @@ function makeFakePrisma() { __seedUser(user: { id: string; tenantId: string }) { users.set(user.id, user); }, - __seedMembership(groupId: string, userId: string) { + __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; @@ -145,6 +147,25 @@ function makeFakePrisma() { 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 }, + }); + } + return rows; + }, + }, }; } @@ -383,13 +404,14 @@ describe('ModuleGrantsService.getUserAccess', () => { const result = await service.getUserAccess('t1', 'u1'); - expect(result).toEqual([ + 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 () => { @@ -402,8 +424,8 @@ describe('ModuleGrantsService.getUserAccess', () => { const result = await service.getUserAccess('t1', 'u1'); - expect(result[0].viaGroups).toEqual(['Gruppe A']); - expect(result[0].direct).toBe(true); + 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 () => { @@ -416,6 +438,71 @@ describe('ModuleGrantsService.getUserAccess', () => { 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 — Logging (D-23)', () => {