import { beforeEach, describe, expect, it, vi } from 'vitest'; import { LdapConfigService } from './ldap-config.service'; // forTenant gibt in den Bestandstests denselben Client zurueck (tenant // scoping ist dort nicht unter Test) — ohne diesen Mock bricht die Datei am // blanken Prisma-Ersatz beim ersten `$extends`-Aufruf (Befund F, // 260909-ipc-PLAN.md). Der eigene Bindungs-Testblock unten biegt die // Implementierung auf ein zweites, unterscheidbares Client-Objekt um. vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((p: unknown) => p), // Systemkontext (260914-eym): liefert den in `__systemClient` hinterlegten // Klienten, sonst denselben Client (Bestandstests). forSystem: vi.fn((p: any) => p.__systemClient ?? p), })); import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; /** * Das Bind-Passwort ist das einzige Zugangsdatum, das nicht gehasht werden * kann — Tessera muss sich damit am Domain Controller anmelden, braucht es * also im Original. Deshalb Verschluesselung, und deshalb diese Tests: sie * halten fest, dass der Klartext nie in die Datenbank geschrieben wird, dass * Aufrufer trotzdem weiterhin ein entschluesseltes `bindPassword` sehen, und * dass ein Wert aus der Zeit vor der Verschluesselung den Sync nicht bricht. */ // Ein durchschaubarer Ersatz fuer AES-256-GCM, der die gespeicherte Form // iv:authTag:ciphertext nachbildet — die Tests pruefen das Zusammenspiel, // nicht die Krypto-Bibliothek. const crypto = { encrypt: vi.fn((plaintext: string) => ['aa11', 'bb22', Buffer.from(plaintext, 'utf8').toString('hex')].join(':'), ), decrypt: vi.fn((stored: string) => { const [, , ciphertext] = stored.split(':'); return Buffer.from(ciphertext, 'hex').toString('utf8'); }), }; const CONFIG_ROW = { id: 'cfg1', tenantId: 't1', serverUrl: 'ldap://example', baseDn: 'dc=example,dc=com', bindDn: 'svc@example', encryptedBindPassword: null as string | null, searchFilter: '(objectClass=person)', syncIntervalMin: 0, isActive: true, tlsRejectUnauthorized: true, groupFilterDns: [] as string[], userExcludeList: [] as string[], fieldMappings: [], }; describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => { let prisma: any; let service: LdapConfigService; beforeEach(() => { vi.clearAllMocks(); prisma = { ldapConfig: { findUnique: vi.fn(), findMany: vi.fn().mockResolvedValue([]), create: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data }), ), update: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data }), ), }, }; service = new LdapConfigService(prisma, crypto as any); }); it('schreibt beim Anlegen den Chiffretext, nie den Klartext', async () => { await service.createConfig('t1', { serverUrl: 'ldap://example', baseDn: 'dc=example,dc=com', bindDn: 'svc@example', bindPassword: 'geheim', } as any); const written = prisma.ldapConfig.create.mock.calls[0][0].data; expect(written.encryptedBindPassword).toBe( `aa11:bb22:${Buffer.from('geheim').toString('hex')}`, ); expect(JSON.stringify(written)).not.toContain('geheim'); // Die alte Klartext-Spalte darf nicht wieder auftauchen. expect(written).not.toHaveProperty('bindPassword'); }); it('verschluesselt auch beim Aktualisieren und laesst das Feld sonst unberuehrt', async () => { await service.updateConfig('t1', { bindPassword: 'neu' } as any); const first = prisma.ldapConfig.update.mock.calls[0][0].data; expect(first.encryptedBindPassword).toBe( `aa11:bb22:${Buffer.from('neu').toString('hex')}`, ); await service.updateConfig('t1', { serverUrl: 'ldap://anders' } as any); const second = prisma.ldapConfig.update.mock.calls[1][0].data; expect(second).not.toHaveProperty('encryptedBindPassword'); }); it('leeres Passwort loescht den Wert, statt Leerstring zu verschluesseln', async () => { await service.updateConfig('t1', { bindPassword: '' } as any); const written = prisma.ldapConfig.update.mock.calls[0][0].data; expect(written.encryptedBindPassword).toBeNull(); expect(crypto.encrypt).not.toHaveBeenCalled(); }); it('gibt Aufrufern weiterhin ein entschluesseltes bindPassword', async () => { prisma.ldapConfig.findUnique.mockResolvedValue({ ...CONFIG_ROW, encryptedBindPassword: `aa11:bb22:${Buffer.from('geheim').toString('hex')}`, }); const config: any = await service.getConfig('t1'); expect(config.bindPassword).toBe('geheim'); expect(config).not.toHaveProperty('encryptedBindPassword'); }); it('reicht einen Altbestand-Klartext unveraendert durch, statt den Sync zu brechen', async () => { // Zeitfenster zwischen Spalten-Umbenennung und Bootstrap-Backfill. prisma.ldapConfig.findUnique.mockResolvedValue({ ...CONFIG_ROW, encryptedBindPassword: 'nochKlartext', }); const config: any = await service.getConfig('t1'); expect(config.bindPassword).toBe('nochKlartext'); expect(crypto.decrypt).not.toHaveBeenCalled(); }); it('meldet einen fehlgeschlagenen Entschluesselungsversuch, statt still kein Passwort zu liefern', async () => { // Ein falscher Schluessel darf nicht wie "kein Passwort konfiguriert" // aussehen — das wuerde einen authentifizierten Bind unbemerkt in einen // anonymen verwandeln. crypto.decrypt.mockImplementationOnce(() => { throw new Error('Unsupported state or unable to authenticate data'); }); prisma.ldapConfig.findUnique.mockResolvedValue({ ...CONFIG_ROW, encryptedBindPassword: 'aa11:bb22:ffff', }); await expect(service.getConfig('t1')).rejects.toThrow(); }); it('verschluesselt beim Start genau die Zeilen, die noch Klartext tragen', async () => { prisma.ldapConfig.findMany.mockResolvedValue([ { id: 'a', tenantId: 't1', encryptedBindPassword: 'klartext' }, { id: 'b', tenantId: 't2', encryptedBindPassword: `aa11:bb22:${Buffer.from('schon').toString('hex')}`, }, { id: 'c', tenantId: 't3', encryptedBindPassword: null }, ]); await service.onApplicationBootstrap(); expect(prisma.ldapConfig.update).toHaveBeenCalledTimes(1); const call = prisma.ldapConfig.update.mock.calls[0][0]; expect(call.where).toEqual({ id: 'a' }); expect(call.data.encryptedBindPassword).toBe( `aa11:bb22:${Buffer.from('klartext').toString('hex')}`, ); }); it('ist beim zweiten Start ein No-Op', async () => { prisma.ldapConfig.findMany.mockResolvedValue([ { id: 'a', tenantId: 't1', encryptedBindPassword: `aa11:bb22:${Buffer.from('x').toString('hex')}`, }, ]); await service.onApplicationBootstrap(); expect(prisma.ldapConfig.update).not.toHaveBeenCalled(); }); it('laesst einen fehlgeschlagenen Backfill den Start nicht verhindern', async () => { prisma.ldapConfig.findMany.mockRejectedValue(new Error('db weg')); await expect(service.onApplicationBootstrap()).resolves.toBeUndefined(); }); }); /** * Aufgabe 2 (260909-ipc, WINDOWS #20 Etappe 2, T-IPC-01/T-IPC-02): belegt, * dass die drei pro-Mandant-Methoden gebunden laufen, die beiden * uebergreifenden Methoden es NICHT tun, und dass das Loeschen einer * Feldzuordnung ohne Mandant nicht mehr moeglich ist. */ describe('LdapConfigService — Bindung an forTenant() (260909-ipc)', () => { let prisma: any; let service: LdapConfigService; beforeEach(() => { vi.clearAllMocks(); prisma = { ldapConfig: { findUnique: vi.fn().mockResolvedValue(CONFIG_ROW), findMany: vi.fn().mockResolvedValue([]), create: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })), update: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })), }, ldapFieldMapping: { create: vi.fn((args: any) => Promise.resolve({ id: 'map1', isDefault: false, ...args.data })), findUnique: vi.fn(), delete: vi.fn((args: any) => Promise.resolve({ id: args.where.id })), }, }; service = new LdapConfigService(prisma, crypto as any); }); it('getConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => { await service.getConfig('t1'); expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); expect(prisma.ldapConfig.findUnique).toHaveBeenCalledWith( expect.objectContaining({ where: { tenantId: 't1' } }), ); }); it('createConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => { await service.createConfig('t1', { serverUrl: 'ldap://example', baseDn: 'dc=example,dc=com', } as any); expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); expect(prisma.ldapConfig.create).toHaveBeenCalled(); }); it('updateConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => { await service.updateConfig('t1', { serverUrl: 'ldap://anders' } as any); expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); expect(prisma.ldapConfig.update).toHaveBeenCalledWith( expect.objectContaining({ where: { tenantId: 't1' } }), ); }); it('addFieldMapping() nimmt den Mandanten entgegen und schreibt gebunden', async () => { await service.addFieldMapping('t1', 'cfg1', { ldapField: 'department', tesseraField: 'department', } as any); expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); expect(prisma.ldapFieldMapping.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ ldapConfigId: 'cfg1' }), }), ); }); it('removeFieldMapping() nimmt den Mandanten entgegen, liest gebunden und loescht gebunden', async () => { prisma.ldapFieldMapping.findUnique.mockResolvedValue({ id: 'map1', ldapConfigId: 'cfg1', isDefault: false, }); const result = await service.removeFieldMapping('t1', 'map1'); expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); expect(prisma.ldapFieldMapping.findUnique).toHaveBeenCalledWith({ where: { id: 'map1' }, }); expect(prisma.ldapFieldMapping.delete).toHaveBeenCalledWith({ where: { id: 'map1' }, }); expect(result).toEqual({ id: 'map1' }); }); it('removeFieldMapping() liefert null, wenn die Zuordnung unter diesem Mandanten nicht sichtbar ist (T-IPC-01)', async () => { // Simuliert die RLS-Wirkung: unter dem Mandantenkontext von t1 ist eine // fremde Feldzuordnung (Mandant t2) unsichtbar — findUnique liefert null, // genau wie es die echte Policy nach dem Scharfschalten taete. prisma.ldapFieldMapping.findUnique.mockResolvedValue(null); const result = await service.removeFieldMapping('t1', 'map-fremd'); expect(result).toBeNull(); expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled(); }); it('removeFieldMapping() schuetzt Vorgabe-Zuordnungen weiterhin, auch gebunden', async () => { prisma.ldapFieldMapping.findUnique.mockResolvedValue({ id: 'map1', ldapConfigId: 'cfg1', isDefault: true, }); await expect(service.removeFieldMapping('t1', 'map1')).rejects.toThrow( 'Cannot delete default field mappings', ); expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled(); }); it('getAllActiveConfigs() liest ueber den Systemkontext: forSystem genau einmal, forTenant nie (260914-eym)', async () => { const systemClient = { ldapConfig: { findMany: vi.fn().mockResolvedValue([{ ...CONFIG_ROW }]) }, }; prisma.__systemClient = systemClient; const result = await service.getAllActiveConfigs(); expect(forSystem).toHaveBeenCalledTimes(1); expect(forSystem).toHaveBeenCalledWith(prisma); expect(forTenant).not.toHaveBeenCalled(); expect(systemClient.ldapConfig.findMany).toHaveBeenCalledWith({ where: { isActive: true }, include: { tenant: true, fieldMappings: true }, }); expect(prisma.ldapConfig.findMany).not.toHaveBeenCalled(); expect(result).toHaveLength(1); }); it('onApplicationBootstrap() mit leerer Liste: forSystem einmal, forTenant nie, kein Update (Leere ist Nichtstun, 260914-eym)', async () => { const systemClient = { ldapConfig: { findMany: vi.fn().mockResolvedValue([]) } }; prisma.__systemClient = systemClient; await service.onApplicationBootstrap(); expect(forSystem).toHaveBeenCalledTimes(1); expect(forTenant).not.toHaveBeenCalled(); expect(prisma.ldapConfig.update).not.toHaveBeenCalled(); }); it('onApplicationBootstrap() mit einer Altzeile (Klartext, t1): liest system, schreibt GEBUNDEN — forTenant genau einmal mit t1, update traegt das verschluesselte Kennwort (260914-eym)', async () => { const systemClient = { ldapConfig: { findMany: vi.fn().mockResolvedValue([ { id: 'alt', tenantId: 't1', encryptedBindPassword: 'klartext' }, ]), }, }; prisma.__systemClient = systemClient; const boundClient = { ldapConfig: { update: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })) }, }; vi.mocked(forTenant).mockImplementation(() => boundClient as any); await service.onApplicationBootstrap(); expect(forSystem).toHaveBeenCalledTimes(1); expect(forTenant).toHaveBeenCalledTimes(1); expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); expect(boundClient.ldapConfig.update).toHaveBeenCalledTimes(1); const call = boundClient.ldapConfig.update.mock.calls[0][0]; expect(call.where).toEqual({ id: 'alt' }); expect(call.data.encryptedBindPassword).toBe( `aa11:bb22:${Buffer.from('klartext').toString('hex')}`, ); // Der rohe Client schreibt NICHT. expect(prisma.ldapConfig.update).not.toHaveBeenCalled(); // Implementierung zuruecksetzen (vi.clearAllMocks loescht nur Aufrufe). vi.mocked(forTenant).mockImplementation((p: unknown) => p as any); }); });