import { BadRequestException, ConflictException, InternalServerErrorException, } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)), })); import { type AutodnsFetch, AutodnsRateLimiter } from './autodns-client'; import { DomainsSettingsService } from './domains-settings.service'; interface Row { environment: 'DEMO' | 'LIVE'; demoUser: string | null; demoEncryptedPassword: string | null; demoContext: number | null; liveUser: string | null; liveEncryptedPassword: string | null; liveContext: number | null; defaultNameServers: string[]; updatedAt: Date; } function emptyRow(over: Partial = {}): Row { return { environment: 'DEMO', demoUser: null, demoEncryptedPassword: null, demoContext: null, liveUser: null, liveEncryptedPassword: null, liveContext: null, defaultNameServers: [], updatedAt: new Date('2026-10-08T10:00:00Z'), ...over, }; } function makeService(initial: Row | null, fetchImpl?: AutodnsFetch) { const state = { row: initial }; const upsert = vi.fn(async ({ create, update }: any) => { state.row = state.row ? { ...state.row, ...update } : emptyRow({ ...create }); return state.row; }); const db = { __bound: () => ({ domainsConfig: { findUnique: vi.fn(async () => state.row), upsert }, }), }; const crypto = { encrypt: vi.fn((plain: string) => `enc(${plain})`), decrypt: vi.fn((stored: string) => stored.replace(/^enc\((.*)\)$/, '$1')), }; const service = new DomainsSettingsService(db as any, crypto as any); service.transport = { fetchImpl, limiter: new AutodnsRateLimiter(0) }; return { service, state, upsert, crypto }; } const configuredDemo = () => emptyRow({ demoUser: 'api-user', demoEncryptedPassword: 'enc(geheim)', demoContext: 4, }); function fetchOf(status: number, body: unknown) { return vi.fn( async () => new Response(JSON.stringify(body), { status }), ) as unknown as AutodnsFetch & ReturnType; } describe('DomainsSettingsService — Lesen', () => { it('ohne Zeile: Demo, nichts eingerichtet', async () => { const { service } = makeService(null); expect(await service.getSettings('t1')).toEqual({ environment: 'DEMO', demo: { user: null, hasPassword: false, context: null }, live: { user: null, hasPassword: false, context: null }, defaultNameServers: [], configured: { demo: false, live: false }, }); }); it('getStatus meldet das aktive System und beide Einrichtungsstaende', async () => { const { service } = makeService(configuredDemo()); expect(await service.getStatus('t1')).toEqual({ environment: 'DEMO', configured: true, demoConfigured: true, liveConfigured: false, defaultNameServers: [], }); }); it('aktives LIVE ohne Live-Zugang gilt als nicht eingerichtet', async () => { const { service } = makeService({ ...configuredDemo(), environment: 'LIVE' }); const status = await service.getStatus('t1'); expect(status.environment).toBe('LIVE'); expect(status.configured).toBe(false); expect(status.demoConfigured).toBe(true); }); }); describe('DomainsSettingsService — Speichern', () => { it('verschluesselt das Passwort und gibt es nie zurueck', async () => { const { service, upsert } = makeService(null); const response = await service.saveSettings('t1', { demoUser: ' api-user ', demoPassword: 'geheim', demoContext: 4, }); const call = upsert.mock.calls[0][0]; expect(call.update.demoEncryptedPassword).toBe('enc(geheim)'); expect(call.update.demoUser).toBe('api-user'); expect(call.create.tenantId).toBe('t1'); const json = JSON.stringify(response); expect(json).not.toContain('geheim'); expect(json).not.toContain('enc('); expect(json).not.toContain('ncrypted'); expect(response.demo.hasPassword).toBe(true); expect(response.configured.demo).toBe(true); }); it('ohne oder mit leerem Passwort bleibt das gespeicherte erhalten', async () => { const { service, upsert } = makeService(configuredDemo()); await service.saveSettings('t1', { demoUser: 'neu' }); await service.saveSettings('t1', { demoUser: 'neu2', demoPassword: '' }); for (const [arg] of upsert.mock.calls) { expect(arg.update).not.toHaveProperty('demoEncryptedPassword'); } }); it('aendert nur mitgeschickte Felder', async () => { const { service, upsert } = makeService(configuredDemo()); await service.saveSettings('t1', { liveContext: 4 }); expect(upsert.mock.calls[0][0].update).toEqual({ liveContext: 4 }); }); it('Wechsel auf LIVE verlangt confirmLive', async () => { const { service } = makeService(configuredDemo()); await expect(service.saveSettings('t1', { environment: 'LIVE' })).rejects.toMatchObject({ response: { code: 'confirmLiveRequired' }, }); await expect( service.saveSettings('t1', { environment: 'LIVE', confirmLive: false }), ).rejects.toBeInstanceOf(BadRequestException); const ok = await service.saveSettings('t1', { environment: 'LIVE', confirmLive: true }); expect(ok.environment).toBe('LIVE'); }); it('Rueckwechsel auf DEMO und erneutes Speichern von LIVE brauchen keine Bestaetigung', async () => { const { service } = makeService({ ...configuredDemo(), environment: 'LIVE' }); await expect(service.saveSettings('t1', { environment: 'LIVE' })).resolves.toBeDefined(); await expect(service.saveSettings('t1', { environment: 'DEMO' })).resolves.toBeDefined(); }); it('normalisiert Nameserver und prueft Anzahl und Form', async () => { const { service, upsert } = makeService(null); await service.saveSettings('t1', { defaultNameServers: [' NS1.Example.COM ', 'ns2.example.com'], }); expect(upsert.mock.calls[0][0].update.defaultNameServers).toEqual([ 'ns1.example.com', 'ns2.example.com', ]); await expect( service.saveSettings('t1', { defaultNameServers: ['ns1.example.com'] }), ).rejects.toMatchObject({ response: { code: 'tooFewNameServers' } }); await expect( service.saveSettings('t1', { defaultNameServers: Array.from({ length: 7 }, (_, i) => `ns${i}.example.com`), }), ).rejects.toBeInstanceOf(BadRequestException); await expect( service.saveSettings('t1', { defaultNameServers: ['ns1.example.com', 'kein rechner!'] }), ).rejects.toMatchObject({ response: { code: 'invalidNameServer' } }); // Leeren ist erlaubt await service.saveSettings('t1', { defaultNameServers: [] }); expect(upsert.mock.calls.at(-1)?.[0].update.defaultNameServers).toEqual([]); }); }); describe('DomainsSettingsService — Zugang', () => { it('getActiveCredentials liefert Zugang und Version', async () => { const { service } = makeService(configuredDemo()); const active = await service.getActiveCredentials('t1'); expect(active).toEqual({ environment: 'DEMO', credentials: { environment: 'DEMO', user: 'api-user', password: 'geheim', context: 4 }, configVersion: new Date('2026-10-08T10:00:00Z').getTime(), }); }); it('nicht eingerichtet -> 409 notConfigured', async () => { const { service } = makeService(null); await expect(service.getActiveCredentials('t1')).rejects.toBeInstanceOf(ConflictException); await expect(service.getActiveCredentials('t1')).rejects.toMatchObject({ response: { code: 'notConfigured' }, }); }); it('Entschluesselungsfehler ist laut und kein stilles "ohne Passwort"', async () => { const { service, crypto } = makeService(configuredDemo()); crypto.decrypt.mockImplementation(() => { throw new Error('bad decrypt'); }); await expect(service.getActiveCredentials('t1')).rejects.toBeInstanceOf( InternalServerErrorException, ); await expect(service.testConnection('t1', 'DEMO')).rejects.toBeInstanceOf( InternalServerErrorException, ); }); }); describe('DomainsSettingsService — Verbindungstest', () => { it('nicht eingerichtetes System: ohne Netzaufruf', async () => { const fetchImpl = fetchOf(200, {}); const { service } = makeService(configuredDemo(), fetchImpl); const result = await service.testConnection('t1', 'LIVE'); expect(result).toMatchObject({ ok: false, kind: 'not-configured' }); expect(fetchImpl).not.toHaveBeenCalled(); }); it('Erfolg: genau ein GET /hello mit dem entschluesselten Passwort', async () => { const fetchImpl = fetchOf(200, { status: { code: 'S0301', type: 'SUCCESS' }, data: [] }); const { service } = makeService(configuredDemo(), fetchImpl); const result = await service.testConnection('t1', 'DEMO'); expect(result.ok).toBe(true); expect(fetchImpl).toHaveBeenCalledTimes(1); const [url, init] = (fetchImpl as unknown as ReturnType).mock.calls[0] as [ string, any, ]; expect(url).toBe('https://api.demo.autodns.com/v1/hello'); expect(init.method).toBe('GET'); expect(init.headers.Authorization).toBe('Basic YXBpLXVzZXI6Z2VoZWlt'); expect(init.headers['X-Domainrobot-Context']).toBe('4'); }); it('falsche Anmeldung: ok false, kind auth, Hinweis auf Benutzername, Passwort und Kontext', async () => { const fetchImpl = fetchOf(401, { status: { code: null, type: 'ERROR' }, messages: [ { code: 'EF00202', text: 'User does not exist or password incorrect.', status: 'ERROR' }, ], }); const { service } = makeService(configuredDemo(), fetchImpl); const result = await service.testConnection('t1', 'DEMO'); expect(result).toMatchObject({ ok: false, kind: 'auth' }); expect(result.message).toContain('Benutzername, Passwort und Kontext'); expect(JSON.stringify(result)).not.toContain('geheim'); }); });