feat(domains): Modul Domains mit AutoDNS-Zugang, Verbindungstest und Einstellungen

- Migration mit vier Tabellen (Einstellungen, Kunden, Kontaktzuordnung, Bestellungen), Zeilenschutz je Organisation
- AutoDNS-Client mit festen Demo-/Live-Adressen, Takt-Begrenzer, 20 s Zeitlimit, ohne Wiederholung
- Zugang je System verschluesselt gespeichert, Live-Wechsel nur mit Bestaetigung, Verbindungstest
- Modulseite mit Systemkennzeichnung und Reiter Einstellungen (nur Verwalten)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:35:21 +02:00
parent 6b0f840c1c
commit 1f1c984184
29 changed files with 3123 additions and 2 deletions
@@ -0,0 +1,257 @@
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> = {}): 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<typeof vi.fn>;
}
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<typeof vi.fn>).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');
});
});