feat(domains): Modul Domains mit AutoDNS-Zugang, Verbindungstest und Einstellungen
- Migration mit vier Tabellen (Einstellungen, Kunden, Kontaktzuordnung, Bestellungen), Zeilenschutz je Organisation - AutoDNS-Client mit festen Demo-/Live-Adressen, Takt-Begrenzer, 20 s Zeitlimit, ohne Wiederholung - Zugang je System verschluesselt gespeichert, Live-Wechsel nur mit Bestaetigung, Verbindungstest - Modulseite mit Systemkennzeichnung und Reiter Einstellungen (nur Verwalten) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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());
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -77,6 +77,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
domains: {
|
||||
component: dynamic(
|
||||
() => import('@/app/(portal)/modules/domains/page'),
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user