import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Role } from '@prisma/client'; import { describe, expect, it, vi } from 'vitest'; // `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft // rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten je Methode // gebunden wird (mit oder ohne Benutzer). vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((p: unknown) => p), })); import { forTenant } from '../prisma/prisma-tenant.extension'; import { CustomModulesService } from './custom-modules.service'; function makeFakePrisma() { const rows = new Map(); let seq = 0; const customModule = { create: vi.fn(async ({ data }: { data: any }) => { const id = `cm-${++seq}`; const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data }; rows.set(id, row); return row; }), findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => { let list = [...rows.values()]; if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId); if (where?.OR) { list = list.filter((r) => where.OR.some((c: { ownerUserId: string | null }) => r.ownerUserId === c.ownerUserId), ); } if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name)); return list; }), findUnique: vi.fn(async ({ where }: { where: { id: string } }) => rows.get(where.id) ?? null), update: vi.fn(async ({ where, data }: { where: { id: string }; data: any }) => { const row = { ...rows.get(where.id), ...data }; rows.set(where.id, row); return row; }), delete: vi.fn(async ({ where }: { where: { id: string } }) => { rows.delete(where.id); }), }; return { customModule, rows }; } const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const }; const admin = { id: 'admin1', role: Role.ADMIN }; const userA = { id: 'ua', role: Role.USER }; const userB = { id: 'ub', role: Role.USER }; function setup() { const prisma = makeFakePrisma(); return { prisma, service: new CustomModulesService(prisma as any) }; } describe('CustomModulesService — anlegen', () => { it('speichert tenantId aus dem Argument, nie aus dem DTO', async () => { const { prisma, service } = setup(); await service.create('t1', userA, { ...dto, tenantId: 'evil' } as any); expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1'); }); it('ohne shared-Angabe ist der Eintrag persoenlich (ownerUserId = Aufrufer)', async () => { const { prisma, service } = setup(); const res: any = await service.create('t1', userA, dto); expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua'); expect(res.personal).toBe(true); expect(res).not.toHaveProperty('ownerUserId'); }); it('auch ein Administrator legt ohne shared persoenlich an', async () => { const { prisma, service } = setup(); const res: any = await service.create('t1', admin, dto); expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('admin1'); expect(res.personal).toBe(true); }); it('shared: true durch einen Administrator legt einen gemeinsamen Eintrag an', async () => { const { prisma, service } = setup(); const res: any = await service.create('t1', admin, { ...dto, shared: true }); expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBeNull(); expect(res.personal).toBe(false); }); it('shared: true durch einen normalen Benutzer -> ForbiddenException, nichts gespeichert', async () => { const { prisma, service } = setup(); await expect(service.create('t1', userA, { ...dto, shared: true })).rejects.toBeInstanceOf( ForbiddenException, ); expect(prisma.customModule.create).not.toHaveBeenCalled(); }); it('shared: false durch einen normalen Benutzer bleibt persoenlich', async () => { const { prisma, service } = setup(); await service.create('t1', userA, { ...dto, shared: false }); expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua'); }); it('das Feld shared landet nie in den gespeicherten Daten', async () => { const { prisma, service } = setup(); await service.create('t1', admin, { ...dto, shared: true }); expect(prisma.customModule.create.mock.calls[0][0].data).not.toHaveProperty('shared'); }); }); describe('CustomModulesService — lesen', () => { it('list liefert gemeinsame plus eigene Eintraege, nie die eines anderen Benutzers', async () => { const { service } = setup(); await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true }); await service.create('t1', userA, { ...dto, name: 'A-privat' }); await service.create('t1', userB, { ...dto, name: 'B-privat' }); const resA: any[] = await service.list('t1', userA); expect(resA.map((r) => [r.name, r.personal])).toEqual([ ['A-privat', true], ['Gemeinsam', false], ]); const resB: any[] = await service.list('t1', userB); expect(resB.map((r) => r.name)).toEqual(['B-privat', 'Gemeinsam']); }); it('list sieht auch als Administrator keine persoenlichen Eintraege anderer', async () => { const { service } = setup(); await service.create('t1', userA, { ...dto, name: 'A-privat' }); await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true }); const res: any[] = await service.list('t1', admin); expect(res.map((r) => r.name)).toEqual(['Gemeinsam']); }); it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => { const { prisma, service } = setup(); await service.create('t1', admin, { ...dto, name: 'Zebra', shared: true }); await service.create('t1', admin, { ...dto, name: 'Anker', shared: true }); await service.create('t2', admin, { ...dto, name: 'Fremd', shared: true }); const result: any[] = await service.list('t1', userA); expect(result.map((r) => r.name)).toEqual(['Anker', 'Zebra']); expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({ tenantId: 't1', OR: [{ ownerUserId: null }, { ownerUserId: 'ua' }], }); }); it('getOne liefert einen gemeinsamen Eintrag jedem, ohne tenantId und ownerUserId', async () => { const { service } = setup(); const created: any = await service.create('t1', admin, { ...dto, shared: true }); const row: any = await service.getOne('t1', userA, created.id); expect(row.name).toBe('Wiki'); expect(row.personal).toBe(false); expect(row).not.toHaveProperty('tenantId'); expect(row).not.toHaveProperty('ownerUserId'); }); it('getOne liefert einen eigenen persoenlichen Eintrag', async () => { const { service } = setup(); const created: any = await service.create('t1', userA, dto); const row: any = await service.getOne('t1', userA, created.id); expect(row.personal).toBe(true); }); it('getOne auf den persoenlichen Eintrag eines anderen -> NotFoundException (auch fuer Administratoren)', async () => { const { service } = setup(); const created: any = await service.create('t1', userA, dto); await expect(service.getOne('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException); await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException); }); it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => { const { service } = setup(); await expect(service.getOne('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException); await expect(service.update('t1', userA, 'nope', { name: 'x' })).rejects.toBeInstanceOf( NotFoundException, ); await expect(service.remove('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException); }); it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => { const { prisma, service } = setup(); const created: any = await service.create('t2', admin, { ...dto, shared: true }); await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException); await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf( NotFoundException, ); await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException); expect(prisma.customModule.update).not.toHaveBeenCalled(); expect(prisma.customModule.delete).not.toHaveBeenCalled(); }); }); describe('CustomModulesService — aendern und loeschen', () => { it('der Besitzer aendert und loescht seinen persoenlichen Eintrag', async () => { const { prisma, service } = setup(); const created: any = await service.create('t1', userA, dto); const updated: any = await service.update('t1', userA, created.id, { name: 'Neu' }); expect(updated.name).toBe('Neu'); expect(updated.personal).toBe(true); await expect(service.remove('t1', userA, created.id)).resolves.toEqual({ deleted: true }); expect(prisma.rows.size).toBe(0); }); it('ein anderer Benutzer kann den persoenlichen Eintrag weder aendern noch loeschen (404)', async () => { const { prisma, service } = setup(); const created: any = await service.create('t1', userA, dto); await expect(service.update('t1', userB, created.id, { name: 'x' })).rejects.toBeInstanceOf( NotFoundException, ); await expect(service.remove('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException); expect(prisma.customModule.update).not.toHaveBeenCalled(); expect(prisma.customModule.delete).not.toHaveBeenCalled(); }); it('auch ein Administrator kann den persoenlichen Eintrag eines Benutzers nicht aendern (404)', async () => { const { prisma, service } = setup(); const created: any = await service.create('t1', userA, dto); await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf( NotFoundException, ); await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException); expect(prisma.rows.size).toBe(1); }); it('ein normaler Benutzer kann einen gemeinsamen Eintrag weder aendern noch loeschen (403)', async () => { const { prisma, service } = setup(); const created: any = await service.create('t1', admin, { ...dto, shared: true }); await expect(service.update('t1', userA, created.id, { name: 'x' })).rejects.toBeInstanceOf( ForbiddenException, ); await expect(service.remove('t1', userA, created.id)).rejects.toBeInstanceOf( ForbiddenException, ); expect(prisma.customModule.update).not.toHaveBeenCalled(); expect(prisma.customModule.delete).not.toHaveBeenCalled(); }); it('ein Administrator aendert und loescht einen gemeinsamen Eintrag', async () => { const { prisma, service } = setup(); const created: any = await service.create('t1', admin, { ...dto, shared: true }); const updated: any = await service.update('t1', admin, created.id, { name: 'Neu' }); expect(updated.name).toBe('Neu'); expect(updated.personal).toBe(false); await expect(service.remove('t1', admin, created.id)).resolves.toEqual({ deleted: true }); expect(prisma.rows.size).toBe(0); }); it('update aendert nur gesetzte Felder und nie Besitz oder Gemeinsamkeit', async () => { const { prisma, service } = setup(); const created: any = await service.create('t1', userA, dto); await service.update('t1', userA, created.id, { name: 'Neu', shared: true, ownerUserId: 'ub', } as any); expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' }); }); }); describe('CustomModulesService — RLS-Bindung', () => { it('bindet persoenliche Zugriffe mit Benutzer, gemeinsame Schreibzugriffe ohne', async () => { const { prisma, service } = setup(); vi.mocked(forTenant).mockClear(); const shared: any = await service.create('t1', admin, { ...dto, shared: true }); const mine: any = await service.create('t1', userA, dto); await service.list('t1', userA); await service.getOne('t1', userA, mine.id); await service.update('t1', userA, mine.id, { name: 'a' }); await service.update('t1', admin, shared.id, { name: 'b' }); const calls = vi.mocked(forTenant).mock.calls; // create shared: ohne Benutzer expect(calls[0]).toEqual([prisma, 't1']); // create personal + list + getOne + (update personal: Laden + Schreiben) expect(calls[1]).toEqual([prisma, 't1', 'ua']); expect(calls[2]).toEqual([prisma, 't1', 'ua']); expect(calls[3]).toEqual([prisma, 't1', 'ua']); // update shared als Admin: Laden mit Benutzer, Schreiben ohne expect(calls[calls.length - 2]).toEqual([prisma, 't1', 'admin1']); expect(calls[calls.length - 1]).toEqual([prisma, 't1']); }); });