import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as nodemailer from 'nodemailer'; import { SettingsService } from './settings.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; /** * SettingsService.spec — NEU (260911-gwh). Der Bereich `settings` hatte VOR * diesem Lauf KEINE Testdatei (Befund J). Zwei-Klienten-Nachbau, aber mit * einer GRENZE als Bauform (anders als `favorites`): der UNGEBUNDENE Nachbau * bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` (der Startpfad) — * KEIN `findUnique`, KEIN `upsert`; der GEBUNDENE Klient bietet * AUSSCHLIESSLICH `findUnique`/`upsert` — KEIN `findFirst`. Ein gebundener * Startpfad scheitert damit ebenso hart wie ein ungebundener Anfrageweg * ("X is not a function" statt eines stillen Fallbacks). * * `nodemailer` wird per `vi.mock` ersetzt — kein echter Transport (lokal * gibt es keinen `mailhog`). */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)), })); let mockVerify = vi.fn(async () => true); let mockSendMail = vi.fn(async () => ({})); vi.mock('nodemailer', () => ({ createTransport: vi.fn(() => ({ verify: (...args: unknown[]) => (mockVerify as any)(...args), sendMail: (...args: unknown[]) => (mockSendMail as any)(...args), })), })); interface FakeSmtpRow { id: string; tenantId: string; host: string; port: number; encryption: string; username: string | null; encryptedPassword: string | null; fromAddress: string; createdAt?: Date; updatedAt?: Date; } interface BoundCall { tenantId: string; model: 'smtpConfig'; method: string; } function applySelect(row: Record, select?: Record) { if (!select) return { ...row }; const out: Record = {}; for (const key of Object.keys(select)) { if (select[key]) out[key] = row[key]; } return out; } function makeFakePrisma(rows: FakeSmtpRow[] = []) { const configs = new Map(rows.map((r) => [r.tenantId, { ...r }])); const boundCallLog: BoundCall[] = []; let autoId = rows.length; const unboundSmtpConfig = { findFirst: vi.fn(async () => { const first = configs.values().next().value; return first ? { ...first } : null; }), }; function makeScopedSmtpConfig(tenantId: string) { return { findUnique: async ({ where, select }: any) => { boundCallLog.push({ tenantId, model: 'smtpConfig', method: 'findUnique' }); const row = configs.get(where.tenantId); if (!row || row.tenantId !== tenantId) return null; return applySelect(row, select); }, upsert: async ({ where, create, update, select }: any) => { boundCallLog.push({ tenantId, model: 'smtpConfig', method: 'upsert' }); if (where.tenantId !== tenantId) return null; const existing = configs.get(tenantId); const record = existing ? { ...existing, ...update } : { id: `smtp-${++autoId}`, createdAt: new Date(), ...create }; record.updatedAt = new Date(); configs.set(tenantId, record); return applySelect(record, select); }, }; } const fake: any = { smtpConfig: unboundSmtpConfig, __configs: configs, __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string) { return { smtpConfig: makeScopedSmtpConfig(tenantId) }; }, }; return fake; } function expectBoundCall(prisma: any, tenantId: string, method: string) { const found = prisma.__boundCallLog.some( (c: BoundCall) => c.tenantId === tenantId && c.model === 'smtpConfig' && c.method === method, ); expect( found, `erwarteter gebundener Aufruf smtpConfig.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, ).toBe(true); } function makeFakeCrypto() { return { encrypt: vi.fn((s: string) => `enc(${s})`), decrypt: vi.fn((s: string) => s.slice(4, -1)), }; } beforeEach(() => { vi.clearAllMocks(); mockVerify = vi.fn(async () => true); mockSendMail = vi.fn(async () => ({})); }); describe('SettingsService — Bindung an forTenant() (260911-gwh)', () => { it('scheitert an "X is not a function", wenn getSmtpConfig versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => { const prisma = makeFakePrisma(); expect((prisma.smtpConfig as any).findUnique).toBeUndefined(); expect((prisma.smtpConfig as any).upsert).toBeUndefined(); }); describe('getSmtpConfig', () => { it('liefert die Zeile mit encryptedPassword (fuer hasPassword), ohne Felder ausserhalb des select', async () => { const prisma = makeFakePrisma([ { id: 'smtp-a', tenantId: 't1', host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: 'user-a', encryptedPassword: 'enc(geheim)', fromAddress: 'a@example.invalid', }, ]); const crypto = makeFakeCrypto(); const service = new SettingsService(prisma as any, crypto as any); const result = await service.getSmtpConfig('t1'); expect(result?.encryptedPassword).toBe('enc(geheim)'); expect(result?.host).toBe('smtp-a.example.invalid'); expectBoundCall(prisma, 't1', 'findUnique'); }); it('fremder Mandant: null', async () => { const prisma = makeFakePrisma([ { id: 'smtp-a', tenantId: 't1', host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: null, encryptedPassword: null, fromAddress: 'a@example.invalid', }, ]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); const result = await service.getSmtpConfig('t2'); expect(result).toBeNull(); }); }); describe('saveSmtpConfig', () => { it('mit Kennwort: encrypt wird GENAU mit dem Klartext aufgerufen, upsert traegt encryptedPassword, Rueckgabe OHNE encryptedPassword', async () => { const prisma = makeFakePrisma(); const crypto = makeFakeCrypto(); const service = new SettingsService(prisma as any, crypto as any); const result = await service.saveSmtpConfig('t1', { host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: 'user-a', password: 'klartext-geheim', fromAddress: 'a@example.invalid', } as any); expect(crypto.encrypt).toHaveBeenCalledWith('klartext-geheim'); expect((result as any).encryptedPassword).toBeUndefined(); const stored = prisma.__configs.get('t1'); expect(stored.encryptedPassword).toBe('enc(klartext-geheim)'); expectBoundCall(prisma, 't1', 'upsert'); const logged = JSON.stringify(prisma.__boundCallLog); expect(logged).not.toContain('klartext-geheim'); }); it('ohne Kennwort (leer oder fehlend): encrypt NICHT aufgerufen, update traegt KEINEN Schluessel encryptedPassword, username fehlend -> null', async () => { const prisma = makeFakePrisma([ { id: 'smtp-a', tenantId: 't1', host: 'alt.example.invalid', port: 587, encryption: 'starttls', username: 'alt-user', encryptedPassword: 'enc(alt-geheim)', fromAddress: 'a@example.invalid', }, ]); const crypto = makeFakeCrypto(); const service = new SettingsService(prisma as any, crypto as any); await service.saveSmtpConfig('t1', { host: 'neu.example.invalid', port: 587, encryption: 'starttls', password: '', fromAddress: 'a@example.invalid', } as any); expect(crypto.encrypt).not.toHaveBeenCalled(); const stored = prisma.__configs.get('t1'); expect(stored.encryptedPassword).toBe('enc(alt-geheim)'); // bestehendes bleibt expect(stored.username).toBeNull(); }); it('unter t2, wenn nur t1 eine Zeile hat: legt fuer t2 an, t1 bleibt unveraendert (Semantik nach der Bindung)', async () => { const prisma = makeFakePrisma([ { id: 'smtp-a', tenantId: 't1', host: 'a.example.invalid', port: 587, encryption: 'starttls', username: null, encryptedPassword: null, fromAddress: 'a@example.invalid', }, ]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); await service.saveSmtpConfig('t2', { host: 'b.example.invalid', port: 587, encryption: 'starttls', fromAddress: 'b@example.invalid', } as any); expect(prisma.__configs.get('t1').host).toBe('a.example.invalid'); expect(prisma.__configs.get('t2').host).toBe('b.example.invalid'); }); }); describe('getDecryptedSmtpConfig', () => { it('entschluesselt encryptedPassword, liefert die Form die tender-mail.service.ts erwartet', async () => { const prisma = makeFakePrisma([ { id: 'smtp-a', tenantId: 't1', host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: 'user-a', encryptedPassword: 'enc(geheim)', fromAddress: 'a@example.invalid', }, ]); const crypto = makeFakeCrypto(); const service = new SettingsService(prisma as any, crypto as any); const result = await service.getDecryptedSmtpConfig('t1'); expect(crypto.decrypt).toHaveBeenCalledWith('enc(geheim)'); expect(result).toEqual({ host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: 'user-a', fromAddress: 'a@example.invalid', decryptedPassword: 'geheim', }); }); it('ohne encryptedPassword: decryptedPassword null, decrypt NICHT aufgerufen', async () => { const prisma = makeFakePrisma([ { id: 'smtp-a', tenantId: 't1', host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: null, encryptedPassword: null, fromAddress: 'a@example.invalid', }, ]); const crypto = makeFakeCrypto(); const service = new SettingsService(prisma as any, crypto as any); const result = await service.getDecryptedSmtpConfig('t1'); expect(result?.decryptedPassword).toBeNull(); expect(crypto.decrypt).not.toHaveBeenCalled(); }); it('T-GWH-01: fremder Mandant -> null, decrypt NICHT aufgerufen', async () => { const prisma = makeFakePrisma([ { id: 'smtp-a', tenantId: 't1', host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: 'user-a', encryptedPassword: 'enc(geheim)', fromAddress: 'a@example.invalid', }, ]); const crypto = makeFakeCrypto(); const service = new SettingsService(prisma as any, crypto as any); const result = await service.getDecryptedSmtpConfig('t2'); expect(result).toBeNull(); expect(crypto.decrypt).not.toHaveBeenCalled(); }); }); describe('testSmtpConfig', () => { const storedRow: FakeSmtpRow = { id: 'smtp-a', tenantId: 't1', host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: 'user-a', encryptedPassword: 'enc(geheim)', fromAddress: 'a@example.invalid', }; it('ohne Kennwort/Benutzername im DTO: greift auf die gespeicherten Werte zurueck, ruft verify auf, { success: true }', async () => { const prisma = makeFakePrisma([storedRow]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); const result = await service.testSmtpConfig('t1', { host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', fromAddress: 'a@example.invalid', } as any); expect(result).toEqual({ success: true }); expect(mockVerify).toHaveBeenCalled(); expect(mockSendMail).not.toHaveBeenCalled(); expect(nodemailer.createTransport).toHaveBeenCalledWith( expect.objectContaining({ auth: { user: 'user-a', pass: 'geheim' } }), ); }); it('mit testTo: sendMail statt verify, from/to aus dto', async () => { const prisma = makeFakePrisma([storedRow]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); await service.testSmtpConfig('t1', { host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', fromAddress: 'a@example.invalid', testTo: 'ziel@example.invalid', } as any); expect(mockSendMail).toHaveBeenCalledWith( expect.objectContaining({ from: 'a@example.invalid', to: 'ziel@example.invalid' }), ); expect(mockVerify).not.toHaveBeenCalled(); }); it('verify wirft -> { success: false }, kein Wurf nach aussen', async () => { mockVerify = vi.fn(async () => { throw new Error('ECONNREFUSED'); }); const prisma = makeFakePrisma([storedRow]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); const result = await service.testSmtpConfig('t1', { host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', fromAddress: 'a@example.invalid', } as any); expect(result.success).toBe(false); }); it('fremder Mandant ohne Kennwort im DTO: auth ohne Benutzer, kein Fehler', async () => { const prisma = makeFakePrisma([storedRow]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); await service.testSmtpConfig('t2', { host: 'x.example.invalid', port: 587, encryption: 'starttls', fromAddress: 'x@example.invalid', } as any); expect(nodemailer.createTransport).toHaveBeenCalledWith( expect.objectContaining({ auth: undefined }), ); }); }); describe('loadAnySmtpConfigForStartupTransport (Startpfad, bewusst ungebunden)', () => { it('laeuft ueber den UNGEBUNDENEN Nachbau (findFirst), liefert secure/requireTLS/entschluesseltes Kennwort', async () => { const prisma = makeFakePrisma([ { id: 'smtp-a', tenantId: 't1', host: 'smtp-a.example.invalid', port: 465, encryption: 'ssl-tls', username: 'user-a', encryptedPassword: 'enc(geheim)', fromAddress: 'a@example.invalid', }, ]); const crypto = makeFakeCrypto(); const service = new SettingsService(prisma as any, crypto as any); const result = await service.loadAnySmtpConfigForStartupTransport(); expect(result).toEqual({ host: 'smtp-a.example.invalid', port: 465, secure: true, requireTLS: false, username: 'user-a', password: 'geheim', fromAddress: 'a@example.invalid', }); }); it('requireTLS bei starttls', async () => { const prisma = makeFakePrisma([ { id: 'smtp-a', tenantId: 't1', host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: null, encryptedPassword: null, fromAddress: 'a@example.invalid', }, ]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); const result = await service.loadAnySmtpConfigForStartupTransport(); expect(result?.secure).toBe(false); expect(result?.requireTLS).toBe(true); }); it('leerer Nachbau -> null', async () => { const prisma = makeFakePrisma([]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); const result = await service.loadAnySmtpConfigForStartupTransport(); expect(result).toBeNull(); }); it('Null-Klienten-Nachweis: der Startpfad erzeugt KEINEN gebundenen Klienten (gemessen, nicht behauptet)', async () => { const prisma = makeFakePrisma([ { id: 'smtp-a', tenantId: 't1', host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: null, encryptedPassword: null, fromAddress: 'a@example.invalid', }, ]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); vi.mocked(forTenant).mockClear(); await service.loadAnySmtpConfigForStartupTransport(); expect(vi.mocked(forTenant).mock.calls.length).toBe(0); }); }); describe('Wachhund je Anfrageweg', () => { const storedRow: FakeSmtpRow = { id: 'smtp-a', tenantId: 't1', host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: 'user-a', encryptedPassword: 'enc(geheim)', fromAddress: 'a@example.invalid', }; it('genau EIN gebundener Klient je Aufruf von getSmtpConfig/saveSmtpConfig/getDecryptedSmtpConfig', async () => { const prisma = makeFakePrisma([storedRow]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); for (const call of [ () => service.getSmtpConfig('t1'), () => service.saveSmtpConfig('t1', { host: 'x.invalid', port: 587, encryption: 'starttls', fromAddress: 'x@invalid.de', } as any), () => service.getDecryptedSmtpConfig('t1'), ]) { vi.mocked(forTenant).mockClear(); await call(); expect(vi.mocked(forTenant).mock.calls.length).toBe(1); } }); it('testSmtpConfig erzeugt genau einen gebundenen Klienten, wenn es auf gespeicherte Zugangsdaten zurueckgreift', async () => { const prisma = makeFakePrisma([storedRow]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); vi.mocked(forTenant).mockClear(); await service.testSmtpConfig('t1', { host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', fromAddress: 'a@example.invalid', } as any); expect(vi.mocked(forTenant).mock.calls.length).toBe(1); }); it('testSmtpConfig erzeugt KEINEN gebundenen Klienten, wenn Kennwort und Benutzername im DTO stehen', async () => { const prisma = makeFakePrisma([storedRow]); const service = new SettingsService(prisma as any, makeFakeCrypto() as any); vi.mocked(forTenant).mockClear(); await service.testSmtpConfig('t1', { host: 'smtp-a.example.invalid', port: 587, encryption: 'starttls', username: 'anderer-user', password: 'anderes-kennwort', fromAddress: 'a@example.invalid', } as any); expect(vi.mocked(forTenant).mock.calls.length).toBe(0); }); }); });