import { ConflictException } from '@nestjs/common'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { UserService } from './user.service'; /** * UserService — Zwei-Klienten-Nachweis und Linie je Methode (260910-das, * Aufgabe 2, Befund C). * * Diese Datei hatte bisher KEINE Attrappe fuer das Bindungshilfsmittel: der * Prisma-Ersatz war ein nacktes Objekt mit genau einem Eintrag * (`user.create`), es gab keine `forTenant`-Attrappe. Nach der Umstellung * waeren die vier bestehenden Testfaelle rot geworden, aber aus dem * FALSCHEN Grund (das Hilfsmittel bekaeme einen unbrauchbaren Klienten), * nicht wegen einer vergessenen Bindung. Muster nach * `groups.service.spec.ts`: `forTenant(prisma, tenantId)` delegiert an * `prisma.__makeBoundClient(tenantId)`, ein protokollierender Wrapper um * DIESELBEN Maps wie der ungebundene Zugriff — der ungebundene Ersatz * protokolliert nicht, der gebundene schon. * * Der Speicher bildet zusaetzlich die plattformweite Eindeutigkeit von * `username` nach: ein Einfuegen mit einem bereits vergebenen Benutzernamen * wirft einen Fehler mit dem Prisma-Fehlercode fuer Eindeutigkeitsverletzungen * (P2002), UNABHAENGIG davon, welchem Mandanten der bestehende Halter * gehoert — ohne diese Nachbildung ist die zentrale Aussage dieses Bereichs * nicht pruefbar. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), })); function makeFakePrisma() { const users = new Map(); const tenants = new Map(); let userCounter = 0; const boundCallLog: { tenantId: string; model: string; method: string }[] = []; function throwUnique(): never { const err: any = new Error('Unique constraint failed on the fields: (`username`)'); err.code = 'P2002'; throw err; } function throwNotFound(): never { const err: any = new Error('Record to update/delete not found'); err.code = 'P2025'; throw err; } function findByUsernameGlobal(username: string, excludeId?: string) { return Array.from(users.values()).find( (u) => u.username === username && u.id !== excludeId, ); } // Ungebundene (RLS-freie) Grundoperationen — fuer findByUsername() und // jede andere direkte this.prisma.user.*-Nutzung. const rawUser = { findUnique: async ({ where }: any) => { if (where.username !== undefined) { return findByUsernameGlobal(where.username) ?? null; } if (where.id !== undefined) { return users.get(where.id) ?? null; } return null; }, findMany: async ({ where }: any = {}) => { let rows = Array.from(users.values()); if (where?.tenantId !== undefined) { rows = rows.filter((u) => u.tenantId === where.tenantId); } return [...rows].sort((a, b) => a.username.localeCompare(b.username)); }, create: async ({ data }: any) => { if (findByUsernameGlobal(data.username)) throwUnique(); userCounter += 1; const record = { id: `u-${userCounter}`, createdAt: new Date(), ...data }; users.set(record.id, record); return record; }, update: async ({ where, data }: any) => { const existing = users.get(where.id); if (!existing) throwNotFound(); if (data.username && findByUsernameGlobal(data.username, existing.id)) throwUnique(); const record = { ...existing, ...data }; users.set(where.id, record); return record; }, delete: async ({ where }: any) => { const existing = users.get(where.id); if (!existing) throwNotFound(); users.delete(where.id); return existing; }, }; // Gebundene (RLS-simulierende) Fassung: jede Methode filtert zusaetzlich // auf den Mandantenkontext, GENAU wie die ausgelieferte Policy es tut — // unabhaengig davon, ob der Aufrufer selbst ein explizites tenantId ins // where schreibt (UserService tut das bewusst NICHT fuer findById/ // update/deactivate/delete, siehe Befund G). function makeScopedUser(tenantId: string) { return { findUnique: async (args: any) => { const row = await rawUser.findUnique(args); return row && row.tenantId === tenantId ? row : null; }, findMany: async (args: any) => { const rows = await rawUser.findMany(args); return rows.filter((r: any) => r.tenantId === tenantId); }, create: async (args: any) => { if (args.data.tenantId !== tenantId) { const err: any = new Error( 'new row violates row-level security policy for table "User"', ); err.code = '42501'; throw err; } return rawUser.create(args); }, update: async (args: any) => { const existing = users.get(args.where.id); if (!existing || existing.tenantId !== tenantId) throwNotFound(); return rawUser.update(args); }, delete: async (args: any) => { const existing = users.get(args.where.id); if (!existing || existing.tenantId !== tenantId) throwNotFound(); return rawUser.delete(args); }, }; } const fake: any = { __seedUser(user: any) { users.set(user.id, user); }, __seedTenant(tenant: any) { tenants.set(tenant.id, tenant); }, user: rawUser, tenant: { findMany: async () => Array.from(tenants.values()), }, __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string) { const scoped = makeScopedUser(tenantId); const wrap = (method: string, fn: (args: any) => any) => { return async (args: any) => { boundCallLog.push({ tenantId, model: 'user', method }); return fn(args); }; }; return { __isBoundClient: true, __tenantId: tenantId, user: { findUnique: wrap('findUnique', scoped.findUnique), findMany: wrap('findMany', scoped.findMany), create: wrap('create', scoped.create), update: wrap('update', scoped.update), delete: wrap('delete', scoped.delete), }, }; }, }; return fake; } 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 expectNotBoundCall(prisma: any, model: string, method: string) { const found = prisma.__boundCallLog.some((c: any) => c.model === model && c.method === method); expect( found, `unerwarteter gebundener Aufruf ${model}.${method} im Protokoll — diese Methode soll UNGEBUNDEN bleiben: ${JSON.stringify(prisma.__boundCallLog)}`, ).toBe(false); } describe('UserService', () => { describe('create — Standardgruppen-Mitgliedschaft (D-11/D-12) und Bindung', () => { let prisma: any; let groupsService: any; let service: UserService; const baseData = { username: 'Alice', email: 'alice@example.com', tenantId: 't1' }; beforeEach(() => { prisma = makeFakePrisma(); groupsService = { addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined) }; service = new UserService(prisma, groupsService); }); it('Test 1: steht mit der uebergebenen Mandantenkennung im Bindungsprotokoll, und ruft nach der Anlage genau einmal addUserToDefaultGroup mit derselben tenantId und der frisch erzeugten userId auf', async () => { const result = await service.create(baseData); expectBoundCall(prisma, 't1', 'user', 'create'); expect(result.tenantId).toBe('t1'); expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1); expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledWith('t1', result.id); }); it('legt den Benutzer bei einem Mandanten ohne markierte Standardgruppe trotzdem an — addUserToDefaultGroup bleibt folgenlos, die Anlage schlägt nicht fehl', async () => { const result = await service.create(baseData); expect(result.username).toBe('alice'); expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1); }); it('gibt den Benutzer trotz werfendem addUserToDefaultGroup zurück — der Fehler wird protokolliert, nicht propagiert', async () => { groupsService.addUserToDefaultGroup.mockRejectedValue(new Error('boom')); await expect(service.create(baseData)).resolves.toHaveProperty('username', 'alice'); }); it('gibt weiterhin den erzeugten Benutzerdatensatz zurück; die Signatur bleibt unverändert', async () => { const result = await service.create(baseData); expect(result).toHaveProperty('id'); expect(result).toMatchObject({ username: 'alice', email: 'alice@example.com', tenantId: 't1', }); }); it('Test 2: wirft bei einem Benutzernamen, den ein Benutzer eines ANDEREN Mandanten bereits hält, eine verständliche deutsche Konfliktmeldung statt eines durchgereichten Datenbankfehlers — und nennt weder den Halter noch dessen Mandanten', async () => { prisma.__seedUser({ id: 'existing', username: 'bob', tenantId: 't2' }); let caught: any; try { await service.create({ username: 'Bob', email: 'bob2@example.com', tenantId: 't1' }); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(ConflictException); expect(caught.message).not.toContain('t2'); expect(caught.message).not.toContain('existing'); expect(caught.message.toLowerCase()).toMatch(/benutzername|adresse/); }); }); /** * quick-260925-bow (D-04): neue Benutzer bekommen die laufende freigegebene * Version eingetragen, damit sie kein "Was ist neu"-Fenster mit Altlasten * sehen. Der Wert ist eine Eigenschaft des Servers (APP_VERSION), nicht des * Aufrufers — die Signatur von create() bekommt kein neues Feld. */ describe('create — lastSeenReleaseVersion (quick-260925-bow)', () => { let prisma: any; let service: UserService; beforeEach(() => { prisma = makeFakePrisma(); service = new UserService(prisma, { addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined) } as any); }); afterEach(() => { vi.unstubAllEnvs(); }); it('traegt bei freigegebener laufender Version X.Y.Z ein (Describe-Stand gekuerzt)', async () => { vi.stubEnv('APP_VERSION', 'v10.2.3-3-gabc1234'); const result = await service.create({ username: 'neu', email: 'neu@example.com', tenantId: 't1' }); expect(result.lastSeenReleaseVersion).toBe('10.2.3'); expectBoundCall(prisma, 't1', 'user', 'create'); }); it('traegt auf dev-Staenden null ein', async () => { vi.stubEnv('APP_VERSION', 'dev'); const result = await service.create({ username: 'neu2', email: 'neu2@example.com', tenantId: 't1' }); expect(result.lastSeenReleaseVersion).toBeNull(); }); it('traegt ohne APP_VERSION null ein', async () => { vi.stubEnv('APP_VERSION', undefined); const result = await service.create({ username: 'neu3', email: 'neu3@example.com', tenantId: 't1' }); expect(result.lastSeenReleaseVersion).toBeNull(); }); it('ein von aussen untergeschobenes Feld setzt den Wert nicht (kein Parameter der Signatur)', async () => { vi.stubEnv('APP_VERSION', 'v10.2.3'); const result = await service.create({ username: 'neu4', email: 'neu4@example.com', tenantId: 't1', lastSeenReleaseVersion: '99.0.0', } as unknown as Parameters[0]); expect(result.lastSeenReleaseVersion).toBe('10.2.3'); }); }); describe('findById', () => { let prisma: any; let service: UserService; beforeEach(() => { prisma = makeFakePrisma(); prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' }); prisma.__seedUser({ id: 'u-b', username: 'bob', tenantId: 't2' }); service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any); }); it('Test 4: steht gebunden im Protokoll und liefert einen Benutzer eines anderen Mandanten NICHT', async () => { const own = await service.findById('t1', 'u-a'); expect(own).toMatchObject({ id: 'u-a' }); expectBoundCall(prisma, 't1', 'user', 'findUnique'); const foreign = await service.findById('t1', 'u-b'); expect(foreign).toBeNull(); }); }); describe('update', () => { let prisma: any; let service: UserService; beforeEach(() => { prisma = makeFakePrisma(); prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' }); prisma.__seedUser({ id: 'u-b', username: 'bob', tenantId: 't2' }); service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any); }); it('Test 3: steht gebunden im Protokoll, und ein Namenswechsel auf einen fremd gehaltenen Benutzernamen wirft dieselbe Art von Konfliktmeldung', async () => { await expect(service.update('t1', 'u-a', { username: 'Bob' })).rejects.toBeInstanceOf( ConflictException, ); expectBoundCall(prisma, 't1', 'user', 'update'); }); }); describe('deactivate/delete', () => { let prisma: any; let service: UserService; beforeEach(() => { prisma = makeFakePrisma(); prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' }); service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any); }); it('Test 5: deactivate steht gebunden im Protokoll', async () => { await service.deactivate('t1', 'u-a'); expectBoundCall(prisma, 't1', 'user', 'update'); }); it('Test 5: delete steht gebunden im Protokoll', async () => { await service.delete('t1', 'u-a'); expectBoundCall(prisma, 't1', 'user', 'delete'); }); }); describe('Plattform-Administratorsicht (Befund F)', () => { let prisma: any; let service: UserService; beforeEach(() => { prisma = makeFakePrisma(); prisma.__seedTenant({ id: 't1' }); prisma.__seedTenant({ id: 't2' }); prisma.__seedUser({ id: 'u-carol', username: 'carol', tenantId: 't1' }); prisma.__seedUser({ id: 'u-alice', username: 'alice', tenantId: 't1' }); prisma.__seedUser({ id: 'u-bob', username: 'bob', tenantId: 't2' }); service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any); }); it('Test 6: liest die Mandanten UNGEBUNDEN und danach je Mandant GEBUNDEN — im Protokoll steht je Mandant genau ein Eintrag, das Ergebnis enthält die Benutzer beider Mandanten in der bisherigen Sortierung nach Benutzername', async () => { const result = await service.findAllForPlatformAdmin(); expect(result.map((u: any) => u.username)).toEqual(['alice', 'bob', 'carol']); expectBoundCall(prisma, 't1', 'user', 'findMany'); expectBoundCall(prisma, 't2', 'user', 'findMany'); const entriesFor = (tenantId: string) => prisma.__boundCallLog.filter( (c: any) => c.tenantId === tenantId && c.model === 'user' && c.method === 'findMany', ).length; expect(entriesFor('t1')).toBe(1); expect(entriesFor('t2')).toBe(1); }); it('Test 7: findet einen Benutzer eines fremden Mandanten über einen GEBUNDENEN Lesezugriff je Mandant — im Protokoll nachweisbar, nicht nur am Ergebnis', async () => { const found = await service.findByIdForPlatformAdmin('u-bob'); expect(found).toMatchObject({ id: 'u-bob', tenantId: 't2' }); expectBoundCall(prisma, 't2', 'user', 'findUnique'); }); it('liefert null, wenn keine Mandant die Kennung besitzt', async () => { const found = await service.findByIdForPlatformAdmin('unbekannt'); expect(found).toBeNull(); }); }); describe('findByUsername (bewusst UNGEBUNDEN)', () => { it('Test 8: steht NICHT im Bindungsprotokoll — das Fehlen der Bindung ist hier die bestandene Erwartung, damit niemand sie später als vergessene Bindung "repariert"', async () => { const prisma = makeFakePrisma(); prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' }); const service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any); const found = await service.findByUsername('Alice'); expect(found).toMatchObject({ id: 'u-a' }); expectNotBoundCall(prisma, 'user', 'findUnique'); }); }); });