1f1c984184
- 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>
258 lines
9.8 KiB
TypeScript
258 lines
9.8 KiB
TypeScript
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');
|
|
});
|
|
});
|