import { describe, expect, it, vi } from 'vitest'; import { TenderEmailConfigService } from './tender-email-config.service'; /** * TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07). * Hand-rolled fake PrismaService (Map) + a fake CryptoService * (deterministic reversible encode, NOT real AES) — same convention as * tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving * this service's own encrypt-preserve-empty / safe-select contract. * * Phase 17, Plan 01 (D-01): ownership moved from tenantId to userId — the * fake prisma below is now keyed by userId (matches the real * `where: { userId }` upsert target), and two new cases prove the actual * new capability: two users of the SAME tenant get two independent rows, * and tenantId is written on create (denormalized, D-01). * * Bindung an forTenant() (260909-laa, Befund C/H): `__makeBoundClient()` * wraps the SAME in-memory Map with a per-call logging layer — a pure * identity mock would leave a forgotten `forTenant()` call invisible to * every test. Muster aus `groups.service.spec.ts` (260909-jts). */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), })); function makeFakeCrypto() { return { encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`), decrypt: vi.fn((stored: string) => { if (!stored.startsWith('enc:')) throw new Error('Invalid encrypted value format'); return Buffer.from(stored.slice(4), 'base64').toString('utf8'); }), }; } function makeFakePrisma() { const configs = new Map(); const boundCallLog: { tenantId: string; model: string; method: string }[] = []; const tenderEmailConfig = { findUnique: vi.fn(async ({ where, select }: any) => { const row = configs.get(where.userId); if (!row) return null; if (!select) return row; const out: any = {}; for (const k of Object.keys(select)) out[k] = row[k]; return out; }), upsert: vi.fn(async ({ where, update, create, select }: any) => { const existing = configs.get(where.userId); const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create }; configs.set(where.userId, row); if (!select) return row; const out: any = {}; for (const k of Object.keys(select)) out[k] = row[k]; return out; }), }; const fake: any = { tenderEmailConfig, __store: configs, __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string) { const wrapped: any = {}; for (const method of Object.keys(tenderEmailConfig)) { wrapped[method] = async (...args: any[]) => { boundCallLog.push({ tenantId, model: 'tenderEmailConfig', method }); return (tenderEmailConfig as any)[method](...args); }; } return { tenderEmailConfig: wrapped }; }, }; return fake; } function expectBoundCall(prisma: any, tenantId: string, method: string) { const found = prisma.__boundCallLog.some( (c: any) => c.tenantId === tenantId && c.model === 'tenderEmailConfig' && c.method === method, ); expect( found, `erwarteter gebundener Aufruf tenderEmailConfig.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, ).toBe(true); } describe('TenderEmailConfigService', () => { it('getConfigForApi returns null when no config exists for the user', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); const result = await service.getConfigForApi('user-missing', 'tenant-x'); expect(result).toBeNull(); }); it('saveConfig encrypts {username,password} and getConfigForApi round-trips username, NEVER returns the password field (T-07-12)', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); await service.saveConfig( { userId: 'user-a', tenantId: 'tenant-a' }, { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test', port: 993, folder: 'INBOX', username: 'alerts@example.test', password: 'super-secret', isActive: true, } as any, ); const apiResult = await service.getConfigForApi('user-a', 'tenant-a'); expect(apiResult).not.toBeNull(); expect(apiResult).not.toHaveProperty('password'); expect(apiResult).not.toHaveProperty('encryptedInboxCreds'); expect(apiResult!.username).toBe('alerts@example.test'); expect(apiResult!.hasPassword).toBe(true); }); it('saveConfig with no username/password leaves hasPassword false and username null (fresh config)', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); await service.saveConfig( { userId: 'user-b', tenantId: 'tenant-b' }, { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test', port: 993, folder: 'INBOX', isActive: false, } as any, ); const apiResult = await service.getConfigForApi('user-b', 'tenant-b'); expect(apiResult!.hasPassword).toBe(false); expect(apiResult!.username).toBeNull(); expect(crypto.encrypt).not.toHaveBeenCalled(); }); it('saveConfig preserves the existing password when only username changes on a re-save', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); await service.saveConfig( { userId: 'user-c', tenantId: 'tenant-c' }, { protocol: 'imap', encryption: 'ssl-tls', username: 'old@example.test', password: 'original-secret', } as any, ); // Re-save with a new username, password left blank (T-07-12 UI convention) await service.saveConfig( { userId: 'user-c', tenantId: 'tenant-c' }, { protocol: 'imap', encryption: 'ssl-tls', username: 'new@example.test', } as any, ); const raw = prisma.__store.get('user-c'); const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds)); expect(decrypted.username).toBe('new@example.test'); expect(decrypted.password).toBe('original-secret'); }); it('saveConfig preserves the existing username when only password changes on a re-save', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); await service.saveConfig( { userId: 'user-d', tenantId: 'tenant-d' }, { protocol: 'imap', encryption: 'ssl-tls', username: 'stable@example.test', password: 'first-secret', } as any, ); await service.saveConfig( { userId: 'user-d', tenantId: 'tenant-d' }, { protocol: 'imap', encryption: 'ssl-tls', password: 'rotated-secret', } as any, ); const raw = prisma.__store.get('user-d'); const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds)); expect(decrypted.username).toBe('stable@example.test'); expect(decrypted.password).toBe('rotated-secret'); }); it('the safe select never includes encryptedInboxCreds in the upsert return value (T-07-12)', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); const result = await service.saveConfig( { userId: 'user-e', tenantId: 'tenant-e' }, { protocol: 'imap', encryption: 'ssl-tls', username: 'x@example.test', password: 'y', } as any, ); expect(result).not.toHaveProperty('encryptedInboxCreds'); expect(result).not.toHaveProperty('password'); }); it('saveConfig writes BOTH userId and tenantId on create (Phase 17, D-01: tenantId stays denormalized)', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); await service.saveConfig( { userId: 'user-f', tenantId: 'tenant-f' }, { protocol: 'imap', encryption: 'ssl-tls' } as any, ); const raw = prisma.__store.get('user-f'); expect(raw.userId).toBe('user-f'); expect(raw.tenantId).toBe('tenant-f'); }); it('two users of the SAME tenant each get their own row — the second save never overwrites the first (Phase 17, D-01)', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); await service.saveConfig( { userId: 'user-g1', tenantId: 'tenant-shared' }, { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g1.test' } as any, ); await service.saveConfig( { userId: 'user-g2', tenantId: 'tenant-shared' }, { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g2.test' } as any, ); const configG1 = await service.getConfigForApi('user-g1', 'tenant-shared'); const configG2 = await service.getConfigForApi('user-g2', 'tenant-shared'); expect(configG1!.host).toBe('imap.user-g1.test'); expect(configG2!.host).toBe('imap.user-g2.test'); expect(prisma.__store.size).toBe(2); }); describe('testConnection (Quick 260907-let, WINDOWS #16)', () => { function makeFakeProviders() { return { imapProvider: { testConnection: vi.fn(async () => ({ success: true })) }, exchangeProvider: { testConnection: vi.fn(async () => ({ success: true })) }, }; } it('a typed-in password is passed through to the provider unchanged, without reading the database', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const { imapProvider, exchangeProvider } = makeFakeProviders(); const service = new TenderEmailConfigService( prisma as any, crypto as any, imapProvider as any, exchangeProvider as any, ); const result = await service.testConnection('user-h', 'tenant-h', { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test', username: 'typed-user', password: 'typed-secret', } as any); expect(prisma.tenderEmailConfig.findUnique).not.toHaveBeenCalled(); expect(imapProvider.testConnection).toHaveBeenCalledWith( expect.objectContaining({ username: 'typed-user', password: 'typed-secret' }), ); expect(result).toEqual({ success: true }); }); it('an empty password falls back to this same user\'s stored, decrypted credentials', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const { imapProvider, exchangeProvider } = makeFakeProviders(); const service = new TenderEmailConfigService( prisma as any, crypto as any, imapProvider as any, exchangeProvider as any, ); await service.saveConfig( { userId: 'user-i', tenantId: 'tenant-i' }, { protocol: 'imap', encryption: 'ssl-tls', username: 'stored-user', password: 'stored-secret', } as any, ); await service.testConnection('user-i', 'tenant-i', { protocol: 'imap', encryption: 'ssl-tls', } as any); expect(imapProvider.testConnection).toHaveBeenCalledWith( expect.objectContaining({ username: 'stored-user', password: 'stored-secret' }), ); }); it("dto.protocol 'exchange' selects the Exchange provider, not IMAP", async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const { imapProvider, exchangeProvider } = makeFakeProviders(); const service = new TenderEmailConfigService( prisma as any, crypto as any, imapProvider as any, exchangeProvider as any, ); await service.testConnection('user-j', 'tenant-j', { protocol: 'exchange', encryption: 'ssl-tls', username: 'ews-user', password: 'ews-secret', } as any); expect(exchangeProvider.testConnection).toHaveBeenCalledTimes(1); expect(imapProvider.testConnection).not.toHaveBeenCalled(); }); }); // --- Bindung an forTenant() (260909-laa, Aufgabe 2) ----------------------- describe('Bindung an forTenant() (260909-laa)', () => { it('getConfigForApi() bindet beide tenderEmailConfig.findUnique-Zugriffe an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); await service.saveConfig( { userId: 'user-k', tenantId: 't1' }, { protocol: 'imap', encryption: 'ssl-tls', username: 'k', password: 'p' } as any, ); prisma.__boundCallLog.length = 0; await service.getConfigForApi('user-k', 't1'); const findUniqueCalls = prisma.__boundCallLog.filter( (c: any) => c.tenantId === 't1' && c.model === 'tenderEmailConfig' && c.method === 'findUnique', ); expect(findUniqueCalls.length).toBe(2); }); it('saveConfig() bindet den credChanged-Lesezugriff UND das upsert an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new TenderEmailConfigService(prisma as any, crypto as any); await service.saveConfig( { userId: 'user-l', tenantId: 't1' }, { protocol: 'imap', encryption: 'ssl-tls', username: 'only-username' } as any, ); expectBoundCall(prisma, 't1', 'findUnique'); expectBoundCall(prisma, 't1', 'upsert'); }); it('testConnection() bindet den Zugangsdaten-Rueckgriff an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const { imapProvider, exchangeProvider } = makeFakeProviders(); const service = new TenderEmailConfigService( prisma as any, crypto as any, imapProvider as any, exchangeProvider as any, ); await service.saveConfig( { userId: 'user-m', tenantId: 't1' }, { protocol: 'imap', encryption: 'ssl-tls', username: 'm', password: 'p' } as any, ); prisma.__boundCallLog.length = 0; await service.testConnection('user-m', 't1', { protocol: 'imap', encryption: 'ssl-tls', } as any); expectBoundCall(prisma, 't1', 'findUnique'); }); function makeFakeProviders() { return { imapProvider: { testConnection: vi.fn(async () => ({ success: true })) }, exchangeProvider: { testConnection: vi.fn(async () => ({ success: true })) }, }; } }); });