diff --git a/apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql b/apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql new file mode 100644 index 0000000..87f2c1e --- /dev/null +++ b/apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql @@ -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()); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b761698..4094a71 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a683578..fc5558b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/domains/autodns-client.spec.ts b/apps/api/src/domains/autodns-client.spec.ts new file mode 100644 index 0000000..669cb9f --- /dev/null +++ b/apps/api/src/domains/autodns-client.spec.ts @@ -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 = {}) { + 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; +} + +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).mock.calls[0] as [ + string, + Record, + ]; + 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; + 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).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).mock.calls[0] as [ + string, + Record, + ]; + expect((init.headers as Record)['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).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, 'Bad Gateway')).toMatchObject({ + ok: false, + kind: 'http', + }); + expect(parseAutodnsEnvelope(401, '')).toMatchObject({ ok: false, kind: 'auth' }); + }); + + it('HTML oder leerer 200-Rumpf -> invalid-response', () => { + expect(parseAutodnsEnvelope(200, 'Login')).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; + 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; + 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'); + }); +}); diff --git a/apps/api/src/domains/autodns-client.ts b/apps/api/src/domains/autodns-client.ts new file mode 100644 index 0000000..b510da6 --- /dev/null +++ b/apps/api/src/domains/autodns-client.ts @@ -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 { + 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 { + const basic = Buffer.from(`${credentials.user}:${credentials.password}`, 'utf8').toString( + 'base64', + ); + const headers: Record = { + 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 = Promise.resolve(); + + constructor( + private readonly minIntervalMs: number = AUTODNS_MIN_INTERVAL_MS, + private readonly now: () => number = () => Date.now(), + private readonly sleep: (ms: number) => Promise = (ms) => + new Promise((resolve) => setTimeout(resolve, ms)), + ) {} + + schedule(task: () => Promise): Promise { + 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; + }; + } | null; + text(): Promise; +} + +export type AutodnsFetch = ( + url: string, + init: { + method: string; + headers: Record; + body?: string; + signal: AbortSignal; + redirect: 'error'; + }, +) => Promise; + +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 { + 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 { + 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 => { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new AutodnsTimeoutError()); + }, timeoutMs); + }); + const exchange = async (): Promise => { + 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); +} diff --git a/apps/api/src/domains/domains-settings.service.spec.ts b/apps/api/src/domains/domains-settings.service.spec.ts new file mode 100644 index 0000000..a5f9c48 --- /dev/null +++ b/apps/api/src/domains/domains-settings.service.spec.ts @@ -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 { + 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; +} + +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).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'); + }); +}); diff --git a/apps/api/src/domains/domains-settings.service.ts b/apps/api/src/domains/domains-settings.service.ts new file mode 100644 index 0000000..e508c3b --- /dev/null +++ b/apps/api/src/domains/domains-settings.service.ts @@ -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 { + 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 { + return this.toSettingsView(await this.loadRow(tenantId)); + } + + async getStatus(tenantId: string): Promise { + 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 { + 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 = {}; + 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 { + 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 { + 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 { + 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.', + }; + } + } + } +} diff --git a/apps/api/src/domains/domains.controller.spec.ts b/apps/api/src/domains/domains.controller.spec.ts new file mode 100644 index 0000000..5529d7b --- /dev/null +++ b/apps/api/src/domains/domains.controller.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/domains/domains.controller.ts b/apps/api/src/domains/domains.controller.ts new file mode 100644 index 0000000..7fd4665 --- /dev/null +++ b/apps/api/src/domains/domains.controller.ts @@ -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); + } +} diff --git a/apps/api/src/domains/domains.module.ts b/apps/api/src/domains/domains.module.ts new file mode 100644 index 0000000..aef55c8 --- /dev/null +++ b/apps/api/src/domains/domains.module.ts @@ -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 { + try { + await seedDomainsModule(this.moduleRegistryService); + this.logger.log('Domains module seeded in registry'); + } catch (error) { + this.logger.error('Failed to seed domains module', error); + } + } +} diff --git a/apps/api/src/domains/domains.seed.ts b/apps/api/src/domains/domains.seed.ts new file mode 100644 index 0000000..4398e92 --- /dev/null +++ b/apps/api/src/domains/domains.seed.ts @@ -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 { + 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, + }); +} diff --git a/apps/api/src/domains/domains.types.ts b/apps/api/src/domains/domains.types.ts new file mode 100644 index 0000000..06128de --- /dev/null +++ b/apps/api/src/domains/domains.types.ts @@ -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.', + }); + } + } +} diff --git a/apps/api/src/domains/dto/domains-settings.dto.ts b/apps/api/src/domains/dto/domains-settings.dto.ts new file mode 100644 index 0000000..cf8ad5d --- /dev/null +++ b/apps/api/src/domains/dto/domains-settings.dto.ts @@ -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'; +} diff --git a/apps/api/src/module-registry/module-manage-handlers.spec.ts b/apps/api/src/module-registry/module-manage-handlers.spec.ts index 87d1463..48d6800 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -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'); diff --git a/apps/web/src/app/(portal)/modules/domains/components/EnvironmentBadge.tsx b/apps/web/src/app/(portal)/modules/domains/components/EnvironmentBadge.tsx new file mode 100644 index 0000000..2ec065b --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/components/EnvironmentBadge.tsx @@ -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 ( + + {label} + + ); +} diff --git a/apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx b/apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx new file mode 100644 index 0000000..d9a6426 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx @@ -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 ( +

+ {message.text} +

+ ); +} + +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(settings.environment); + const [confirmingLive, setConfirmingLive] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(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 ( + + + + + } + > +
+ {(['DEMO', 'LIVE'] as const).map((env) => ( + + ))} +
+ {confirmingLive && ( +
+

{t('system.confirmText')}

+
+ + +
+
+ )} +
+ ); +} + +// --- 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(null); + const [testResult, setTestResult] = useState(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 ( + +
+ + {testResult && ( +

+ {testResult.message} +

+ )} + {dirty &&

{t('access.saveFirst')}

} +
+ + + + } + > +
+
+ + setUser(e.target.value)} + className={INPUT_CLASS} + /> +
+
+ + setPassword(e.target.value)} + className={INPUT_CLASS} + /> +
+
+ + setContext(e.target.value)} + className={INPUT_CLASS} + /> + {contextValue === 'invalid' && ( +

{t('access.contextInvalid')}

+ )} +
+
+

{t('access.contextHelp')}

+
+ ); +} + +// --- 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(padNameServers(settings.defaultNameServers)); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(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 ( + +
+ +
+ + + } + > +
+ {rows.map((value, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Zeilen sind positionsgebunden und haben keine Kennung +
+
+ + setRows(rows.map((r, i) => (i === index ? e.target.value : r)))} + className={INPUT_CLASS} + /> +
+ {rows.length > MIN_NAMESERVER_ROWS && ( + + )} +
+ ))} + {rows.length < MAX_NAMESERVER_ROWS && ( + + )} +
+
+ ); +} + +// --- 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(null); + const [loadError, setLoadError] = useState(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 ( +

+ {loadError} +

+ ); + } + if (!settings) { + return

{t('settings.loading')}

; + } + + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx b/apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx new file mode 100644 index 0000000..cb72680 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx @@ -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( + + {ui} + , + ); +} + +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(); + 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 { + return { + environment: 'DEMO', + configured: true, + demoConfigured: true, + liveConfigured: false, + defaultNameServers: [], + ...over, + }; +} + +function settings(over: Partial = {}): 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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'], + }); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/domains/layout.tsx b/apps/web/src/app/(portal)/modules/domains/layout.tsx new file mode 100644 index 0000000..95588d9 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/layout.tsx @@ -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 {children}; +} diff --git a/apps/web/src/app/(portal)/modules/domains/page.tsx b/apps/web/src/app/(portal)/modules/domains/page.tsx new file mode 100644 index 0000000..a8878df --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/page.tsx @@ -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(null); + const [statusError, setStatusError] = useState(false); + const [tab, setTab] = useState('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 ( +
+ } + /> + {statusError && ( +

+ {t('errors.loadStatus')} +

+ )} + {status && !status.configured && ( + +

+ {canManage ? t('notConfigured.managerHint') : t('notConfigured.userHint')} +

+ {canManage && ( + + )} +
+ )} + {tabs.length > 0 && activeTab && ( + <> + + {activeTab === 'settings' && } + + )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/module-layouts.test.tsx b/apps/web/src/app/(portal)/modules/module-layouts.test.tsx index 4fe93ff..47d31f7 100644 --- a/apps/web/src/app/(portal)/modules/module-layouts.test.tsx +++ b/apps/web/src/app/(portal)/modules/module-layouts.test.tsx @@ -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 }); diff --git a/apps/web/src/components/modules/module-tile.tsx b/apps/web/src/components/modules/module-tile.tsx index 6b8d56c..8f7fdb1 100644 --- a/apps/web/src/components/modules/module-tile.tsx +++ b/apps/web/src/components/modules/module-tile.tsx @@ -58,6 +58,14 @@ const GLYPHS: Record = { ), cloud: , + earth: ( + <> + + + + + + ), tile: ( <> diff --git a/apps/web/src/lib/domains-api.ts b/apps/web/src/lib/domains-api.ts new file mode 100644 index 0000000..9696b2b --- /dev/null +++ b/apps/web/src/lib/domains-api.ts @@ -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 { + 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( + path: string, + init: { method?: string; json?: unknown } = {}, +): Promise { + const method = init.method ?? 'GET'; + const headers: Record = {}; + 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 { + return request('/status'); +} + +export function getDomainsSettings(): Promise { + return request('/settings'); +} + +export function saveDomainsSettings(input: SaveDomainsSettingsInput): Promise { + return request('/settings', { method: 'PUT', json: input }); +} + +export function testDomainsConnection( + environment: DomainsEnvironment, +): Promise { + return request('/connection-test', { + method: 'POST', + json: { environment }, + }); +} diff --git a/apps/web/src/lib/module-identity.ts b/apps/web/src/lib/module-identity.ts index 52edf7e..5216428 100644 --- a/apps/web/src/lib/module-identity.ts +++ b/apps/web/src/lib/module-identity.ts @@ -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 = { 'tender-radar': 'radar', @@ -18,6 +18,7 @@ const ICONS: Record = { 'nextcloud-status': 'cloud', 'kantine-datev': 'utensils', 'handelsware-datev': 'shopping-bag', + domains: 'earth', }; /** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */ diff --git a/apps/web/src/lib/module-loader.ts b/apps/web/src/lib/module-loader.ts index 9d7025d..357d9bc 100644 --- a/apps/web/src/lib/module-loader.ts +++ b/apps/web/src/lib/module-loader.ts @@ -77,6 +77,12 @@ export const MODULE_REGISTRY: Record = { { ssr: false }, ), }, + domains: { + component: dynamic( + () => import('@/app/(portal)/modules/domains/page'), + { ssr: false }, + ), + }, }; /** diff --git a/apps/web/src/lib/stores/nav-store.ts b/apps/web/src/lib/stores/nav-store.ts index f0fcc26..b2deb65 100644 --- a/apps/web/src/lib/stores/nav-store.ts +++ b/apps/web/src/lib/stores/nav-store.ts @@ -32,6 +32,7 @@ const MODULE_TITLE_KEYS: Record = { 'tender-radar': 'tenderRadar.page.title', 'kantine-datev': 'kantineDatev.title', 'handelsware-datev': 'handelswareDatev.title', + domains: 'domains.title', }; /** diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 9bb6ab2..a738ecd 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -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." + } } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index eacb772..cdfb289 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -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." + } } } diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index 570a7d3..47eb4f9 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -103,6 +103,8 @@ export const UMLAUT_REPLACEMENTS: Record = { * 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) diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index b8dbabe..a36509a 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -181,8 +181,9 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | reminders | 0 | 12 | 1 | **quick-260929-if2 (Aufgabe 3):** nachgemessen mit der Gate-Schleife: 0/12/1 — +5 gebunden, +1 System. `reminders.service.ts` +1 gebunden (`getEmailAvailability`: `user.findFirst` für die eigene E-Mail-Adresse, an Mandant und Benutzer gebunden). NEU `reminder-mail.scheduler.ts`: +4 gebunden je Kandidatenzeile (`reminder.updateMany` als Anspruch, `reminder.findFirst`, `user.findFirst` für die Adresse des Besitzers, `reminder.updateMany` als Freigabe bei Transportfehler; alle über `forTenant(prisma, c.tenantId)` ohne Benutzer) und +1 System (`systemPrisma.reminder.findMany`, die Kandidatenabfrage über alle Mandanten, nur skalarer Select). Vorher: **quick-260929-if2 (Aufgabe 2):** nachgemessen mit der Gate-Schleife: 0/7/0 — +4 gebunden: `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst`, ein Treffer für alle drei; fremde und unbekannte Kennungen sind dort ununterscheidbar 404, D-05). Vorher: **quick-260929-if2 (Aufgabe 1, Tracer):** neu, drei gebundene Rohtreffer in `reminders.service.ts`, nachgemessen mit der Gate-Schleife: 0/3/0 — `list` (`findMany`), `create` (`count` fuer die Grenze von 100 und `create`). Persönliche Erinnerungen je Benutzer, jede Methode bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`). Kein ungebundener Zugriff, kein Systemkontext in diesem Bereich (der E-Mail-Planer folgt in Aufgabe 3). | | kantine-datev | 0 | 2 | 0 | **quick-261002-fm5:** neu, zwei gebundene Rohtreffer in `kantine-datev.service.ts` (`getSettings` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. | | handelsware-datev | 0 | 8 | 0 | **quick-261002-fm5:** neu, acht gebundene Rohtreffer über `tenantPrisma` in `handelsware-datev.service.ts` (`handelswareDatevConfig` 2, `handelswareKonto` 6), nachgemessen mit der Gate-Schleife: 0/8/0. Dazu fünf Zugriffe über den Transaktionsparameter `tx` von `withTenantTransaction` (`handelswareDatevConfig` 1, `handelswareKonto` 4), die diese einfache Rohtrefferzählung strukturell nicht sieht (siehe Hinweis zu `groups` oben) — die Bestandsaufnahme unten führt sie. Kein ungebundener Zugriff, kein Systemkontext. | +| domains | 0 | 2 | 0 | **quick-261008-dts (Aufgabe 1):** neu, zwei gebundene Rohtreffer über `tenantPrisma` in `domains-settings.service.ts` (`domainsConfig`: `loadRow` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. Die weiteren Dienste des Moduls (Kunden und Kontakte, Bestellungen) tragen ihre Zeilen, sobald sie gebaut sind. | | nextcloud-status | 0 | 23 | 1 | **quick-261002-kxc (Aufgabe 3, Endstand):** 21→23 gebunden — `listRecentAlerts` in `nextcloud-alert.service.ts` liest die Abonnements des Aufrufers (`nextcloudAlertSubscription.findMany`, Klient mit Mandant UND Benutzer) und dazu die letzten Übergänge dieser Clouds (`nextcloudInstance.findMany`, nur skalare Felder, `where: { tenantId, id: { in } }`); System unverändert 1. Nachgemessen mit der Gate-Schleife über `nextcloud-status/`: 0/23/1. Vorher: **quick-261002-kxc (Aufgabe 1):** 14→21 gebunden, System unverändert 1 — NEU `nextcloud-alert.service.ts` mit sieben gebundenen Rohtreffern (`subscribe`: `nextcloudInstance.findFirst`, `nextcloudAlertSubscription.upsert`; `unsubscribe`: `deleteMany`; `subscribedInstanceIds`: `findMany`; `evaluateAfterCheck`: `nextcloudInstance.updateMany`; `notifySubscribers`: `nextcloudAlertSubscription.findMany`, `user.findMany`), je Methode ein Klient; `nextcloud-status.service.ts` unverändert bei 14. Nachgemessen mit der Gate-Schleife über `nextcloud-status/`: 0/21/1. Vorher: **quick-261002-k67 (Aufgabe 2, Endstand):** 4→14 gebunden, 0→1 System — Schreibwege und Planer: `updateInstance` (`findFirst`, `update`), `deleteInstance` (`findFirst`, `delete`), `uploadLogo` (`findFirst`, `update`), `getLogo` (`findFirst`), `removeLogo` (`findFirst`, `update`), `listInstanceIdsForTenant` (`findMany`) bringen zehn weitere gebundene Rohtreffer, je Methode ein Klient. Der eine System-Rohtreffer ist `loadAllInstancesForScheduler()` (`systemPrisma.nextcloudInstance.findMany`, nur `select: { id, tenantId }`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Nachgemessen mit der Gate-Schleife: 0/14/1. Vorher: **quick-261002-k67 (Aufgabe 1):** neu, vier gebundene Rohtreffer in `nextcloud-status.service.ts` (`listForTenant` `findMany`, `createInstance` `create`, `checkInstance` `findFirst` und `update`). | -| **Summe** | **65** | **295** | **8** | **quick-261003-387:** Ungebunden +4 und Gebunden +22 = `module-categories` (neu, siehe dortige Zeile), System unverändert: 65/295/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor, die Differenz zur vorigen Messung ist genau +4/+22. Vorher: **quick-261002-kxc (Aufgabe 3):** Gebunden +2 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/273/8; die Gate-Schleife über alle Bereiche ergibt jetzt 61/280/8, die Differenz von 7 ist die unten beschriebene ältere Abweichung. Vorher: **quick-261002-kxc (Aufgabe 1):** Gebunden +7 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/271/8. Hinweis zur Messung: die Gate-Schleife über ALLE Bereiche ergibt heute 61/278/8 — die Mehrzahl von 7 gegenüber 271 stammt nicht aus diesem Plan, sondern aus Zeilen, die ihre Werte seit längerem nicht nachgeführt haben (gemessen: `dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, dazu Bereiche ohne eigene Zeile); sie wurden hier bewusst nicht angefasst. Vorher: **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| **Summe** | **65** | **297** | **8** | **quick-261008-dts (Aufgabe 1):** Gebunden +2 = `domains` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 65/297/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261003-387:** Ungebunden +4 und Gebunden +22 = `module-categories` (neu, siehe dortige Zeile), System unverändert: 65/295/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor, die Differenz zur vorigen Messung ist genau +4/+22. Vorher: **quick-261002-kxc (Aufgabe 3):** Gebunden +2 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/273/8; die Gate-Schleife über alle Bereiche ergibt jetzt 61/280/8, die Differenz von 7 ist die unten beschriebene ältere Abweichung. Vorher: **quick-261002-kxc (Aufgabe 1):** Gebunden +7 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/271/8. Hinweis zur Messung: die Gate-Schleife über ALLE Bereiche ergibt heute 61/278/8 — die Mehrzahl von 7 gegenüber 271 stammt nicht aus diesem Plan, sondern aus Zeilen, die ihre Werte seit längerem nicht nachgeführt haben (gemessen: `dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, dazu Bereiche ohne eigene Zeile); sie wurden hier bewusst nicht angefasst. Vorher: **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 89 Paare) @@ -416,6 +417,10 @@ und `/customModule`, alle `gebunden`) und +1 `keine-mandantengebundene-tabelle` 23 `keine-mandantengebundene-tabelle`, 16 `beides`, 2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. +quick-261008-dts (Aufgabe 1): +1 `muss-mandantengebunden` (`domains-settings.service.ts`/`domainsConfig`, `gebunden`): +101 Paare, davon 60 `muss-mandantengebunden`, 23 `keine-mandantengebundene-tabelle`, 16 `beides`, +2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. + quick-261002-kxc (Aufgabe 3): Paarzahl unverändert (96); `listRecentAlerts` nutzt dieselben Paare `nextcloud-alert.service.ts`/`nextcloudAlertSubscription` und `/nextcloudInstance`. @@ -881,6 +886,7 @@ werden. | apps/api/src/kantine-datev/kantine-datev.service.ts | kantineDatevConfig | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — die drei Nummern der Kantinenabrechnung (Beraternummer, Mandantennummer, Lohnart), eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002120000) — Einstellungen des Mandanten, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der diese Einstellungen über alle Mandanten liest. Zwei mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `getSettings` (`findUnique`), `saveSettings` (`upsert`). Die hochgeladene Kantinen-CSV (Namen, Personalnummern) berührt die Datenbank nie. | | apps/api/src/handelsware-datev/handelsware-datev.service.ts | handelswareDatevConfig | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — Einstellungen der Handelsware (Standard-Erlöskonto, Startwert Gegenkonto), eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002130000) — Einstellungen des Mandanten, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst. Zwei Rohtreffer über `const tenantPrisma = forTenant(this.prisma, tenantId)` (`getSettings` `findUnique`, `saveSettings` `upsert`) und einer über den Transaktionsparameter von `withTenantTransaction` (`export` liest die Einstellungen in derselben Transaktion wie die Kontenliste, `tx.handelswareDatevConfig.findUnique`). | | apps/api/src/handelsware-datev/handelsware-datev.service.ts | handelswareKonto | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — Kontenliste der Handelsware (Produktname → Gegenkonto, Erlöskonto), mehrere Zeilen je Mandant, Name je Mandant eindeutig. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002130000, Form aus `ProxmoxServer`), keine `system_read_policy`. Sechs Rohtreffer über `tenantPrisma` (`listAccounts` `findMany`, `createAccount` `create`, `updateAccount` `findFirst` UND `update`, `deleteAccount` `findFirst` UND `delete`) und vier über den Transaktionsparameter von `withTenantTransaction` (`importAccountsCsv` `deleteMany` UND `createMany` als EINE Transaktion; `export` `findMany` UND `createMany` — berechnet die Zuordnung neu und speichert neue Konten in derselben Transaktion, in der die Datei entsteht). `updateAccount`/`deleteAccount` prüfen die Kennung zusätzlich mit `where: { id, tenantId }` und antworten mit 404 (zweites Netz, solange der RLS-Schalter aus ist). | +| apps/api/src/domains/domains-settings.service.ts | domainsConfig | muss-mandantengebunden | gebunden | **quick-261008-dts:** neu — Einstellungen des Moduls Domains (AutoDNS), eine Zeile je Organisation (Singleton): aktives System (Demo/Live), getrennte Zugänge je System mit AES-verschlüsseltem Passwort (CryptoService, nie an den Client zurückgegeben), Standard-Nameserver. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000) — Einstellungen der Organisation, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst, der Auftragsstatus wird beim Öffnen der Seite abgefragt. Zwei Rohtreffer über `const tenantPrisma = forTenant(this.prisma, tenantId)` (`loadRow` `findUnique`, `saveSettings` `upsert`); `where` trägt `tenantId`. | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | nextcloudAlertSubscription | muss-mandantengebunden | gebunden | **quick-261002-kxc:** neu — die persönliche Glocke „Benachrichtigen“ je Benutzer und Cloud. `tenantId`- und `userId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension (Migration 20261002170000, Form aus `Reminder`), bewusst OHNE `system_read_policy` — die Tabelle wird nie im Systemkontext gelesen. Jeder Zugriff läuft an den Mandanten gebunden: `subscribe`/`unsubscribe`/`subscribedInstanceIds` über `forTenant(this.prisma, tenantId, userId)` (Benutzer aus dem Token, nie aus dem Body), der Versand (`notifySubscribers`) über `forTenant(this.prisma, tenantId)` mit `where: { tenantId, instanceId }`; `listRecentAlerts` (Meldung in Tessera) liest nur die Abonnements des Aufrufers (`where: { tenantId, userId }`, Benutzer aus dem Token). | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | nextcloudInstance | muss-mandantengebunden | gebunden | **quick-261002-kxc:** `subscribe` prüft per `findFirst` mit `where: { id, tenantId }`, dass die Cloud dem Mandanten gehört (sonst 404); `evaluateAfterCheck` beansprucht den Übergang per `updateMany` mit `where: { id, tenantId, alertState }` VOR dem Mailversand (nur `count === 1` meldet, mehrere API-Instanzen und Neustarts melden nie doppelt). Beides an den Mandanten gebunden, kein Systemkontext. | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | user | muss-mandantengebunden | gebunden | **quick-261002-kxc:** `notifySubscribers` liest die Empfänger der Abonnements (`user.findMany` mit `where: { tenantId, id: { in } }`, nur skalare Felder E-Mail, Rolle, `isActive`), gebunden an den Mandanten der Cloud. Konto-Aktivität und Modulzugriff werden beim Senden erneut geprüft (nicht erst beim Einschalten der Glocke). |