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,165 @@
-- 261008-dts — Modul "Domains" (AutoDNS / InterNetX Domainrobot).
--
-- Zweck: vier neue Tabellen und zwei Aufzaehlungen.
-- * "DomainsConfig": Einstellungen der Organisation (Singleton), getrennte
-- Zugaenge fuer das Demo- und das Live-System, Passwoerter AES-verschluesselt
-- (CryptoService), Standard-Nameserver.
-- * "DomainsCustomer": Kunden, denen AutoDNS-Kontakte zugeordnet werden;
-- eine Zeile darf als "Eigene Firma" markiert sein (Pruefung im Dienst).
-- * "DomainsContactAssignment": Zuordnung AutoDNS-Kontakt -> Kunde. Die
-- Umgebung (DEMO/LIVE) gehoert zum Schluessel, weil Kontakt-Ids beider
-- Systeme kollidieren. Kontakte und Domains selbst werden NICHT gespiegelt,
-- AutoDNS bleibt die Quelle der Wahrheit.
-- * "DomainsOrder": Domain-Bestellungen (Geldsicherheit). "openKey" traegt den
-- Domainnamen, solange die Bestellung offen ist (DRAFT, SUBMITTING,
-- SUBMITTED, UNKNOWN, SUCCESS) und ist bei FAILED/CANCELED NULL. Die
-- Unique-Regel (tenantId, environment, openKey) verhindert zwei offene
-- Bestellungen derselben Domain; NULLs wiederholen sich in Postgres
-- beliebig. SUCCESS behaelt den Schluessel mit Absicht: kurz nach der
-- Registrierung kann ein verzoegerter WHOIS noch "frei" melden.
--
-- AutoDNS-Kontakt- und Job-Ids stehen als TEXT (opake Dezimalzahlen).
--
-- Von Hand geschrieben (Vorbild 20261002130000_handelsware_datev); der
-- DDL-Teil stammt aus `prisma migrate diff`, damit der Stand ohne Abweichung
-- zum Schema passt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): alle
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — das sind
-- Verwaltungsdaten der Organisation, keine persoenlichen Daten eines
-- Benutzers. Keine `system_read_policy`: es gibt keinen Hintergrunddienst, der
-- diese Tabellen ueber alle Organisationen liest (Auftragsstatus wird beim
-- Oeffnen der Seite abgefragt, nicht per Zeitplan).
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- CreateEnum
CREATE TYPE "AutodnsEnvironment" AS ENUM ('DEMO', 'LIVE');
-- CreateEnum
CREATE TYPE "DomainOrderStatus" AS ENUM ('DRAFT', 'SUBMITTING', 'SUBMITTED', 'SUCCESS', 'FAILED', 'UNKNOWN', 'CANCELED');
-- CreateTable
CREATE TABLE "DomainsConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"environment" "AutodnsEnvironment" NOT NULL DEFAULT 'DEMO',
"demoUser" TEXT,
"demoEncryptedPassword" TEXT,
"demoContext" INTEGER,
"liveUser" TEXT,
"liveEncryptedPassword" TEXT,
"liveContext" INTEGER,
"defaultNameServers" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainsConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainsCustomer" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isOwnCompany" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainsCustomer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainsContactAssignment" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"environment" "AutodnsEnvironment" NOT NULL,
"autodnsContactId" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainsContactAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainsOrder" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"environment" "AutodnsEnvironment" NOT NULL,
"domainName" TEXT NOT NULL,
"openKey" TEXT,
"status" "DomainOrderStatus" NOT NULL DEFAULT 'DRAFT',
"payload" JSONB NOT NULL,
"jobId" TEXT,
"jobStatus" TEXT,
"errorText" TEXT,
"createdByUserId" TEXT NOT NULL,
"confirmedByUserId" TEXT,
"confirmedByUsername" TEXT,
"confirmedAt" TIMESTAMP(3),
"lastCheckedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainsOrder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DomainsConfig_tenantId_key" ON "DomainsConfig"("tenantId");
-- CreateIndex
CREATE INDEX "DomainsConfig_tenantId_idx" ON "DomainsConfig"("tenantId");
-- CreateIndex
CREATE INDEX "DomainsCustomer_tenantId_idx" ON "DomainsCustomer"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "DomainsCustomer_tenantId_name_key" ON "DomainsCustomer"("tenantId", "name");
-- CreateIndex
CREATE INDEX "DomainsContactAssignment_tenantId_idx" ON "DomainsContactAssignment"("tenantId");
-- CreateIndex
CREATE INDEX "DomainsContactAssignment_customerId_idx" ON "DomainsContactAssignment"("customerId");
-- CreateIndex
CREATE UNIQUE INDEX "DomainsContactAssignment_tenantId_environment_autodnsContac_key" ON "DomainsContactAssignment"("tenantId", "environment", "autodnsContactId");
-- CreateIndex
CREATE INDEX "DomainsOrder_tenantId_idx" ON "DomainsOrder"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "DomainsOrder_tenantId_environment_openKey_key" ON "DomainsOrder"("tenantId", "environment", "openKey");
-- AddForeignKey
ALTER TABLE "DomainsContactAssignment" ADD CONSTRAINT "DomainsContactAssignment_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "DomainsCustomer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Zeilenschutz
ALTER TABLE "DomainsConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DomainsConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DomainsConfig"
USING ("tenantId" = current_tenant_id());
ALTER TABLE "DomainsCustomer" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DomainsCustomer" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DomainsCustomer"
USING ("tenantId" = current_tenant_id());
ALTER TABLE "DomainsContactAssignment" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DomainsContactAssignment" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DomainsContactAssignment"
USING ("tenantId" = current_tenant_id());
ALTER TABLE "DomainsOrder" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DomainsOrder" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DomainsOrder"
USING ("tenantId" = current_tenant_id());
+102
View File
@@ -841,6 +841,108 @@ model NextcloudAlertSubscription {
@@index([tenantId, userId])
}
// Domains (quick-261008-dts): Anbindung an AutoDNS / InterNetX (Domainrobot).
// AutoDNS bleibt die Quelle der Wahrheit fuer Kontakte und Domains (werden live
// gelesen, nie gespiegelt); lokal liegt nur, was AutoDNS nicht kennt (Zuordnung
// Kontakt -> Kunde) oder was Geldsicherheit braucht (Bestellungen). AutoDNS-
// Kontakt- und Job-Ids sind opake Dezimalzahlen und stehen als String (kein
// Int32-Ueberlauf). Zeilenschutz nach Muster ProxmoxServer (tenantId, keine
// Relation zu Tenant).
enum AutodnsEnvironment {
DEMO
LIVE
}
enum DomainOrderStatus {
DRAFT
SUBMITTING
SUBMITTED
SUCCESS
FAILED
UNKNOWN
CANCELED
}
// Einstellungen je Organisation (Singleton). Getrennte Zugaenge fuer das
// Demo- und das Live-System; Passwoerter AES-verschluesselt (CryptoService),
// nie an den Client zurueckgegeben.
model DomainsConfig {
id String @id @default(uuid())
tenantId String @unique
environment AutodnsEnvironment @default(DEMO)
demoUser String?
demoEncryptedPassword String?
demoContext Int?
liveUser String?
liveEncryptedPassword String?
liveContext Int?
defaultNameServers String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
// Kunden, denen AutoDNS-Kontakte zugeordnet werden. Hoechstens einer ist die
// eigene Firma (Pruefung im Dienst). Kein Firmenname als Vorgabe.
model DomainsCustomer {
id String @id @default(uuid())
tenantId String
name String
isOwnCompany Boolean @default(false)
assignments DomainsContactAssignment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, name])
@@index([tenantId])
}
// Zuordnung AutoDNS-Kontakt -> Kunde. Die Umgebung ist Teil des Schluessels,
// weil Kontakt-Ids von Demo- und Live-System zwangslaeufig kollidieren.
model DomainsContactAssignment {
id String @id @default(uuid())
tenantId String
environment AutodnsEnvironment
autodnsContactId String
customerId String
customer DomainsCustomer @relation(fields: [customerId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, environment, autodnsContactId])
@@index([tenantId])
@@index([customerId])
}
// Domain-Bestellungen (Geldsicherheit). `openKey` traegt den Domainnamen,
// solange die Bestellung offen ist (DRAFT, SUBMITTING, SUBMITTED, UNKNOWN,
// SUCCESS), und ist bei FAILED/CANCELED null; die Unique-Regel darauf
// verhindert zwei offene Bestellungen derselben Domain (NULLs wiederholen
// sich in Postgres).
model DomainsOrder {
id String @id @default(uuid())
tenantId String
environment AutodnsEnvironment
domainName String
openKey String?
status DomainOrderStatus @default(DRAFT)
payload Json
jobId String?
jobStatus String?
errorText String?
createdByUserId String
confirmedByUserId String?
confirmedByUsername String?
confirmedAt DateTime?
lastCheckedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, environment, openKey])
@@index([tenantId])
}
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
+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');