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');
@@ -0,0 +1,34 @@
import { useTranslations } from 'next-intl';
import type { DomainsStatus } from '@/lib/domains-api';
/**
* Dauerhafte Kennzeichnung des aktiven AutoDNS-Systems im Seitenkopf (D-E):
* Demo (Testbetrieb), Live (kostet Geld) oder "nicht eingerichtet". Die
* Klassen stehen als vollstaendige Zeichenketten, damit Tailwind sie findet.
*/
export function EnvironmentBadge({ status }: { status: DomainsStatus | null }) {
const t = useTranslations('domains.environment');
if (!status) return null;
let label: string;
let classes: string;
if (!status.configured) {
label = t('notConfigured');
classes = 'bg-status-idle/12 text-status-idle-fg';
} else if (status.environment === 'LIVE') {
label = t('live');
classes = 'bg-status-down/12 text-status-down-fg';
} else {
label = t('demo');
classes = 'bg-status-warn/12 text-status-warn-fg';
}
return (
<span
data-testid="domains-environment-badge"
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${classes}`}
>
{label}
</span>
);
}
@@ -0,0 +1,509 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { SettingsSection } from '@/components/control-center/settings-section';
import {
type ConnectionTestResult,
type DomainsEnvironment,
type DomainsEnvironmentAccess,
DomainsRequestError,
type DomainsSettings,
getDomainsSettings,
type SaveDomainsSettingsInput,
saveDomainsSettings,
testDomainsConnection,
} from '@/lib/domains-api';
const INPUT_CLASS =
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
const PRIMARY_BUTTON =
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
const SECONDARY_BUTTON =
'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:cursor-not-allowed disabled:opacity-50';
/** Der Live-Kontext von AutoDNS ist in der Regel 4; das Demo-System hat keinen Vorschlag. */
const LIVE_DEFAULT_CONTEXT = 4;
const MIN_NAMESERVER_ROWS = 2;
const MAX_NAMESERVER_ROWS = 6;
type Message = { kind: 'ok' | 'error'; text: string };
function MessageLine({ message }: { message: Message | null }) {
if (!message) return null;
return (
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
);
}
function useErrorText() {
const t = useTranslations('domains');
return (error: unknown) =>
error instanceof DomainsRequestError ? error.message : t('errors.request');
}
// --- System (Demo/Live) -------------------------------------------------------
function SystemCard({
settings,
onSaved,
}: {
settings: DomainsSettings;
onSaved: (s: DomainsSettings) => void;
}) {
const t = useTranslations('domains.settings');
const errorText = useErrorText();
const [selected, setSelected] = useState<DomainsEnvironment>(settings.environment);
const [confirmingLive, setConfirmingLive] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<Message | null>(null);
useEffect(() => {
setSelected(settings.environment);
setConfirmingLive(false);
}, [settings.environment]);
const choose = (environment: DomainsEnvironment) => {
setMessage(null);
if (environment === 'LIVE' && settings.environment !== 'LIVE') {
// Der Wechsel auf das kostenpflichtige System braucht eine ausdrueckliche Bestaetigung.
setSelected('LIVE');
setConfirmingLive(true);
return;
}
setConfirmingLive(false);
setSelected(environment);
};
const save = async (input: SaveDomainsSettingsInput) => {
setSaving(true);
setMessage(null);
try {
onSaved(await saveDomainsSettings(input));
setConfirmingLive(false);
setMessage({ kind: 'ok', text: t('saved') });
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setSaving(false);
}
};
const cancelLive = () => {
setConfirmingLive(false);
setSelected(settings.environment);
};
return (
<SettingsSection
id="domains-system"
title={t('system.title')}
description={t('system.description')}
footer={
<>
<MessageLine message={message} />
<button
type="button"
className={PRIMARY_BUTTON}
disabled={saving || confirmingLive || selected === settings.environment}
onClick={() => save({ environment: selected })}
>
{saving ? t('saving') : t('save')}
</button>
</>
}
>
<fieldset className="space-y-3">
{(['DEMO', 'LIVE'] as const).map((env) => (
<label key={env} className="flex cursor-pointer items-start gap-3">
<input
type="radio"
name="domains-environment"
value={env}
checked={selected === env}
onChange={() => choose(env)}
className="mt-1"
/>
<span>
<span className="block text-sm font-medium text-foreground">
{env === 'DEMO' ? t('system.demo') : t('system.live')}
</span>
<span className="block text-xs text-muted-foreground">
{env === 'DEMO' ? t('system.demoHelp') : t('system.liveHelp')}
</span>
</span>
</label>
))}
</fieldset>
{confirmingLive && (
<div
role="alertdialog"
aria-label={t('system.confirmText')}
className="mt-4 space-y-3 rounded-md border border-status-down/40 bg-status-down/10 p-4"
>
<p className="text-sm font-medium text-foreground">{t('system.confirmText')}</p>
<div className="flex gap-2">
<button
type="button"
className={PRIMARY_BUTTON}
disabled={saving}
onClick={() => save({ environment: 'LIVE', confirmLive: true })}
>
{t('system.confirm')}
</button>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={saving}
onClick={cancelLive}
>
{t('system.cancel')}
</button>
</div>
</div>
)}
</SettingsSection>
);
}
// --- Zugang je System ---------------------------------------------------------
function parseContext(text: string): number | null | 'invalid' {
const trimmed = text.trim();
if (trimmed === '') return null;
if (!/^\d{1,10}$/.test(trimmed)) return 'invalid';
const n = Number.parseInt(trimmed, 10);
return n >= 1 && n <= 2147483647 ? n : 'invalid';
}
function AccessCard({
environment,
access,
onSaved,
}: {
environment: DomainsEnvironment;
access: DomainsEnvironmentAccess;
onSaved: (s: DomainsSettings) => void;
}) {
const t = useTranslations('domains.settings');
const errorText = useErrorText();
const prefix = environment === 'DEMO' ? 'demo' : 'live';
const initialContext =
access.context !== null
? String(access.context)
: environment === 'LIVE'
? String(LIVE_DEFAULT_CONTEXT)
: '';
const [user, setUser] = useState(access.user ?? '');
const [password, setPassword] = useState('');
const [context, setContext] = useState(initialContext);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [message, setMessage] = useState<Message | null>(null);
const [testResult, setTestResult] = useState<ConnectionTestResult | null>(null);
useEffect(() => {
setUser(access.user ?? '');
setPassword('');
setContext(
access.context !== null
? String(access.context)
: environment === 'LIVE'
? String(LIVE_DEFAULT_CONTEXT)
: '',
);
}, [access.user, access.context, environment]);
const contextValue = parseContext(context);
const dirty =
user.trim() !== (access.user ?? '') || password !== '' || context.trim() !== initialContext;
const save = async () => {
if (contextValue === 'invalid') return;
setSaving(true);
setMessage(null);
const input: SaveDomainsSettingsInput = {
[`${prefix}User`]: user.trim(),
[`${prefix}Context`]: contextValue,
...(password !== '' ? { [`${prefix}Password`]: password } : {}),
};
try {
onSaved(await saveDomainsSettings(input));
setPassword('');
setMessage({ kind: 'ok', text: t('saved') });
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setSaving(false);
}
};
const runTest = async () => {
setTesting(true);
setTestResult(null);
try {
setTestResult(await testDomainsConnection(environment));
} catch (error) {
setTestResult({ ok: false, message: errorText(error) });
} finally {
setTesting(false);
}
};
const idBase = `domains-${prefix}`;
return (
<SettingsSection
id={idBase}
title={environment === 'DEMO' ? t('access.demoTitle') : t('access.liveTitle')}
description={t('access.description')}
footer={
<>
<div className="mr-auto space-y-1">
<MessageLine message={message} />
{testResult && (
<p
role={testResult.ok ? 'status' : 'alert'}
className={`text-sm ${testResult.ok ? 'text-status-ok-fg' : 'text-destructive'}`}
>
{testResult.message}
</p>
)}
{dirty && <p className="text-xs text-muted-foreground">{t('access.saveFirst')}</p>}
</div>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={testing || dirty}
onClick={runTest}
>
{testing ? t('access.testing') : t('access.test')}
</button>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={saving || !dirty || contextValue === 'invalid'}
onClick={save}
>
{saving ? t('saving') : t('save')}
</button>
</>
}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label htmlFor={`${idBase}-user`} className="text-sm font-medium text-foreground">
{t('access.user')}
</label>
<input
id={`${idBase}-user`}
type="text"
autoComplete="off"
maxLength={100}
value={user}
onChange={(e) => setUser(e.target.value)}
className={INPUT_CLASS}
/>
</div>
<div className="space-y-1">
<label htmlFor={`${idBase}-password`} className="text-sm font-medium text-foreground">
{t('access.password')}
</label>
<input
id={`${idBase}-password`}
type="password"
autoComplete="new-password"
maxLength={200}
value={password}
placeholder={access.hasPassword ? t('access.passwordStored') : ''}
onChange={(e) => setPassword(e.target.value)}
className={INPUT_CLASS}
/>
</div>
<div className="space-y-1">
<label htmlFor={`${idBase}-context`} className="text-sm font-medium text-foreground">
{t('access.context')}
</label>
<input
id={`${idBase}-context`}
type="number"
inputMode="numeric"
min={1}
value={context}
aria-invalid={contextValue === 'invalid'}
onChange={(e) => setContext(e.target.value)}
className={INPUT_CLASS}
/>
{contextValue === 'invalid' && (
<p className="text-xs text-destructive">{t('access.contextInvalid')}</p>
)}
</div>
</div>
<p className="mt-3 text-xs text-muted-foreground">{t('access.contextHelp')}</p>
</SettingsSection>
);
}
// --- Standard-Nameserver --------------------------------------------------------
function padNameServers(list: string[]): string[] {
const rows = [...list];
while (rows.length < MIN_NAMESERVER_ROWS) rows.push('');
return rows;
}
function NameServersCard({
settings,
onSaved,
}: {
settings: DomainsSettings;
onSaved: (s: DomainsSettings) => void;
}) {
const t = useTranslations('domains.settings');
const errorText = useErrorText();
const [rows, setRows] = useState<string[]>(padNameServers(settings.defaultNameServers));
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<Message | null>(null);
useEffect(() => {
setRows(padNameServers(settings.defaultNameServers));
}, [settings.defaultNameServers]);
const cleaned = rows.map((r) => r.trim().toLowerCase()).filter((r) => r.length > 0);
const dirty = cleaned.join('\n') !== settings.defaultNameServers.join('\n');
const save = async () => {
setSaving(true);
setMessage(null);
try {
onSaved(await saveDomainsSettings({ defaultNameServers: cleaned }));
setMessage({ kind: 'ok', text: t('saved') });
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setSaving(false);
}
};
return (
<SettingsSection
id="domains-nameservers"
title={t('nameServers.title')}
description={t('nameServers.description')}
footer={
<>
<div className="mr-auto">
<MessageLine message={message} />
</div>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={saving || !dirty}
onClick={save}
>
{saving ? t('saving') : t('save')}
</button>
</>
}
>
<div className="space-y-3">
{rows.map((value, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Zeilen sind positionsgebunden und haben keine Kennung
<div key={index} className="flex items-end gap-2">
<div className="flex-1 space-y-1">
<label
htmlFor={`domains-ns-${index}`}
className="text-sm font-medium text-foreground"
>
{t('nameServers.label', { number: index + 1 })}
</label>
<input
id={`domains-ns-${index}`}
type="text"
autoComplete="off"
maxLength={253}
placeholder="ns1.example.com"
value={value}
onChange={(e) => setRows(rows.map((r, i) => (i === index ? e.target.value : r)))}
className={INPUT_CLASS}
/>
</div>
{rows.length > MIN_NAMESERVER_ROWS && (
<button
type="button"
className={SECONDARY_BUTTON}
onClick={() => setRows(rows.filter((_, i) => i !== index))}
>
{t('nameServers.remove')}
</button>
)}
</div>
))}
{rows.length < MAX_NAMESERVER_ROWS && (
<button type="button" className={SECONDARY_BUTTON} onClick={() => setRows([...rows, ''])}>
{t('nameServers.add')}
</button>
)}
</div>
</SettingsSection>
);
}
// --- Reiter ---------------------------------------------------------------------
/**
* Einstellungen des Moduls Domains (nur fuer Verwalter). Jede Karte speichert
* nur ihre eigenen Felder (Teil-PUT); nach jedem Speichern wird der Zustand
* der Seite (Kennzeichnung im Kopf) neu geladen.
*/
export function SettingsTab({ onChanged }: { onChanged: () => void }) {
const t = useTranslations('domains');
const [settings, setSettings] = useState<DomainsSettings | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getDomainsSettings()
.then((s) => {
if (!cancelled) setSettings(s);
})
.catch((error) => {
if (!cancelled) {
setLoadError(error instanceof DomainsRequestError ? error.message : t('errors.request'));
}
});
return () => {
cancelled = true;
};
}, [t]);
const handleSaved = (next: DomainsSettings) => {
setSettings(next);
onChanged();
};
if (loadError) {
return (
<p role="alert" className="text-sm text-destructive">
{loadError}
</p>
);
}
if (!settings) {
return <p className="text-sm text-muted-foreground">{t('settings.loading')}</p>;
}
return (
<div className="space-y-6">
<SystemCard settings={settings} onSaved={handleSaved} />
<AccessCard environment="DEMO" access={settings.demo} onSaved={handleSaved} />
<AccessCard environment="LIVE" access={settings.live} onSaved={handleSaved} />
<NameServersCard settings={settings} onSaved={handleSaved} />
</div>
);
}
@@ -0,0 +1,243 @@
import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DomainsSettings, DomainsStatus } from '@/lib/domains-api';
import de from '@/messages/de.json';
import DomainsPage from './page';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockGetStatus = vi.fn();
const mockGetSettings = vi.fn();
const mockSaveSettings = vi.fn();
const mockTestConnection = vi.fn();
vi.mock('@/lib/domains-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
return {
...actual,
getDomainsStatus: (...args: unknown[]) => mockGetStatus(...args),
getDomainsSettings: (...args: unknown[]) => mockGetSettings(...args),
saveDomainsSettings: (...args: unknown[]) => mockSaveSettings(...args),
testDomainsConnection: (...args: unknown[]) => mockTestConnection(...args),
};
});
let mockCanManage: boolean | null = true;
vi.mock('@/lib/use-module-capability', () => ({
useCanManageModule: () => mockCanManage,
}));
function status(over: Partial<DomainsStatus> = {}): DomainsStatus {
return {
environment: 'DEMO',
configured: true,
demoConfigured: true,
liveConfigured: false,
defaultNameServers: [],
...over,
};
}
function settings(over: Partial<DomainsSettings> = {}): DomainsSettings {
return {
environment: 'DEMO',
demo: { user: 'api-user', hasPassword: true, context: 1 },
live: { user: null, hasPassword: false, context: null },
defaultNameServers: [],
configured: { demo: true, live: false },
...over,
};
}
beforeEach(() => {
mockCanManage = true;
mockGetStatus.mockReset().mockResolvedValue(status());
mockGetSettings.mockReset().mockResolvedValue(settings());
mockSaveSettings.mockReset().mockImplementation(async () => settings());
mockTestConnection
.mockReset()
.mockResolvedValue({ ok: true, message: 'Verbindung erfolgreich.' });
});
afterEach(() => {
cleanup();
});
describe('Domains-Seite — Kopf und Reiter', () => {
it('Verwalter sieht den Reiter Einstellungen und die Demo-Kennzeichnung', async () => {
render(<DomainsPage />);
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
expect((await screen.findByTestId('domains-environment-badge')).textContent).toBe(
'Demo-System (Testbetrieb)',
);
});
it('Live-System zeigt die Kostenwarnung', async () => {
mockGetStatus.mockResolvedValue(status({ environment: 'LIVE', liveConfigured: true }));
render(<DomainsPage />);
await waitFor(() =>
expect(screen.getByTestId('domains-environment-badge').textContent).toBe(
'Live-System – Registrierungen kosten Geld',
),
);
});
it('nicht eingerichtet: Kennzeichnung und Hinweis', async () => {
mockGetStatus.mockResolvedValue(status({ configured: false, demoConfigured: false }));
render(<DomainsPage />);
await waitFor(() =>
expect(screen.getByTestId('domains-environment-badge').textContent).toBe(
'AutoDNS nicht eingerichtet',
),
);
expect(screen.getByText('AutoDNS ist noch nicht eingerichtet.')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Zu den Einstellungen' })).toBeTruthy();
});
it('nicht eingerichtet, ohne Verwalten: Hinweis auf einen Administrator, kein Einstellungen-Reiter', async () => {
mockCanManage = false;
mockGetStatus.mockResolvedValue(status({ configured: false, demoConfigured: false }));
render(<DomainsPage />);
expect(await screen.findByText(/Bitte wenden Sie sich an einen Administrator/)).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Zu den Einstellungen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
it('Benutzer ohne Verwalten sieht den Reiter Einstellungen nicht', async () => {
mockCanManage = false;
render(<DomainsPage />);
await screen.findByTestId('domains-environment-badge');
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
expect(mockGetSettings).not.toHaveBeenCalled();
});
});
describe('Einstellungen', () => {
it('Passwortfelder beginnen leer, mit Hinweis auf das gespeicherte Passwort', async () => {
render(<DomainsPage />);
const demoPassword = (await screen.findByLabelText('Passwort', {
selector: '#domains-demo-password',
})) as HTMLInputElement;
expect(demoPassword.value).toBe('');
expect(demoPassword.placeholder).toBe('Gespeichert – leer lassen, um es beizubehalten');
const livePassword = document.getElementById('domains-live-password') as HTMLInputElement;
expect(livePassword.placeholder).toBe('');
});
it('der Live-Kontext ist mit 4 vorbelegt, der Demo-Kontext zeigt den gespeicherten Wert', async () => {
render(<DomainsPage />);
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
expect((document.getElementById('domains-live-context') as HTMLInputElement).value).toBe('4');
expect((document.getElementById('domains-demo-context') as HTMLInputElement).value).toBe('1');
});
it('Wechsel auf Live verlangt Bestaetigung; erst die bestaetigte Speicherung sendet confirmLive', async () => {
mockSaveSettings.mockResolvedValue(settings({ environment: 'LIVE' }));
render(<DomainsPage />);
const liveRadio = await screen.findByRole('radio', { name: /Live-System \(kostenpflichtig\)/ });
fireEvent.click(liveRadio);
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText(/kosten Geld/)).toBeTruthy();
expect(mockSaveSettings).not.toHaveBeenCalled();
// Abbrechen sendet nichts und stellt die Auswahl zurueck.
fireEvent.click(within(dialog).getByRole('button', { name: 'Abbrechen' }));
expect(mockSaveSettings).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).toBeNull();
expect(
(screen.getByRole('radio', { name: /Demo-System \(Testbetrieb\)/ }) as HTMLInputElement)
.checked,
).toBe(true);
fireEvent.click(screen.getByRole('radio', { name: /Live-System \(kostenpflichtig\)/ }));
fireEvent.click(
within(await screen.findByRole('alertdialog')).getByRole('button', {
name: 'Live-System verwenden',
}),
);
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
expect(mockSaveSettings).toHaveBeenCalledWith({ environment: 'LIVE', confirmLive: true });
});
it('Speichern des Zugangs sendet nur die eigenen Felder; ohne Eingabe kein Passwort', async () => {
render(<DomainsPage />);
const user = (await screen.findByLabelText('Benutzername', {
selector: '#domains-demo-user',
})) as HTMLInputElement;
fireEvent.change(user, { target: { value: 'neuer-user' } });
const demoCard = document.getElementById('domains-demo') as HTMLElement;
fireEvent.click(within(demoCard).getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
expect(mockSaveSettings).toHaveBeenCalledWith({ demoUser: 'neuer-user', demoContext: 1 });
});
it('Verbindung testen: ein Aufruf je Klick, gesperrt waehrend des Laufs, zeigt das Ergebnis', async () => {
let resolveTest: (v: { ok: boolean; message: string }) => void = () => undefined;
mockTestConnection.mockImplementation(
() =>
new Promise((resolve) => {
resolveTest = resolve;
}),
);
render(<DomainsPage />);
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
const demoCard = document.getElementById('domains-demo') as HTMLElement;
const button = within(demoCard).getByRole('button', { name: 'Verbindung testen' });
fireEvent.click(button);
await waitFor(() => expect(mockTestConnection).toHaveBeenCalledTimes(1));
expect(mockTestConnection).toHaveBeenCalledWith('DEMO');
const running = within(demoCard).getByRole('button', { name: 'Verbindung wird getestet …' });
expect((running as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(running);
expect(mockTestConnection).toHaveBeenCalledTimes(1);
resolveTest({ ok: false, message: 'Anmeldung bei AutoDNS fehlgeschlagen.' });
expect(await within(demoCard).findByText('Anmeldung bei AutoDNS fehlgeschlagen.')).toBeTruthy();
});
it('der Verbindungstest ist bei ungespeicherten Aenderungen gesperrt', async () => {
render(<DomainsPage />);
const password = (await screen.findByLabelText('Passwort', {
selector: '#domains-demo-password',
})) as HTMLInputElement;
fireEvent.change(password, { target: { value: 'neu' } });
const demoCard = document.getElementById('domains-demo') as HTMLElement;
expect(
(within(demoCard).getByRole('button', { name: 'Verbindung testen' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(within(demoCard).getByText('Bitte zuerst speichern')).toBeTruthy();
});
it('Nameserver: speichert die bereinigte Liste', async () => {
render(<DomainsPage />);
const first = (await screen.findByLabelText('Nameserver 1')) as HTMLInputElement;
fireEvent.change(first, { target: { value: ' NS1.Example.com ' } });
fireEvent.change(screen.getByLabelText('Nameserver 2'), {
target: { value: 'ns2.example.com' },
});
const card = document.getElementById('domains-nameservers') as HTMLElement;
fireEvent.click(within(card).getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
expect(mockSaveSettings).toHaveBeenCalledWith({
defaultNameServers: ['ns1.example.com', 'ns2.example.com'],
});
});
});
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react';
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
export default function DomainsLayout({ children }: { children: ReactNode }) {
return <ModuleAccessGate moduleSlug="domains">{children}</ModuleAccessGate>;
}
@@ -0,0 +1,84 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { TabBar } from '@/components/accounting/tab-bar';
import { SettingsSection } from '@/components/control-center/settings-section';
import { PageHeader } from '@/components/layout/page-header';
import { type DomainsStatus, getDomainsStatus } from '@/lib/domains-api';
import { useCanManageModule } from '@/lib/use-module-capability';
import { EnvironmentBadge } from './components/EnvironmentBadge';
import { SettingsTab } from './components/SettingsTab';
type TabId = 'settings';
/**
* Domains (quick-261008-dts): Domains bei AutoDNS registrieren, Kontakte und
* Kunden zuordnen. Der Seitenkopf traegt dauerhaft die Kennzeichnung des
* aktiven Systems (Demo/Live). Der Reiter "Einstellungen" erscheint nur fuer
* Administratoren und Benutzer mit der Freigabestufe Verwalten — bindend ist
* allein die API.
*/
export default function DomainsPage() {
const t = useTranslations('domains');
const canManage = useCanManageModule('domains') === true;
const [status, setStatus] = useState<DomainsStatus | null>(null);
const [statusError, setStatusError] = useState(false);
const [tab, setTab] = useState<TabId>('settings');
const reloadStatus = useCallback(async () => {
try {
setStatus(await getDomainsStatus());
setStatusError(false);
} catch {
setStatusError(true);
}
}, []);
useEffect(() => {
void reloadStatus();
}, [reloadStatus]);
const tabs: { id: TabId; label: string }[] = [];
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tabs.some((x) => x.id === tab) ? tab : tabs[0]?.id;
return (
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<PageHeader
moduleSlug="domains"
title={t('title')}
description={t('description')}
actions={<EnvironmentBadge status={status} />}
/>
{statusError && (
<p role="alert" className="text-sm text-destructive">
{t('errors.loadStatus')}
</p>
)}
{status && !status.configured && (
<SettingsSection title={t('notConfigured.title')}>
<p className="text-sm text-muted-foreground">
{canManage ? t('notConfigured.managerHint') : t('notConfigured.userHint')}
</p>
{canManage && (
<button
type="button"
onClick={() => setTab('settings')}
className="mt-3 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
>
{t('notConfigured.goToSettings')}
</button>
)}
</SettingsSection>
)}
{tabs.length > 0 && activeTab && (
<>
<TabBar tabs={tabs} active={activeTab} onChange={setTab} />
{activeTab === 'settings' && <SettingsTab onChanged={reloadStatus} />}
</>
)}
</div>
);
}
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
import CertManagerLayout from './cert-manager/layout';
import DkvFleetLayout from './dkv-fleet/layout';
import DomaincheckLayout from './domaincheck/layout';
import DomainsLayout from './domains/layout';
import HandelswareDatevLayout from './handelsware-datev/layout';
import KantineDatevLayout from './kantine-datev/layout';
import NextcloudStatusLayout from './nextcloud-status/layout';
@@ -44,6 +45,7 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)',
['kantine-datev', KantineDatevLayout],
['handelsware-datev', HandelswareDatevLayout],
['nextcloud-status', NextcloudStatusLayout],
['domains', DomainsLayout],
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
const element = Layout({ children: placeholderChild });
@@ -58,6 +58,14 @@ const GLYPHS: Record<ModuleIconId, ReactNode> = {
</>
),
cloud: <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />,
earth: (
<>
<circle cx="12" cy="12" r="10" />
<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54" />
<path d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17" />
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" />
</>
),
tile: (
<>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
+124
View File
@@ -0,0 +1,124 @@
/**
* Domains — API-Client (quick-261008-dts). Konsumiert `/modules/domains/*`.
* `credentials: 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis
* (Muster `nextcloud-status-api.ts`). Der Browser spricht nie mit AutoDNS;
* Zugangsdaten kommen hier nie zurueck (nur `hasPassword`).
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const BASE = '/modules/domains';
export type DomainsEnvironment = 'DEMO' | 'LIVE';
export interface DomainsStatus {
environment: DomainsEnvironment;
/** Ist das AKTIVE System eingerichtet? */
configured: boolean;
demoConfigured: boolean;
liveConfigured: boolean;
defaultNameServers: string[];
}
export interface DomainsEnvironmentAccess {
user: string | null;
hasPassword: boolean;
context: number | null;
}
export interface DomainsSettings {
environment: DomainsEnvironment;
demo: DomainsEnvironmentAccess;
live: DomainsEnvironmentAccess;
defaultNameServers: string[];
configured: { demo: boolean; live: boolean };
}
/** Teilspeichern: nur mitgeschickte Felder aendern sich; leeres Passwort = unveraendert. */
export interface SaveDomainsSettingsInput {
environment?: DomainsEnvironment;
confirmLive?: boolean;
demoUser?: string;
demoPassword?: string;
demoContext?: number | null;
liveUser?: string;
livePassword?: string;
liveContext?: number | null;
defaultNameServers?: string[];
}
export interface ConnectionTestResult {
ok: boolean;
kind?: string;
message: string;
}
/** Fehler mit HTTP-Status, maschinenlesbarer Kennung und deutscher Servermeldung. */
export class DomainsRequestError extends Error {
constructor(
readonly status: number,
readonly code: string | null,
message: string,
) {
super(message);
this.name = 'DomainsRequestError';
}
}
async function failure(res: Response): Promise<DomainsRequestError> {
let message = `Request failed (${res.status})`;
let code: string | null = null;
try {
const body = await res.json();
const raw = body?.message;
if (Array.isArray(raw)) message = raw.join(' ');
else if (typeof raw === 'string') message = raw;
if (typeof body?.code === 'string') code = body.code;
} catch {
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
}
return new DomainsRequestError(res.status, code, message);
}
async function request<T>(
path: string,
init: { method?: string; json?: unknown } = {},
): Promise<T> {
const method = init.method ?? 'GET';
const headers: Record<string, string> = {};
let body: string | undefined;
if (init.json !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(init.json);
}
const res = await fetch(`${API_URL}${BASE}${path}`, {
method,
credentials: 'include',
headers,
body,
...(method === 'GET' ? { cache: 'no-store' as const } : {}),
});
if (!res.ok) throw await failure(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export function getDomainsStatus(): Promise<DomainsStatus> {
return request<DomainsStatus>('/status');
}
export function getDomainsSettings(): Promise<DomainsSettings> {
return request<DomainsSettings>('/settings');
}
export function saveDomainsSettings(input: SaveDomainsSettingsInput): Promise<DomainsSettings> {
return request<DomainsSettings>('/settings', { method: 'PUT', json: input });
}
export function testDomainsConnection(
environment: DomainsEnvironment,
): Promise<ConnectionTestResult> {
return request<ConnectionTestResult>('/connection-test', {
method: 'POST',
json: { environment },
});
}
+2 -1
View File
@@ -7,7 +7,7 @@
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`).
*/
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'tile';
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'earth' | 'tile';
const ICONS: Record<string, ModuleIconId> = {
'tender-radar': 'radar',
@@ -18,6 +18,7 @@ const ICONS: Record<string, ModuleIconId> = {
'nextcloud-status': 'cloud',
'kantine-datev': 'utensils',
'handelsware-datev': 'shopping-bag',
domains: 'earth',
};
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */
+6
View File
@@ -77,6 +77,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
{ ssr: false },
),
},
domains: {
component: dynamic(
() => import('@/app/(portal)/modules/domains/page'),
{ ssr: false },
),
},
};
/**
+1
View File
@@ -32,6 +32,7 @@ const MODULE_TITLE_KEYS: Record<string, string> = {
'tender-radar': 'tenderRadar.page.title',
'kantine-datev': 'kantineDatev.title',
'handelsware-datev': 'handelswareDatev.title',
domains: 'domains.title',
};
/**
+60
View File
@@ -2048,5 +2048,65 @@
"calendar": {
"sourcesTitle": "Kalenderquellen"
}
},
"domains": {
"title": "Domains",
"description": "Domains bei AutoDNS registrieren, Kontakte und Kunden zuordnen",
"environment": {
"demo": "Demo-System (Testbetrieb)",
"live": "Live-System – Registrierungen kosten Geld",
"notConfigured": "AutoDNS nicht eingerichtet"
},
"tabs": {
"settings": "Einstellungen"
},
"notConfigured": {
"title": "AutoDNS ist noch nicht eingerichtet.",
"managerHint": "Hinterlegen Sie Benutzername, Passwort und Kontext des gewählten Systems in den Einstellungen.",
"goToSettings": "Zu den Einstellungen",
"userHint": "Bitte wenden Sie sich an einen Administrator oder an jemanden mit der Freigabestufe Verwalten."
},
"settings": {
"loading": "Einstellungen werden geladen …",
"save": "Speichern",
"saving": "Speichern …",
"saved": "Gespeichert.",
"system": {
"title": "System",
"description": "Legen Sie fest, ob Tessera mit dem Testsystem oder mit dem echten System von AutoDNS arbeitet.",
"demo": "Demo-System (Testbetrieb)",
"demoHelp": "Zum Ausprobieren. Hier entstehen keine echten Registrierungen.",
"live": "Live-System (kostenpflichtig)",
"liveHelp": "Registrierungen sind verbindlich und kosten Geld.",
"confirmText": "Ab jetzt laufen Registrierungen über das Live-System von AutoDNS und kosten Geld.",
"confirm": "Live-System verwenden",
"cancel": "Abbrechen"
},
"access": {
"demoTitle": "Zugang Demo-System",
"liveTitle": "Zugang Live-System",
"description": "Diese Angaben erhalten Sie von AutoDNS. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt.",
"user": "Benutzername",
"password": "Passwort",
"passwordStored": "Gespeichert – leer lassen, um es beizubehalten",
"context": "Kontext",
"contextHelp": "Verwenden Sie einen eigenen AutoDNS-Benutzer für Tessera ohne Zwei-Faktor-Anmeldung.",
"contextInvalid": "Der Kontext muss eine ganze Zahl größer als 0 sein.",
"test": "Verbindung testen",
"testing": "Verbindung wird getestet …",
"saveFirst": "Bitte zuerst speichern"
},
"nameServers": {
"title": "Standard-Nameserver",
"description": "Diese Nameserver werden bei jeder Registrierung vorausgefüllt. Sie müssen bei Ihrem Anbieter bereits eingerichtet sein, sonst scheitert die Registrierung.",
"label": "Nameserver {number}",
"add": "Nameserver hinzufügen",
"remove": "Entfernen"
}
},
"errors": {
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
"loadStatus": "Der Zustand der AutoDNS-Anbindung konnte nicht geladen werden."
}
}
}
+60
View File
@@ -2048,5 +2048,65 @@
"calendar": {
"sourcesTitle": "Calendar sources"
}
},
"domains": {
"title": "Domains",
"description": "Register domains with AutoDNS, assign contacts and customers",
"environment": {
"demo": "Demo system (test mode)",
"live": "Live system – registrations cost money",
"notConfigured": "AutoDNS not set up"
},
"tabs": {
"settings": "Settings"
},
"notConfigured": {
"title": "AutoDNS is not set up yet.",
"managerHint": "Enter the user name, password and context of the selected system in the settings.",
"goToSettings": "Go to settings",
"userHint": "Please ask an administrator or someone with the Manage permission level."
},
"settings": {
"loading": "Loading settings …",
"save": "Save",
"saving": "Saving …",
"saved": "Saved.",
"system": {
"title": "System",
"description": "Choose whether Tessera works with the AutoDNS test system or with the real AutoDNS system.",
"demo": "Demo system (test mode)",
"demoHelp": "For trying things out. No real registrations are made here.",
"live": "Live system (billable)",
"liveHelp": "Registrations are binding and cost money.",
"confirmText": "From now on, registrations run through the AutoDNS live system and cost money.",
"confirm": "Use live system",
"cancel": "Cancel"
},
"access": {
"demoTitle": "Demo system access",
"liveTitle": "Live system access",
"description": "You receive these details from AutoDNS. The password is stored encrypted and is never shown again.",
"user": "User name",
"password": "Password",
"passwordStored": "Stored – leave empty to keep it",
"context": "Context",
"contextHelp": "Use a dedicated AutoDNS user for Tessera without two-factor sign-in.",
"contextInvalid": "The context must be a whole number greater than 0.",
"test": "Test connection",
"testing": "Testing connection …",
"saveFirst": "Please save first"
},
"nameServers": {
"title": "Default name servers",
"description": "These name servers are prefilled for every registration. They must already be set up with your provider, otherwise the registration fails.",
"label": "Name server {number}",
"add": "Add name server",
"remove": "Remove"
}
},
"errors": {
"request": "The request failed. Please try again.",
"loadStatus": "The state of the AutoDNS connection could not be loaded."
}
}
}
@@ -103,6 +103,8 @@ export const UMLAUT_REPLACEMENTS: Record<string, string> = {
* and must never be touched by the replacement or flagged by the guard.
*/
export const UMLAUT_ALLOWLIST: readonly string[] = [
// quick-261008-dts: Modul Domains (korrektes Deutsch)
'verschlüsselt',
// quick-261003-387: Kategorienverwaltung (korrektes Deutsch)
'Neuer',
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
File diff suppressed because one or more lines are too long