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
+2
View File
@@ -17,6 +17,7 @@ import { DkvModule } from './dkv/dkv.module';
import { CertManagerModule } from './cert-manager/cert-manager.module';
import { FavoritesModule } from './favorites/favorites.module';
import { DomaincheckModule } from './domaincheck/domaincheck.module';
import { DomainsModule } from './domains/domains.module';
import { GroupsModule } from './groups/groups.module';
import { ModuleRegistryModule } from './module-registry/module-registry.module';
import { PrismaModule } from './prisma/prisma.module';
@@ -60,6 +61,7 @@ import { RemindersModule } from './reminders/reminders.module';
BugReportsModule,
ProxmoxModule,
NextcloudStatusModule,
DomainsModule,
KantineDatevModule,
HandelswareDatevModule,
CustomModulesModule,
+313
View File
@@ -0,0 +1,313 @@
import { describe, expect, it, vi } from 'vitest';
import {
type AutodnsCredentials,
type AutodnsFetch,
AutodnsRateLimiter,
autodnsRequest,
parseAutodnsEnvelope,
} from './autodns-client';
const CREDS: AutodnsCredentials = {
environment: 'DEMO',
user: 'api-user',
password: 'geheim',
context: 4,
};
const NO_WAIT = new AutodnsRateLimiter(0);
function jsonResponse(status: number, body: unknown): Response {
return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status });
}
function okEnvelope(extra: Record<string, unknown> = {}) {
return {
status: { code: 'S0301', text: 'ok', type: 'SUCCESS' },
stid: '20261008-app1',
object: { type: 'contact', value: '1', summary: 3 },
messages: [],
data: [{ id: 1 }],
...extra,
};
}
function fetchReturning(response: () => Response) {
const fn = vi.fn(async () => response());
return fn as unknown as AutodnsFetch & ReturnType<typeof vi.fn>;
}
describe('autodnsRequest — Aufruf', () => {
it('GET sendet exakt URL und Kopfzeilen (Demo)', async () => {
const fetchImpl = fetchReturning(() => jsonResponse(200, okEnvelope()));
await autodnsRequest(CREDS, 'GET', '/hello', { fetchImpl, limiter: NO_WAIT });
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [
string,
Record<string, unknown>,
];
expect(url).toBe('https://api.demo.autodns.com/v1/hello');
expect(init.method).toBe('GET');
expect(init.redirect).toBe('error');
expect(init.body).toBeUndefined();
const headers = init.headers as Record<string, string>;
expect(Object.keys(headers).sort()).toEqual(
['Accept', 'Authorization', 'User-Agent', 'X-Domainrobot-Context'].sort(),
);
expect(headers.Authorization).toBe('Basic YXBpLXVzZXI6Z2VoZWlt');
expect(headers['X-Domainrobot-Context']).toBe('4');
expect(headers.Accept).toBe('application/json');
expect(headers['User-Agent']).toMatch(/^Tessera\//);
expect(headers['Content-Type']).toBeUndefined();
});
it('LIVE zeigt auf die Live-Adresse', async () => {
const fetchImpl = fetchReturning(() => jsonResponse(200, okEnvelope()));
await autodnsRequest({ ...CREDS, environment: 'LIVE' }, 'GET', '/hello', {
fetchImpl,
limiter: NO_WAIT,
});
const [url] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [string];
expect(url).toBe('https://api.autodns.com/v1/hello');
});
it('wirft bei unbekannter Umgebung, bevor gesendet wird', async () => {
const fetchImpl = fetchReturning(() => jsonResponse(200, okEnvelope()));
await expect(
autodnsRequest({ ...CREDS, environment: 'STAGING' as never }, 'GET', '/hello', {
fetchImpl,
limiter: NO_WAIT,
}),
).rejects.toThrow();
expect(fetchImpl).not.toHaveBeenCalled();
});
it('POST mit Body ergaenzt Content-Type und sendet das JSON', async () => {
const fetchImpl = fetchReturning(() => jsonResponse(200, okEnvelope()));
await autodnsRequest(CREDS, 'POST', '/contact/_search', {
body: { filters: [], view: { limit: 100, offset: 0 } },
fetchImpl,
limiter: NO_WAIT,
});
const [, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [
string,
Record<string, unknown>,
];
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
expect(init.body).toBe('{"filters":[],"view":{"limit":100,"offset":0}}');
expect(init.method).toBe('POST');
});
it('haengt keys[] an', async () => {
const fetchImpl = fetchReturning(() => jsonResponse(200, okEnvelope()));
await autodnsRequest(CREDS, 'POST', '/domain/_search', {
body: {},
keys: ['expire', 'ownerc'],
fetchImpl,
limiter: NO_WAIT,
});
const [url] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [string];
expect(url).toBe('https://api.demo.autodns.com/v1/domain/_search?keys[]=expire&keys[]=ownerc');
});
it.each([
'/a/../b',
'/a?x=1',
'//a',
'hello',
'/a//b',
'/a#b',
])('lehnt den Pfad %s vor dem Senden ab', async (path) => {
const fetchImpl = fetchReturning(() => jsonResponse(200, okEnvelope()));
await expect(
autodnsRequest(CREDS, 'GET', path, { fetchImpl, limiter: NO_WAIT }),
).rejects.toThrow();
expect(fetchImpl).not.toHaveBeenCalled();
});
});
describe('autodnsRequest — Ergebnis', () => {
it('HTTP 200 + SUCCESS: ok mit Daten, Objekt und Status', async () => {
const fetchImpl = fetchReturning(() => jsonResponse(200, okEnvelope()));
const result = await autodnsRequest(CREDS, 'GET', '/hello', { fetchImpl, limiter: NO_WAIT });
expect(result).toMatchObject({
ok: true,
httpStatus: 200,
statusCode: 'S0301',
statusType: 'SUCCESS',
object: { type: 'contact', value: '1', summary: 3 },
data: [{ id: 1 }],
stid: '20261008-app1',
});
});
it('HTTP 200 + status.type ERROR ist ein Fachfehler mit den Meldungstexten', async () => {
const fetchImpl = fetchReturning(() =>
jsonResponse(200, {
status: { code: 'E0102', text: 'x', type: 'ERROR' },
messages: [{ code: 'E1', text: 'Domain not available', status: 'ERROR' }],
}),
);
const result = await autodnsRequest(CREDS, 'POST', '/domain', {
body: {},
fetchImpl,
limiter: NO_WAIT,
});
expect(result).toMatchObject({
ok: false,
kind: 'business',
httpStatus: 200,
messages: ['Domain not available'],
});
});
it('liest status.resultCode, wenn code fehlt', () => {
const result = parseAutodnsEnvelope(
200,
JSON.stringify({ status: { resultCode: 'S0301', type: 'SUCCESS' }, data: [] }),
);
expect(result).toMatchObject({ ok: true, statusCode: 'S0301' });
});
it('eine Meldung mit Status ERROR macht auch eine SUCCESS-Huelle zum Fehler', () => {
const result = parseAutodnsEnvelope(
200,
JSON.stringify({
status: { type: 'SUCCESS' },
messages: [{ text: 'kaputt', status: 'ERROR' }],
}),
);
expect(result).toMatchObject({ ok: false, kind: 'business' });
});
it.each([
[401, 'auth'],
[403, 'forbidden'],
[429, 'rate-limit'],
[500, 'business'],
])('HTTP %i mit Huelle -> %s', (status, kind) => {
const result = parseAutodnsEnvelope(
status,
JSON.stringify({
status: { code: null, text: null, type: 'ERROR' },
messages: [
{ code: 'EF00202', text: 'User does not exist or password incorrect.', status: 'ERROR' },
],
stid: 's1',
}),
);
expect(result).toMatchObject({ ok: false, kind, httpStatus: status });
if (!result.ok) expect(result.messages).toEqual(['User does not exist or password incorrect.']);
});
it('nicht-2xx ohne Huelle -> http (401 bleibt auth)', () => {
expect(parseAutodnsEnvelope(502, '<html>Bad Gateway</html>')).toMatchObject({
ok: false,
kind: 'http',
});
expect(parseAutodnsEnvelope(401, '')).toMatchObject({ ok: false, kind: 'auth' });
});
it('HTML oder leerer 200-Rumpf -> invalid-response', () => {
expect(parseAutodnsEnvelope(200, '<html>Login</html>')).toMatchObject({
ok: false,
kind: 'invalid-response',
});
expect(parseAutodnsEnvelope(200, '')).toMatchObject({ ok: false, kind: 'invalid-response' });
});
it('kuerzt Meldungstexte auf 200 Zeichen und hoechstens 5', () => {
const messages = Array.from({ length: 8 }, (_, i) => ({
text: `${i}${'x'.repeat(300)}`,
status: 'ERROR',
}));
const result = parseAutodnsEnvelope(
200,
JSON.stringify({ status: { type: 'ERROR' }, messages }),
);
if (result.ok) throw new Error('sollte fehlschlagen');
expect(result.messages).toHaveLength(5);
expect(result.messages.every((m) => m.length === 200)).toBe(true);
});
it('Zeitueberschreitung: nie aufloesender Aufruf -> timeout, genau ein Aufruf', async () => {
const fetchImpl = vi.fn(() => new Promise(() => undefined)) as unknown as AutodnsFetch &
ReturnType<typeof vi.fn>;
const result = await autodnsRequest(CREDS, 'POST', '/domain', {
body: {},
fetchImpl,
timeoutMs: 20,
limiter: NO_WAIT,
});
expect(result).toMatchObject({ ok: false, kind: 'timeout', httpStatus: null });
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('Netzwerkfehler -> network, Zertifikatsfehler -> tls, jeweils ein Aufruf', async () => {
const dns = vi.fn(async () => {
throw Object.assign(new TypeError('fetch failed'), { cause: { code: 'ENOTFOUND' } });
}) as unknown as AutodnsFetch & ReturnType<typeof vi.fn>;
expect(
await autodnsRequest(CREDS, 'GET', '/hello', { fetchImpl: dns, limiter: NO_WAIT }),
).toMatchObject({ ok: false, kind: 'network' });
expect(dns).toHaveBeenCalledTimes(1);
const cert = vi.fn(async () => {
throw Object.assign(new TypeError('fetch failed'), { cause: { code: 'CERT_HAS_EXPIRED' } });
}) as unknown as AutodnsFetch;
expect(
await autodnsRequest(CREDS, 'GET', '/hello', { fetchImpl: cert, limiter: NO_WAIT }),
).toMatchObject({ ok: false, kind: 'tls' });
});
it('Rumpf ueber 5 MiB -> invalid-response', async () => {
const big = 'x'.repeat(5 * 1024 * 1024 + 1);
const fetchImpl = fetchReturning(() => new Response(big, { status: 200 }));
const result = await autodnsRequest(CREDS, 'GET', '/hello', { fetchImpl, limiter: NO_WAIT });
expect(result).toMatchObject({ ok: false, kind: 'invalid-response' });
});
it('Ergebnis enthaelt weder Passwort noch Basic-Kopfzeile', async () => {
const fetchImpl = fetchReturning(() =>
jsonResponse(401, { status: { type: 'ERROR' }, messages: [] }),
);
const result = await autodnsRequest(CREDS, 'GET', '/hello', { fetchImpl, limiter: NO_WAIT });
const json = JSON.stringify(result);
expect(json).not.toContain('geheim');
expect(json).not.toContain('Basic ');
expect(json).not.toContain('YXBpLXVzZXI6Z2VoZWlt');
});
});
describe('AutodnsRateLimiter', () => {
it('startet drei Aufgaben im Abstand von mindestens 350 ms', async () => {
let clock = 0;
const limiter = new AutodnsRateLimiter(
350,
() => clock,
async (ms) => {
clock += ms;
},
);
const starts: number[] = [];
await Promise.all(
[0, 1, 2].map(() =>
limiter.schedule(async () => {
starts.push(clock);
}),
),
);
expect(starts[0]).toBe(0);
expect(starts[1]).toBeGreaterThanOrEqual(350);
expect(starts[2]).toBeGreaterThanOrEqual(700);
});
it('eine fehlgeschlagene Aufgabe blockiert die naechste nicht', async () => {
const limiter = new AutodnsRateLimiter(0);
const failing = limiter.schedule(async () => {
throw new Error('boom');
});
await expect(failing).rejects.toThrow('boom');
await expect(limiter.schedule(async () => 'weiter')).resolves.toBe('weiter');
});
});
+453
View File
@@ -0,0 +1,453 @@
import { fetch as undiciFetch } from 'undici';
/**
* Der einzige HTTP-Zugang zu AutoDNS / InterNetX (Domainrobot JSON API) —
* quick-261008-dts. Framework-frei, damit der Transport einzeln getestet wird.
*
* Leitplanken (jede ist durch `autodns-client.spec.ts` festgeschrieben):
*
* - FESTE HOSTS (kein SSRF): Die Basisadresse kommt ausschliesslich aus der
* Konstante `AUTODNS_BASE_URLS` (DEMO/LIVE). Es gibt keine freie
* Adresseingabe; ein anderer Umgebungswert wirft, bevor irgendetwas
* gesendet wird. Der Header `X-Domainrobot-Demo` wird nie gesendet — das
* System wird allein ueber die Basisadresse gewaehlt.
* - ANMELDUNG: HTTP Basic plus Header `X-Domainrobot-Context` (Zahl). Es gibt
* keinen API-Schluessel. Zugangsdaten verlassen diese Datei nie: weder ein
* Ergebniswert noch ein Fehlertext enthaelt Kopfzeilen oder das Passwort.
* - KEIN RETRY, nirgends, auch nicht fuer Lesezugriffe: Ein wiederholtes
* `POST /domain` koennte eine Domain zweimal registrieren (kostet Geld,
* Timeout heisst NICHT "nicht bestellt"); wiederholte falsche Anmeldungen
* koennen den AutoDNS-Benutzer sperren.
* - TAKT: AutoDNS erlaubt 3 Anfragen pro Sekunde und IP. Ein prozessweiter
* Begrenzer laesst hoechstens alle 350 ms einen Aufruf starten.
* - HTTP 200 mit `status.type === 'ERROR'` ist ein FEHLER (AutoDNS meldet
* Fachfehler nicht verlaesslich ueber den HTTP-Status).
* - `redirect: 'error'`: Zugangsdaten folgen nie einer Weiterleitung.
* - Zwingend `undiciFetch` statt des globalen `fetch` (Vorbild
* `proxmox-client.service.ts`): so bleibt der Aufrufweg im ganzen Backend
* einheitlich und in Tests ueber `fetchImpl` ersetzbar.
*
* `autodnsRequest` ist absichtlich allgemein (Methode, Pfad, Body, Schluessel),
* damit spaetere Funktionen (Transfer, Kuendigung, DNS-Zonen) keinen neuen
* Transport brauchen.
*/
export const AUTODNS_BASE_URLS = {
DEMO: 'https://api.demo.autodns.com/v1',
LIVE: 'https://api.autodns.com/v1',
} as const;
export type AutodnsEnvironmentName = keyof typeof AUTODNS_BASE_URLS;
export const AUTODNS_TIMEOUT_MS = 20_000;
export const AUTODNS_MAX_BODY_BYTES = 5 * 1024 * 1024;
export const AUTODNS_MIN_INTERVAL_MS = 350;
const MESSAGE_MAX_CHARS = 200;
const MESSAGE_MAX_COUNT = 5;
/**
* Bekannte Zertifikatsfehlerkennungen (gleiche Menge wie
* `proxmox-client.service.ts`).
*/
const CERTIFICATE_ERROR_CODES = new Set([
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
'CERT_HAS_EXPIRED',
'ERR_TLS_CERT_ALTNAME_INVALID',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'CERT_UNTRUSTED',
'ERR_TLS_CERT_ALTNAME_INVALID_ALTERNATE',
'CERT_SIGNATURE_FAILURE',
'CERT_NOT_YET_VALID',
]);
export interface AutodnsCredentials {
environment: AutodnsEnvironmentName;
user: string;
password: string;
context: number;
}
export type AutodnsFailureKind =
| 'auth'
| 'forbidden'
| 'rate-limit'
| 'business'
| 'http'
| 'timeout'
| 'network'
| 'tls'
| 'invalid-response';
export interface AutodnsObjectInfo {
type: string | null;
value: string | null;
summary: number | null;
}
export interface AutodnsSuccess {
ok: true;
httpStatus: number;
statusCode: string | null;
statusType: string | null;
object: AutodnsObjectInfo | null;
data: unknown[];
messages: string[];
stid: string | null;
}
export interface AutodnsFailure {
ok: false;
kind: AutodnsFailureKind;
httpStatus: number | null;
statusCode: string | null;
messages: string[];
stid: string | null;
}
export type AutodnsResult = AutodnsSuccess | AutodnsFailure;
// --- Antwort-Huelle ---------------------------------------------------------
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asString(value: unknown): string | null {
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return null;
}
/** Nur `messages[].text`, je auf 200 Zeichen gekuerzt, hoechstens 5 (D-C). */
function collectMessageTexts(messages: unknown): string[] {
if (!Array.isArray(messages)) return [];
const texts: string[] = [];
for (const entry of messages) {
if (!isRecord(entry)) continue;
const text = asString(entry.text);
if (text && text.trim().length > 0) {
texts.push(text.trim().slice(0, MESSAGE_MAX_CHARS));
if (texts.length >= MESSAGE_MAX_COUNT) break;
}
}
return texts;
}
function hasErrorMessage(messages: unknown): boolean {
if (!Array.isArray(messages)) return false;
return messages.some((m) => isRecord(m) && m.status === 'ERROR');
}
function parseObjectInfo(value: unknown): AutodnsObjectInfo | null {
if (!isRecord(value)) return null;
const summary =
typeof value.summary === 'number' && Number.isFinite(value.summary) ? value.summary : null;
return { type: asString(value.type), value: asString(value.value), summary };
}
function failureKindForStatus(httpStatus: number, hasEnvelope: boolean): AutodnsFailureKind {
if (httpStatus === 401) return 'auth';
if (httpStatus === 403) return 'forbidden';
if (httpStatus === 429) return 'rate-limit';
return hasEnvelope ? 'business' : 'http';
}
/**
* Reiner Parser der AutoDNS-Antwort. Wirft nie. Erfolg ist: HTTP 2xx UND eine
* lesbare Huelle UND weder `status.type === 'ERROR'` noch eine Meldung mit
* Status `ERROR`. `status.code` fehlt in manchen Dokumentationen und heisst
* dort `resultCode` — beides wird gelesen.
*/
export function parseAutodnsEnvelope(httpStatus: number, text: string): AutodnsResult {
const is2xx = httpStatus >= 200 && httpStatus < 300;
let parsed: unknown = null;
if (text && text.trim().length > 0) {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
}
const envelope =
isRecord(parsed) && (isRecord(parsed.status) || 'messages' in parsed) ? parsed : null;
if (!envelope) {
if (is2xx) {
return {
ok: false,
kind: 'invalid-response',
httpStatus,
statusCode: null,
messages: [],
stid: null,
};
}
return {
ok: false,
kind: failureKindForStatus(httpStatus, false),
httpStatus,
statusCode: null,
messages: [],
stid: null,
};
}
const status = isRecord(envelope.status) ? envelope.status : {};
const statusCode = asString(status.code) ?? asString(status.resultCode);
const statusType = asString(status.type);
const messages = collectMessageTexts(envelope.messages);
const stid = asString(envelope.stid);
if (!is2xx) {
return {
ok: false,
kind: failureKindForStatus(httpStatus, true),
httpStatus,
statusCode,
messages,
stid,
};
}
if (statusType === 'ERROR' || hasErrorMessage(envelope.messages)) {
return { ok: false, kind: 'business', httpStatus, statusCode, messages, stid };
}
return {
ok: true,
httpStatus,
statusCode,
statusType,
object: parseObjectInfo(envelope.object),
data: Array.isArray(envelope.data) ? envelope.data : [],
messages,
stid,
};
}
// --- Kopfzeilen -------------------------------------------------------------
export function buildAutodnsHeaders(
credentials: AutodnsCredentials,
hasBody: boolean,
): Record<string, string> {
const basic = Buffer.from(`${credentials.user}:${credentials.password}`, 'utf8').toString(
'base64',
);
const headers: Record<string, string> = {
Authorization: `Basic ${basic}`,
'X-Domainrobot-Context': String(credentials.context),
Accept: 'application/json',
'User-Agent': `Tessera/${process.env.APP_VERSION || 'dev'}`,
};
if (hasBody) headers['Content-Type'] = 'application/json';
return headers;
}
// --- Takt-Begrenzer ---------------------------------------------------------
/**
* Prozessweite Warteschlange: zwei Aufrufstarts liegen mindestens
* `minIntervalMs` auseinander. Die Aufgabe selbst laeuft danach frei; ein
* Fehlschlag einer Aufgabe unterbricht die Kette nicht.
*/
export class AutodnsRateLimiter {
private lastStart = Number.NEGATIVE_INFINITY;
private tail: Promise<void> = Promise.resolve();
constructor(
private readonly minIntervalMs: number = AUTODNS_MIN_INTERVAL_MS,
private readonly now: () => number = () => Date.now(),
private readonly sleep: (ms: number) => Promise<void> = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms)),
) {}
schedule<T>(task: () => Promise<T>): Promise<T> {
const started = this.tail.then(async () => {
const wait = this.lastStart + this.minIntervalMs - this.now();
if (wait > 0) await this.sleep(wait);
this.lastStart = this.now();
});
this.tail = started.catch(() => undefined);
return started.then(() => task());
}
}
/** Gemeinsamer Begrenzer fuer alle Aufrufe dieses Prozesses. */
export const defaultAutodnsLimiter = new AutodnsRateLimiter();
// --- Anfrage ----------------------------------------------------------------
interface ResponseLike {
status: number;
headers?: { get(name: string): string | null };
body?: {
getReader(): {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(): Promise<void>;
};
} | null;
text(): Promise<string>;
}
export type AutodnsFetch = (
url: string,
init: {
method: string;
headers: Record<string, string>;
body?: string;
signal: AbortSignal;
redirect: 'error';
},
) => Promise<ResponseLike>;
export interface AutodnsRequestOptions {
body?: unknown;
/** Zusatzfelder (`?keys[]=a&keys[]=b`). */
keys?: string[];
fetchImpl?: AutodnsFetch;
timeoutMs?: number;
limiter?: AutodnsRateLimiter;
}
class AutodnsTimeoutError extends Error {
constructor() {
super('AutoDNS request timed out');
this.name = 'AutodnsTimeoutError';
}
}
class AutodnsBodyTooLargeError extends Error {
constructor() {
super('AutoDNS response body too large');
this.name = 'AutodnsBodyTooLargeError';
}
}
function isCertificateError(err: unknown): boolean {
const code = (err as { code?: unknown } | null)?.code;
const causeCode = (err as { cause?: { code?: unknown } } | null)?.cause?.code;
if (typeof code === 'string' && CERTIFICATE_ERROR_CODES.has(code)) return true;
if (typeof causeCode === 'string' && CERTIFICATE_ERROR_CODES.has(causeCode)) return true;
const message = err instanceof Error ? err.message : String(err ?? '');
for (const known of CERTIFICATE_ERROR_CODES) {
if (message.includes(known)) return true;
}
return false;
}
function failure(kind: AutodnsFailureKind): AutodnsFailure {
return { ok: false, kind, httpStatus: null, statusCode: null, messages: [], stid: null };
}
/** Liest den Rumpf hoechstens bis `AUTODNS_MAX_BODY_BYTES` ein. */
async function readCappedBody(response: ResponseLike): Promise<string> {
const declared = Number(response.headers?.get('content-length') ?? '');
if (Number.isFinite(declared) && declared > AUTODNS_MAX_BODY_BYTES) {
throw new AutodnsBodyTooLargeError();
}
const reader = response.body?.getReader();
if (!reader) {
const text = await response.text();
if (Buffer.byteLength(text, 'utf8') > AUTODNS_MAX_BODY_BYTES) {
throw new AutodnsBodyTooLargeError();
}
return text;
}
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
total += value.byteLength;
if (total > AUTODNS_MAX_BODY_BYTES) {
await reader.cancel().catch(() => undefined);
throw new AutodnsBodyTooLargeError();
}
chunks.push(value);
}
}
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
}
function assertValidPath(path: string): void {
if (
typeof path !== 'string' ||
!path.startsWith('/') ||
path.includes('..') ||
path.includes('?') ||
path.includes('#') ||
path.includes('//') ||
/\s/.test(path)
) {
throw new Error(`Ungültiger AutoDNS-Pfad: ${String(path).slice(0, 80)}`);
}
}
/**
* Genau EIN Aufruf an AutoDNS, ohne Wiederholung. Wirft nur bei
* Programmierfehlern (unbekannte Umgebung, ungueltiger Pfad); Netz-, Zeit-,
* TLS- und HTTP-Probleme kommen als `{ ok: false, kind }` zurueck.
*/
export async function autodnsRequest(
credentials: AutodnsCredentials,
method: 'GET' | 'POST' | 'PUT',
path: string,
opts: AutodnsRequestOptions = {},
): Promise<AutodnsResult> {
const environment: unknown = credentials.environment;
if (environment !== 'DEMO' && environment !== 'LIVE') {
throw new Error('Unbekannte AutoDNS-Umgebung.');
}
assertValidPath(path);
let query = '';
if (opts.keys && opts.keys.length > 0) {
query = `?${opts.keys.map((k) => `keys[]=${encodeURIComponent(k)}`).join('&')}`;
}
const url = `${AUTODNS_BASE_URLS[credentials.environment]}${path}${query}`;
const hasBody = opts.body !== undefined;
const headers = buildAutodnsHeaders(credentials, hasBody);
const fetchImpl = opts.fetchImpl ?? (undiciFetch as unknown as AutodnsFetch);
const timeoutMs = opts.timeoutMs ?? AUTODNS_TIMEOUT_MS;
const limiter = opts.limiter ?? defaultAutodnsLimiter;
const execute = async (): Promise<AutodnsResult> => {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new AutodnsTimeoutError());
}, timeoutMs);
});
const exchange = async (): Promise<AutodnsResult> => {
const response = await fetchImpl(url, {
method,
headers,
...(hasBody ? { body: JSON.stringify(opts.body) } : {}),
signal: controller.signal,
redirect: 'error',
});
const text = await readCappedBody(response);
return parseAutodnsEnvelope(response.status, text);
};
try {
return await Promise.race([exchange(), timeout]);
} catch (err) {
if (err instanceof AutodnsTimeoutError) return failure('timeout');
if (err instanceof AutodnsBodyTooLargeError) return failure('invalid-response');
if (isCertificateError(err)) return failure('tls');
return failure('network');
} finally {
if (timer) clearTimeout(timer);
}
};
return limiter.schedule(execute);
}
@@ -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');
});
});
@@ -0,0 +1,319 @@
import {
BadRequestException,
ConflictException,
Injectable,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import {
AUTODNS_BASE_URLS,
type AutodnsCredentials,
type AutodnsFetch,
type AutodnsRateLimiter,
autodnsRequest,
} from './autodns-client';
import {
AUTODNS_AUTH_MESSAGE,
type DomainsConnectionTestResult,
type DomainsEnvironment,
type DomainsEnvironmentView,
type DomainsSettingsView,
type DomainsStatusView,
} from './domains.types';
import type { SaveDomainsSettingsDto } from './dto/domains-settings.dto';
export const NOT_CONFIGURED = {
code: 'notConfigured',
message:
'AutoDNS ist für das gewählte System noch nicht eingerichtet. Bitte hinterlegen Sie den Zugang in den Einstellungen.',
};
const DECRYPT_FAILED = {
code: 'decryptFailed',
message:
'Das gespeicherte AutoDNS-Passwort ließ sich nicht entschlüsseln. Bitte tragen Sie es in den Einstellungen neu ein.',
};
const MIN_NAMESERVERS = 2;
const MAX_NAMESERVERS = 6;
/** Rechnername: Labels aus Buchstaben, Ziffern, Bindestrich; mindestens zwei Labels. */
const HOSTNAME_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])$/;
interface ConfigRow {
environment: DomainsEnvironment;
demoUser: string | null;
demoEncryptedPassword: string | null;
demoContext: number | null;
liveUser: string | null;
liveEncryptedPassword: string | null;
liveContext: number | null;
defaultNameServers: string[];
updatedAt: Date;
}
export interface ActiveCredentials {
environment: DomainsEnvironment;
credentials: AutodnsCredentials;
/** Aenderungsstand der Einstellungen (ms) — Teil des Zwischenspeicher-Schluessels. */
configVersion: number;
}
function isConfigured(user: string | null, password: string | null, context: number | null) {
return Boolean(user && password && context !== null && context !== undefined);
}
/**
* Einstellungen des Moduls Domains (quick-261008-dts): getrennte, verschluesselt
* abgelegte Zugaenge fuer das Demo- und das Live-System, Umschalter,
* Standard-Nameserver und der Verbindungstest. Das Passwort verlaesst diesen
* Dienst nie in Richtung Client; Antworten tragen nur `hasPassword`.
* Gesamter Zugriff auf `domainsConfig` liegt ausschliesslich hier.
*/
@Injectable()
export class DomainsSettingsService {
private readonly logger = new Logger(DomainsSettingsService.name);
/**
* Nur fuer Tests: ersetzt den Netzzugang und den Takt-Begrenzer. Im
* Betrieb bleibt das leer (echter `undiciFetch`, prozessweiter Begrenzer).
*/
transport: { fetchImpl?: AutodnsFetch; limiter?: AutodnsRateLimiter } = {};
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService,
) {}
// --- Lesen -----------------------------------------------------------------
private async loadRow(tenantId: string): Promise<ConfigRow | null> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.domainsConfig.findUnique({ where: { tenantId } });
return (row as ConfigRow | null) ?? null;
}
private envView(row: ConfigRow | null, env: DomainsEnvironment): DomainsEnvironmentView {
if (!row) return { user: null, hasPassword: false, context: null };
return env === 'DEMO'
? {
user: row.demoUser ?? null,
hasPassword: Boolean(row.demoEncryptedPassword),
context: row.demoContext ?? null,
}
: {
user: row.liveUser ?? null,
hasPassword: Boolean(row.liveEncryptedPassword),
context: row.liveContext ?? null,
};
}
private toSettingsView(row: ConfigRow | null): DomainsSettingsView {
const demoConfigured = row
? isConfigured(row.demoUser, row.demoEncryptedPassword, row.demoContext)
: false;
const liveConfigured = row
? isConfigured(row.liveUser, row.liveEncryptedPassword, row.liveContext)
: false;
return {
environment: row?.environment ?? 'DEMO',
demo: this.envView(row, 'DEMO'),
live: this.envView(row, 'LIVE'),
defaultNameServers: row?.defaultNameServers ?? [],
configured: { demo: demoConfigured, live: liveConfigured },
};
}
async getSettings(tenantId: string): Promise<DomainsSettingsView> {
return this.toSettingsView(await this.loadRow(tenantId));
}
async getStatus(tenantId: string): Promise<DomainsStatusView> {
const view = this.toSettingsView(await this.loadRow(tenantId));
return {
environment: view.environment,
configured: view.environment === 'DEMO' ? view.configured.demo : view.configured.live,
demoConfigured: view.configured.demo,
liveConfigured: view.configured.live,
defaultNameServers: view.defaultNameServers,
};
}
// --- Speichern -------------------------------------------------------------
private normalizeNameServers(raw: string[]): string[] {
const list = raw.map((n) => n.trim().toLowerCase()).filter((n) => n.length > 0);
if (list.length === 0) return [];
if (list.length > MAX_NAMESERVERS) {
throw new BadRequestException({
code: 'tooManyNameServers',
message: 'Höchstens sechs Nameserver sind möglich.',
});
}
for (const name of list) {
if (!HOSTNAME_PATTERN.test(name)) {
throw new BadRequestException({
code: 'invalidNameServer',
message: `Der Nameserver „${name}“ ist kein gültiger Rechnername.`,
});
}
}
if (list.length < MIN_NAMESERVERS) {
throw new BadRequestException({
code: 'tooFewNameServers',
message: 'Bitte geben Sie mindestens zwei Nameserver an.',
});
}
return list;
}
async saveSettings(tenantId: string, dto: SaveDomainsSettingsDto): Promise<DomainsSettingsView> {
const current = await this.loadRow(tenantId);
if (
dto.environment === 'LIVE' &&
(current?.environment ?? 'DEMO') !== 'LIVE' &&
dto.confirmLive !== true
) {
throw new BadRequestException({
code: 'confirmLiveRequired',
message:
'Der Wechsel auf das Live-System muss ausdrücklich bestätigt werden, weil Registrierungen dort Geld kosten.',
});
}
const data: Record<string, unknown> = {};
if (dto.environment !== undefined) data.environment = dto.environment;
if (dto.demoUser !== undefined) data.demoUser = dto.demoUser.trim() || null;
if (dto.liveUser !== undefined) data.liveUser = dto.liveUser.trim() || null;
if (dto.demoContext !== undefined) data.demoContext = dto.demoContext;
if (dto.liveContext !== undefined) data.liveContext = dto.liveContext;
// Leeres oder fehlendes Passwort = gespeichertes bleibt (Muster LDAP).
if (dto.demoPassword) data.demoEncryptedPassword = this.crypto.encrypt(dto.demoPassword);
if (dto.livePassword) data.liveEncryptedPassword = this.crypto.encrypt(dto.livePassword);
if (dto.defaultNameServers !== undefined) {
data.defaultNameServers = this.normalizeNameServers(dto.defaultNameServers);
}
const tenantPrisma = forTenant(this.prisma, tenantId);
const saved = await tenantPrisma.domainsConfig.upsert({
where: { tenantId },
create: { tenantId, ...data },
update: data,
});
return this.toSettingsView(saved as ConfigRow);
}
// --- Zugang ----------------------------------------------------------------
/** Entschluesselt laut; ein Fehler ist NIE "kein Passwort" (D-D). */
private decryptPassword(stored: string): string {
try {
return this.crypto.decrypt(stored);
} catch (error) {
this.logger.error(
'AutoDNS-Passwort ließ sich nicht entschlüsseln (Schlüssel geändert oder Wert beschädigt)',
error instanceof Error ? error.stack : undefined,
);
throw new InternalServerErrorException(DECRYPT_FAILED);
}
}
private credentialsFor(
row: ConfigRow | null,
environment: DomainsEnvironment,
): AutodnsCredentials | null {
if (!row) return null;
const user = environment === 'DEMO' ? row.demoUser : row.liveUser;
const encrypted =
environment === 'DEMO' ? row.demoEncryptedPassword : row.liveEncryptedPassword;
const context = environment === 'DEMO' ? row.demoContext : row.liveContext;
if (!user || !encrypted || context === null || context === undefined) return null;
return { environment, user, password: this.decryptPassword(encrypted), context };
}
/** Zugang des AKTIVEN Systems, sonst 409 `notConfigured`. */
async getActiveCredentials(tenantId: string): Promise<ActiveCredentials> {
const row = await this.loadRow(tenantId);
const environment: DomainsEnvironment = row?.environment ?? 'DEMO';
const credentials = this.credentialsFor(row, environment);
if (!row || !credentials) throw new ConflictException(NOT_CONFIGURED);
return { environment, credentials, configVersion: row.updatedAt.getTime() };
}
/** Standard-Nameserver fuer das Registrierungsformular. */
async getDefaultNameServers(tenantId: string): Promise<string[]> {
return (await this.loadRow(tenantId))?.defaultNameServers ?? [];
}
// --- Verbindungstest -------------------------------------------------------
/**
* Genau ein `GET /hello` an das gewaehlte System. Antwortet immer mit
* `{ ok, kind, message }` — auch bei Fehlern (kein 4xx/5xx an den Browser).
*/
async testConnection(
tenantId: string,
environment: DomainsEnvironment,
): Promise<DomainsConnectionTestResult> {
const row = await this.loadRow(tenantId);
const credentials = this.credentialsFor(row, environment);
if (!credentials) {
return {
ok: false,
kind: 'not-configured',
message:
'Für dieses System sind Benutzername, Passwort und Kontext noch nicht vollständig gespeichert.',
};
}
const result = await autodnsRequest(credentials, 'GET', '/hello', {
fetchImpl: this.transport.fetchImpl,
limiter: this.transport.limiter,
});
if (result.ok) {
return {
ok: true,
message: 'Verbindung erfolgreich. AutoDNS hat die Anmeldung bestätigt.',
};
}
switch (result.kind) {
case 'auth':
case 'forbidden':
return { ok: false, kind: 'auth', message: AUTODNS_AUTH_MESSAGE };
case 'timeout':
return {
ok: false,
kind: 'timeout',
message:
'AutoDNS hat nicht rechtzeitig geantwortet. Bitte versuchen Sie es später erneut.',
};
case 'network':
return {
ok: false,
kind: 'network',
message: `AutoDNS ist nicht erreichbar (${AUTODNS_BASE_URLS[environment]}).`,
};
case 'tls':
return {
ok: false,
kind: 'tls',
message: 'Das Zertifikat von AutoDNS konnte nicht geprüft werden.',
};
default: {
const text = result.messages.join(' ');
return {
ok: false,
kind: result.kind,
message: text
? `AutoDNS meldet: ${text}`
: 'AutoDNS hat eine unerwartete Antwort geliefert.',
};
}
}
}
}
@@ -0,0 +1,83 @@
import 'reflect-metadata';
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY } from '../module-registry/module.guard';
import { DomainsController } from './domains.controller';
const proto = DomainsController.prototype as any;
const req = (tenantId?: string) => ({ tenantId }) as any;
function makeSettings() {
return {
getStatus: vi.fn(async (..._a: unknown[]) => ({})),
getSettings: vi.fn(async (..._a: unknown[]) => ({})),
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
testConnection: vi.fn(async (..._a: unknown[]) => ({})),
};
}
describe('DomainsController — Metadaten', () => {
it('haengt an modules/domains und traegt @UseModule(domains)', () => {
expect(Reflect.getMetadata('path', DomainsController)).toBe('modules/domains');
expect(Reflect.getMetadata(MODULE_SLUG_KEY, DomainsController)).toBe('domains');
});
it('Einstellungen und Verbindungstest verlangen Verwalten, ohne Rollen-Decorator', () => {
for (const name of ['getSettings', 'saveSettings', 'testConnection']) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBe(true);
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
}
});
it('getStatus steht auf Benutzen-Ebene', () => {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.getStatus)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, proto.getStatus)).toBeUndefined();
});
it('Pfade und Methoden', () => {
const route = (name: string) => [
Reflect.getMetadata('method', proto[name]),
Reflect.getMetadata('path', proto[name]),
];
// RequestMethod: GET 0, POST 1, PUT 2, DELETE 3
expect(route('getStatus')).toEqual([0, 'status']);
expect(route('getSettings')).toEqual([0, 'settings']);
expect(route('saveSettings')).toEqual([2, 'settings']);
expect(route('testConnection')).toEqual([1, 'connection-test']);
});
});
describe('DomainsController — Routen-Reihenfolge (statisch vor :id)', () => {
it('deklariert jeden Handler mit :id-Pfad nach allen statischen Handlern', () => {
const names = Object.getOwnPropertyNames(DomainsController.prototype).filter(
(n) => n !== 'constructor' && typeof proto[n] === 'function' && n !== 'requireTenantId',
);
const isIdRoute = (n: string) => String(Reflect.getMetadata('path', proto[n])).includes(':id');
const firstId = names.findIndex(isIdRoute);
if (firstId === -1) return;
names.slice(firstId).forEach((n) => {
expect(isIdRoute(n), `${n} steht nach einer :id-Route, ist aber statisch`).toBe(true);
});
});
});
describe('DomainsController — Verhalten', () => {
it('reicht req.tenantId weiter', async () => {
const settings = makeSettings();
const c = new DomainsController(settings as any);
await c.getStatus(req('t1'));
await c.getSettings(req('t1'));
await c.saveSettings(req('t1'), { demoUser: 'x' } as any);
await c.testConnection(req('t1'), { environment: 'DEMO' });
expect(settings.getStatus).toHaveBeenCalledWith('t1');
expect(settings.getSettings).toHaveBeenCalledWith('t1');
expect(settings.saveSettings).toHaveBeenCalledWith('t1', { demoUser: 'x' });
expect(settings.testConnection).toHaveBeenCalledWith('t1', 'DEMO');
});
it('ohne Mandantenkontext 403', async () => {
const c = new DomainsController(makeSettings() as any);
await expect(c.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
});
});
@@ -0,0 +1,63 @@
import { Body, Controller, ForbiddenException, Get, Post, Put, Req } from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import { DomainsSettingsService } from './domains-settings.service';
import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-settings.dto';
/**
* `@UseModule('domains')` auf Klassenebene — Aktivierung UND Freigabe.
* `tenantId` kommt ausschliesslich aus `req.tenantId`, nie aus Body oder Query.
*
* Rechte je Route (quick-261008-dts, D-P):
* Benutzen (nur Klassen-`@UseModule`): GET status, GET customers, GET
* contacts, GET domains, GET orders, POST orders/refresh (gleicht
* nur den Zustand mit AutoDNS ab, aendert dort nichts).
* Verwalten (`@ModuleManage('domains')`, Administratoren und Benutzer mit
* der Freigabestufe Verwalten): GET/PUT settings, POST
* connection-test, Kunden und Kontakte anlegen/aendern/zuordnen,
* Verfuegbarkeit pruefen, Bestellungen anlegen, abschicken,
* abbrechen.
* Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale
* RolesGuard wuerde Verwalter sonst aussperren.
*
* REIHENFOLGE: alle statischen Routen stehen VOR jeder Route mit `:id`, sonst
* faengt die Parameterroute sie ab (404-Shadowing). Spaetere Aufgaben haengen
* ihre `:id`-Routen ans ENDE; `domains.controller.spec.ts` prueft die
* Deklarationsreihenfolge.
*/
@Controller('modules/domains')
@UseModule('domains')
export class DomainsController {
constructor(private readonly settings: DomainsSettingsService) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get('status')
async getStatus(@Req() req: AuthenticatedRequest) {
return this.settings.getStatus(this.requireTenantId(req));
}
@Get('settings')
@ModuleManage('domains')
async getSettings(@Req() req: AuthenticatedRequest) {
return this.settings.getSettings(this.requireTenantId(req));
}
@Put('settings')
@ModuleManage('domains')
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: SaveDomainsSettingsDto) {
return this.settings.saveSettings(this.requireTenantId(req), dto);
}
@Post('connection-test')
@ModuleManage('domains')
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: TestDomainsConnectionDto) {
return this.settings.testConnection(this.requireTenantId(req), dto.environment);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { DomainsController } from './domains.controller';
import { seedDomainsModule } from './domains.seed';
import { DomainsSettingsService } from './domains-settings.service';
/**
* Modul "Domains" (quick-261008-dts): AutoDNS-Anbindung. Traegt sich beim Start
* in die Modulverwaltung ein; aktiviert wird per Marktplatz. `CryptoService`
* kommt aus dem globalen `CryptoModule`, `PrismaService` ist global.
*/
@Module({
imports: [ModuleRegistryModule],
controllers: [DomainsController],
providers: [DomainsSettingsService],
})
export class DomainsModule implements OnModuleInit {
private readonly logger = new Logger(DomainsModule.name);
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
async onModuleInit(): Promise<void> {
try {
await seedDomainsModule(this.moduleRegistryService);
this.logger.log('Domains module seeded in registry');
} catch (error) {
this.logger.error('Failed to seed domains module', error);
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import { ModuleRegistryService } from '../module-registry/module-registry.service';
/**
* Seeds the domains module into the module registry (quick-261008-dts).
* Vorbild `nextcloud-status.seed.ts`; Kategorie `domain-tools` (neben
* Domaincheck — Administratoren koennen sie umhaengen). Der Slug ist zugleich
* der Wert in `@UseModule`.
*/
export async function seedDomainsModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
slug: 'domains',
name: 'Domains',
version: '1.0.0',
category: 'domain-tools',
description: {
de: 'Domains bei AutoDNS registrieren, Kontakte und Kunden zuordnen',
en: 'Register domains with AutoDNS, assign contacts and customers',
},
isSystem: true,
});
}
+76
View File
@@ -0,0 +1,76 @@
import { BadGatewayException, GatewayTimeoutException, type HttpException } from '@nestjs/common';
import type { AutodnsFailure } from './autodns-client';
/** Gemeinsame Typen des Moduls Domains (quick-261008-dts). */
export type DomainsEnvironment = 'DEMO' | 'LIVE';
/** Zugang eines Systems, wie er an den Client geht — NIE mit Passwort. */
export interface DomainsEnvironmentView {
user: string | null;
hasPassword: boolean;
context: number | null;
}
export interface DomainsSettingsView {
environment: DomainsEnvironment;
demo: DomainsEnvironmentView;
live: DomainsEnvironmentView;
defaultNameServers: string[];
configured: { demo: boolean; live: boolean };
}
export interface DomainsStatusView {
environment: DomainsEnvironment;
/** Ist das AKTIVE System eingerichtet? */
configured: boolean;
demoConfigured: boolean;
liveConfigured: boolean;
defaultNameServers: string[];
}
export interface DomainsConnectionTestResult {
ok: boolean;
kind?: string;
message: string;
}
export const AUTODNS_AUTH_MESSAGE =
'Anmeldung bei AutoDNS fehlgeschlagen. Bitte prüfen Sie Benutzername, Passwort und Kontext.';
function joinMessages(failure: AutodnsFailure): string {
return failure.messages.length > 0 ? failure.messages.join(' ') : '';
}
/**
* Uebersetzt einen fehlgeschlagenen AutoDNS-Aufruf in die HTTP-Antwort an den
* Browser (D-F). Anmelde- und Rechtefehler von AutoDNS werden bewusst 502 und
* NIE 401/403: das Web wertet 401 als abgelaufene Tessera-Sitzung und wuerde
* den Benutzer abmelden.
*/
export function autodnsFailureToHttp(failure: AutodnsFailure): HttpException {
switch (failure.kind) {
case 'auth':
case 'forbidden':
return new BadGatewayException({ code: 'autodnsAuth', message: AUTODNS_AUTH_MESSAGE });
case 'timeout':
case 'network':
case 'tls':
return new GatewayTimeoutException({
code: 'autodnsUnavailable',
message: 'AutoDNS ist gerade nicht erreichbar. Bitte versuchen Sie es später erneut.',
});
case 'rate-limit':
return new BadGatewayException({
code: 'autodnsError',
message: 'AutoDNS bearbeitet zu viele Anfragen. Bitte versuchen Sie es in Kürze erneut.',
});
default: {
const text = joinMessages(failure);
return new BadGatewayException({
code: 'autodnsError',
message: text ? `AutoDNS meldet: ${text}` : 'AutoDNS hat die Anfrage nicht angenommen.',
});
}
}
}
@@ -0,0 +1,74 @@
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
/**
* Einstellungen des Moduls Domains (quick-261008-dts). Jedes Feld ist
* optional — gespeichert wird nur, was mitgeschickt wird (jede Karte der
* Oberflaeche speichert nur ihre eigenen Felder). Ein fehlendes oder leeres
* Passwort laesst das gespeicherte unveraendert.
*/
export class SaveDomainsSettingsDto {
@IsOptional()
@IsIn(['DEMO', 'LIVE'])
environment?: 'DEMO' | 'LIVE';
/** Ausdrueckliche Bestaetigung fuer den Wechsel auf das Live-System. */
@IsOptional()
@IsBoolean()
confirmLive?: boolean;
@IsOptional()
@IsString()
@MaxLength(100)
demoUser?: string;
@IsOptional()
@IsString()
@MaxLength(200)
demoPassword?: string;
@ValidateIf((_o, value) => value !== null && value !== undefined)
@IsInt()
@Min(1)
@Max(2147483647)
demoContext?: number | null;
@IsOptional()
@IsString()
@MaxLength(100)
liveUser?: string;
@IsOptional()
@IsString()
@MaxLength(200)
livePassword?: string;
@ValidateIf((_o, value) => value !== null && value !== undefined)
@IsInt()
@Min(1)
@Max(2147483647)
liveContext?: number | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@IsString({ each: true })
@MaxLength(253, { each: true })
defaultNameServers?: string[];
}
export class TestDomainsConnectionDto {
@IsIn(['DEMO', 'LIVE'])
environment!: 'DEMO' | 'LIVE';
}
@@ -4,6 +4,7 @@ import { Role } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { DkvController } from '../dkv/dkv.controller';
import { DomainsController } from '../domains/domains.controller';
import { ModuleGrantsController } from '../groups/module-grants.controller';
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
import { KantineDatevController } from '../kantine-datev/kantine-datev.controller';
@@ -84,6 +85,19 @@ describe('Umgestellte Handler (Verwalten)', () => {
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it.each(['getSettings', 'saveSettings', 'testConnection'])(
'DomainsController.%s verlangt Verwalten für domains (quick-261008-dts)',
(name) => {
expectManage(DomainsController, name, 'domains');
},
);
it.each(['getStatus'])('DomainsController.%s bleibt auf Benutzen-Ebene', (name) => {
const fn = handler(DomainsController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => {
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev');