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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user