From cc1c83ab0a9e950ed3fda8266db3ad2557ef7668 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 8 Oct 2026 11:29:01 +0200 Subject: [PATCH] fix(domains): HTTP 5xx nie als Ablehnung werten, Verwerfen mit Neupruefung, Entwurfsstand im Anspruch (CR-01, WR-01, WR-02, WR-03, IN-03) CR-01: 5xx, 408, 425 (auch mit AutoDNS-Huelle) sind kein "abgelehnt" mehr, Bestellung endet in UNKNOWN und der Abgleich wertet sie nicht als "Domain gibt es nicht". 429 gilt bei der Bestellung ebenfalls als unklar, weil nicht belegt ist, dass AutoDNS vor der Verarbeitung abgelehnt hat. WR-01: Verwerfen eines unklaren Auftrags fragt im Augenblick des Verwerfens erneut bei AutoDNS nach; nur ein ausdrueckliches "nicht gefunden" verwirft. WR-02: Die Bestaetigung traegt den Entwurfsstand (updatedAt); der Anspruch greift nur bei gleichem Stand, sonst 409 orderChanged. WR-03: Auftraege ohne lesbares Datum/Objekt oder eine volle Trefferliste machen "nichts gefunden" unklar statt verwerfbar. IN-03: Scheitert das Speichern nach POST /domain, wird die Auftragsnummer protokolliert und der Auftrag auf UNKNOWN gebracht (nie DRAFT), ohne 500. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/api/src/domains/autodns-client.spec.ts | 12 +- apps/api/src/domains/autodns-client.ts | 14 +- .../domains/domains-orders.service.spec.ts | 389 ++++++++++++++++-- .../api/src/domains/domains-orders.service.ts | 194 +++++++-- .../src/domains/domains.controller.spec.ts | 13 +- apps/api/src/domains/domains.controller.ts | 8 +- .../src/domains/dto/domains-order.dto.spec.ts | 19 + apps/api/src/domains/dto/domains-order.dto.ts | 14 + .../modules/domains/components/OrdersTab.tsx | 2 + .../domains/components/RegisterTab.test.tsx | 16 +- .../domains/components/RegisterTab.tsx | 2 +- apps/web/src/lib/domains-api.ts | 15 +- docs/anleitung-anwender.md | 2 +- 13 files changed, 632 insertions(+), 68 deletions(-) create mode 100644 apps/api/src/domains/dto/domains-order.dto.spec.ts diff --git a/apps/api/src/domains/autodns-client.spec.ts b/apps/api/src/domains/autodns-client.spec.ts index 669cb9f..c09e51f 100644 --- a/apps/api/src/domains/autodns-client.spec.ts +++ b/apps/api/src/domains/autodns-client.spec.ts @@ -184,7 +184,17 @@ describe('autodnsRequest — Ergebnis', () => { [401, 'auth'], [403, 'forbidden'], [429, 'rate-limit'], - [500, 'business'], + [400, 'business'], + [404, 'business'], + [409, 'business'], + // Serverfehler und Zeitueberschreitungen sind KEINE Ablehnung, auch mit Huelle: + // ein Gateway kann nach dem Anlegen eines Auftrags so antworten. + [408, 'http'], + [425, 'http'], + [500, 'http'], + [502, 'http'], + [503, 'http'], + [504, 'http'], ])('HTTP %i mit Huelle -> %s', (status, kind) => { const result = parseAutodnsEnvelope( status, diff --git a/apps/api/src/domains/autodns-client.ts b/apps/api/src/domains/autodns-client.ts index b510da6..aba2cfe 100644 --- a/apps/api/src/domains/autodns-client.ts +++ b/apps/api/src/domains/autodns-client.ts @@ -147,11 +147,23 @@ function parseObjectInfo(value: unknown): AutodnsObjectInfo | null { return { type: asString(value.type), value: asString(value.value), summary }; } +/** + * Ordnet eine nicht erfolgreiche HTTP-Antwort ein. `business` heisst: AutoDNS + * hat die Anfrage VOR der Verarbeitung ausdruecklich abgelehnt (Client-Fehler + * 4xx mit lesbarer Huelle). Alles, was auch ein Gateway oder ein haengendes + * Backend NACH dem Anlegen eines Auftrags liefern kann, ist ausdruecklich + * KEINE Ablehnung und wird `http`: Serverfehler (5xx), 408 Request Timeout und + * 425 Too Early — auch mit AutoDNS-artiger Huelle. Die Bestellung und der + * Abgleich behandeln `http` als "Ausgang unbekannt", nie als "nicht bestellt" + * bzw. "gibt es nicht". + */ 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'; + if (httpStatus === 408 || httpStatus === 425) return 'http'; + const isClientError = httpStatus >= 400 && httpStatus < 500; + return hasEnvelope && isClientError ? 'business' : 'http'; } /** diff --git a/apps/api/src/domains/domains-orders.service.spec.ts b/apps/api/src/domains/domains-orders.service.spec.ts index 60f5b4b..cb0e17f 100644 --- a/apps/api/src/domains/domains-orders.service.spec.ts +++ b/apps/api/src/domains/domains-orders.service.spec.ts @@ -67,7 +67,10 @@ interface OrderRow { function matches(row: Record, where: Record = {}): boolean { return Object.entries(where).every(([key, expected]) => { - if (expected && typeof expected === 'object' && !(expected instanceof Date)) { + if (expected instanceof Date) { + return row[key] instanceof Date && row[key].getTime() === expected.getTime(); + } + if (expected && typeof expected === 'object') { if ('in' in expected) return (expected.in as unknown[]).includes(row[key]); if ('not' in expected) return row[key] !== expected.not; } @@ -150,7 +153,12 @@ function makeHarness(opts: { environment?: 'DEMO' | 'LIVE'; timeoutMs?: number } // Wie Postgres: prueft die Bedingung gegen den AKTUELLEN Zeilenstand, synchron. updateMany: vi.fn(async ({ where, data }: any) => { const rows = state.orders.filter((o) => matches(o, where)); - for (const r of rows) Object.assign(r, data); + // Wie Prisma `@updatedAt`: jede Aenderung setzt einen neuen Stand. + for (const r of rows) { + Object.assign(r, data, { + updatedAt: new Date(Math.max(Date.now(), r.updatedAt.getTime() + 1)), + }); + } return { count: rows.length }; }), }; @@ -235,6 +243,9 @@ function studioReply(domain: string, status: string, prices?: unknown[]) { const USER = { id: 'u1', username: 'anna' }; +/** Der Stand, den der Browser aus der Zusammenfassung zurueckschickt. */ +const versionOf = (order: { updatedAt: Date }) => order.updatedAt.toISOString(); + const EXPECTED_SUBMIT_BODY = { name: 'beispiel.de', period: { unit: 'YEAR', period: 1 }, @@ -362,6 +373,7 @@ describe('createOrder', () => { expect(result.contacts.zone).toEqual({ id: 22, displayName: 'Erika Muster' }); expect(result.nameServers).toEqual(['ns1.example.com', 'ns2.example.com']); expect(result.order).toMatchObject({ domainName: 'beispiel.de', status: 'DRAFT' }); + expect(result.version).toBe(h.state.orders[0].updatedAt.toISOString()); // Der Entwurf selbst bestellt nichts. expect(h.calls.every((c) => !c.url.endsWith('/domain'))).toBe(true); }); @@ -426,6 +438,7 @@ describe('createOrder', () => { expect(h.state.orders).toHaveLength(1); expect(h.state.orders[0].payload.ownerContactId).toBe(11); expect(h.orderApi.create).not.toHaveBeenCalled(); + expect(result.version).toBe(h.state.orders[0].updatedAt.toISOString()); }); it.each([ @@ -466,6 +479,7 @@ describe('submitOrder — der Anspruch', () => { it('sendet nach dem Anspruch genau EINEN POST /domain ohne Query und mit dem erwarteten Rumpf', async () => { const h = makeHarness(); const order = h.addOrder(); + const seenVersion = new Date(versionOf(order)); h.respond(() => reply( 200, @@ -475,7 +489,7 @@ describe('submitOrder — der Anspruch', () => { ), ); - const view = await h.service.submitOrder('t1', USER, order.id); + const view = await h.service.submitOrder('t1', USER, order.id, seenVersion.toISOString()); expect(h.orderApi.updateMany).toHaveBeenCalledTimes(2); const claim = h.orderApi.updateMany.mock.calls[0][0]; @@ -484,6 +498,7 @@ describe('submitOrder — der Anspruch', () => { tenantId: 't1', status: 'DRAFT', environment: 'DEMO', + updatedAt: seenVersion, }); expect(claim.data).toMatchObject({ status: 'SUBMITTING', @@ -532,8 +547,8 @@ describe('submitOrder — der Anspruch', () => { }); const results = await Promise.allSettled([ - h.service.submitOrder('t1', USER, order.id), - h.service.submitOrder('t1', USER, order.id), + h.service.submitOrder('t1', USER, order.id, versionOf(order)), + h.service.submitOrder('t1', USER, order.id, versionOf(order)), ]); const ok = results.filter((r) => r.status === 'fulfilled'); @@ -549,7 +564,9 @@ describe('submitOrder — der Anspruch', () => { const h = makeHarness({ environment: 'DEMO' }); const order = h.addOrder({ environment: 'DEMO' }); h.state.environment = 'LIVE'; - await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ + await expect( + h.service.submitOrder('t1', USER, order.id, versionOf(order)), + ).rejects.toMatchObject({ status: 409, response: { code: 'environmentChanged' }, }); @@ -568,7 +585,9 @@ describe('submitOrder — der Anspruch', () => { reads += 1; return reads === 1 && row ? { ...row, environment: 'DEMO' } : row; }); - await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ + await expect( + h.service.submitOrder('t1', USER, order.id, versionOf(order)), + ).rejects.toMatchObject({ response: { code: 'environmentChanged' }, }); expect(h.fetchImpl).not.toHaveBeenCalled(); @@ -585,7 +604,9 @@ describe('submitOrder — der Anspruch', () => { ])('Auftrag im Zustand %s -> 409 alreadySubmitted, kein Netz', async (status) => { const h = makeHarness(); const order = h.addOrder({ status }); - await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ + await expect( + h.service.submitOrder('t1', USER, order.id, versionOf(order)), + ).rejects.toMatchObject({ status: 409, response: { code: 'alreadySubmitted' }, }); @@ -595,7 +616,58 @@ describe('submitOrder — der Anspruch', () => { it('fremde Nummer -> 404, kein Netz', async () => { const h = makeHarness(); h.addOrder({ tenantId: 'andere' }); - await expect(h.service.submitOrder('t1', USER, 'o1')).rejects.toBeInstanceOf(NotFoundException); + await expect( + h.service.submitOrder('t1', USER, 'o1', new Date().toISOString()), + ).rejects.toBeInstanceOf(NotFoundException); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + + it('anderer Stand als in der Zusammenfassung gesehen -> 409 orderChanged, kein Anspruch, kein Netz', async () => { + const h = makeHarness(); + const order = h.addOrder(); + const seen = versionOf(order); + // Ein zweiter Verwalter hat den Entwurf inzwischen neu angelegt (Kontakte, Nameserver). + order.updatedAt = new Date(order.updatedAt.getTime() + 5000); + order.payload = { ...order.payload, ownerContactId: 22 }; + await expect(h.service.submitOrder('t1', USER, order.id, seen)).rejects.toMatchObject({ + status: 409, + response: { code: 'orderChanged' }, + }); + expect(h.fetchImpl).not.toHaveBeenCalled(); + expect(h.orderApi.updateMany).not.toHaveBeenCalled(); + expect(h.state.orders[0].status).toBe('DRAFT'); + }); + + it('Aenderung zwischen Lesen und Anspruch -> der Anspruch greift nicht: 409 orderChanged, kein Netz', async () => { + const h = makeHarness(); + const order = h.addOrder(); + const seen = versionOf(order); + const original = h.orderApi.findFirst.getMockImplementation() as (a: any) => Promise; + let reads = 0; + h.orderApi.findFirst.mockImplementation(async (a: any) => { + const row = await original(a); + reads += 1; + if (reads === 1) { + // Nach dem Lesen, vor dem Anspruch: ein anderer ueberschreibt den Entwurf. + order.updatedAt = new Date(order.updatedAt.getTime() + 5000); + order.payload = { ...order.payload, ownerContactId: 22 }; + } + return row; + }); + await expect(h.service.submitOrder('t1', USER, order.id, seen)).rejects.toMatchObject({ + status: 409, + response: { code: 'orderChanged' }, + }); + expect(h.fetchImpl).not.toHaveBeenCalled(); + expect(h.state.orders[0].status).toBe('DRAFT'); + }); + + it('ein unlesbarer Stand bestellt nichts', async () => { + const h = makeHarness(); + const order = h.addOrder(); + await expect(h.service.submitOrder('t1', USER, order.id, 'kein-datum')).rejects.toMatchObject({ + response: { code: 'orderChanged' }, + }); expect(h.fetchImpl).not.toHaveBeenCalled(); }); }); @@ -605,7 +677,7 @@ describe('submitOrder — Ausgang', () => { const h = makeHarness({ timeoutMs: 20 }); const order = h.addOrder(); h.respond(() => new Promise(() => undefined)); - const view = await h.service.submitOrder('t1', USER, order.id); + const view = await h.service.submitOrder('t1', USER, order.id, versionOf(order)); expect(view.status).toBe('UNKNOWN'); expect(h.calls).toHaveLength(1); expect(h.state.orders[0].status).toBe('UNKNOWN'); @@ -619,7 +691,7 @@ describe('submitOrder — Ausgang', () => { h.respond(() => { throw Object.assign(new Error('reset'), { code: 'ECONNRESET' }); }); - const view = await h.service.submitOrder('t1', USER, order.id); + const view = await h.service.submitOrder('t1', USER, order.id, versionOf(order)); expect(view.status).toBe('UNKNOWN'); expect(h.calls).toHaveLength(1); }); @@ -628,14 +700,66 @@ describe('submitOrder — Ausgang', () => { const h = makeHarness(); const order = h.addOrder(); h.respond(() => reply(200, 'Wartung')); - expect((await h.service.submitOrder('t1', USER, order.id)).status).toBe('UNKNOWN'); + expect((await h.service.submitOrder('t1', USER, order.id, versionOf(order))).status).toBe( + 'UNKNOWN', + ); }); it('HTTP 502 ohne Umschlag (Gateway) -> UNKNOWN, nicht FAILED', async () => { const h = makeHarness(); const order = h.addOrder(); h.respond(() => reply(502, 'Bad Gateway')); - expect((await h.service.submitOrder('t1', USER, order.id)).status).toBe('UNKNOWN'); + expect((await h.service.submitOrder('t1', USER, order.id, versionOf(order))).status).toBe( + 'UNKNOWN', + ); + }); + + it.each([ + 408, 425, 429, 500, 502, 503, 504, + ])('HTTP %i mit AutoDNS-Huelle -> UNKNOWN, nicht FAILED, openKey bleibt', async (status) => { + const h = makeHarness(); + const order = h.addOrder(); + h.respond(() => + reply( + status, + envelope([], { + type: 'ERROR', + messages: [{ code: 'E9', text: 'Gateway timeout', status: 'ERROR' }], + }), + ), + ); + const view = await h.service.submitOrder('t1', USER, order.id, versionOf(order)); + expect(view.status).toBe('UNKNOWN'); + expect(view.errorText).toBeNull(); + expect(h.calls).toHaveLength(1); + expect(h.state.orders[0]).toMatchObject({ status: 'UNKNOWN', openKey: 'beispiel.de' }); + expect(h.state.orders[0].lastCheckedAt).toBeNull(); + // Die Domain bleibt gesperrt: keine zweite Bestellung moeglich. + h.respond(() => studioReply('beispiel.de', 'FREE')); + await expect( + h.service.createOrder('t1', 'u1', { + domain: 'beispiel.de', + ownerContactId: 11, + adminContactId: 11, + techContactId: 22, + zoneContactId: 22, + nameServers: ['ns1.example.com', 'ns2.example.com'], + } as any), + ).rejects.toMatchObject({ response: { code: 'orderOpen' } }); + }); + + it('HTTP 400 mit AutoDNS-Huelle -> FAILED (ausdrueckliche Ablehnung)', async () => { + const h = makeHarness(); + const order = h.addOrder(); + h.respond(() => + reply( + 400, + envelope([], { type: 'ERROR', messages: [{ text: 'Invalid contact', status: 'ERROR' }] }), + ), + ); + const view = await h.service.submitOrder('t1', USER, order.id, versionOf(order)); + expect(view.status).toBe('FAILED'); + expect(h.state.orders[0].openKey).toBeNull(); }); it('HTTP 200 mit status.type ERROR -> FAILED mit Text, openKey leer', async () => { @@ -650,7 +774,7 @@ describe('submitOrder — Ausgang', () => { }), ), ); - const view = await h.service.submitOrder('t1', USER, order.id); + const view = await h.service.submitOrder('t1', USER, order.id, versionOf(order)); expect(view.status).toBe('FAILED'); expect(view.errorText).toContain('Domain not available'); expect(h.state.orders[0].openKey).toBeNull(); @@ -669,7 +793,7 @@ describe('submitOrder — Ausgang', () => { }), ), ); - const view = await h.service.submitOrder('t1', USER, order.id); + const view = await h.service.submitOrder('t1', USER, order.id, versionOf(order)); expect(view.status).toBe('FAILED'); expect(view.errorText).toContain('Benutzername, Passwort und Kontext'); expect(h.state.orders[0].openKey).toBeNull(); @@ -681,7 +805,7 @@ describe('submitOrder — Ausgang', () => { h.respond(() => reply(200, envelope([{}], { object: { type: 'domain', value: 'beispiel.de', summary: 1 } })), ); - const view = await h.service.submitOrder('t1', USER, order.id); + const view = await h.service.submitOrder('t1', USER, order.id, versionOf(order)); expect(view.status).toBe('SUBMITTED'); expect(h.state.orders[0].jobId).toBeNull(); }); @@ -690,14 +814,52 @@ describe('submitOrder — Ausgang', () => { const h = makeHarness({ timeoutMs: 20 }); const order = h.addOrder(); h.respond(() => new Promise(() => undefined)); - await h.service.submitOrder('t1', USER, order.id); - await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ + await h.service.submitOrder('t1', USER, order.id, versionOf(order)); + await expect( + h.service.submitOrder('t1', USER, order.id, versionOf(order)), + ).rejects.toMatchObject({ response: { code: 'alreadySubmitted' }, }); expect(h.calls).toHaveLength(1); }); }); +describe('submitOrder — Speichern des Ergebnisses scheitert (IN-03)', () => { + function failWritesFrom(h: ReturnType, failing: number[]) { + const original = h.orderApi.updateMany.getMockImplementation() as (a: any) => Promise; + let call = 0; + h.orderApi.updateMany.mockImplementation(async (a: any) => { + call += 1; + if (failing.includes(call)) throw new Error('Datenbank nicht erreichbar'); + return original(a); + }); + } + + it('Ergebnis-Schreibvorgang scheitert -> kein 500, Auftrag wird UNKNOWN, nie DRAFT', async () => { + const h = makeHarness(); + const order = h.addOrder(); + h.respond(() => reply(200, envelope([{ id: 987, status: 'RUNNING' }]))); + failWritesFrom(h, [2]); // 1 = Anspruch, 2 = Ergebnis + const view = await h.service.submitOrder('t1', USER, order.id, versionOf(order)); + expect(view.status).toBe('UNKNOWN'); + expect(h.state.orders[0]).toMatchObject({ status: 'UNKNOWN', openKey: 'beispiel.de' }); + expect(h.calls).toHaveLength(1); + expect(h.directory.invalidate).toHaveBeenCalledWith('t1'); + }); + + it('auch UNKNOWN laesst sich nicht speichern -> Ansicht UNKNOWN, Zeile bleibt SUBMITTING (nie DRAFT)', async () => { + const h = makeHarness(); + const order = h.addOrder(); + h.respond(() => reply(200, envelope([{ id: 987, status: 'RUNNING' }]))); + failWritesFrom(h, [2, 3]); + const view = await h.service.submitOrder('t1', USER, order.id, versionOf(order)); + expect(view.status).toBe('UNKNOWN'); + expect(h.state.orders[0].status).toBe('SUBMITTING'); + expect(h.state.orders[0].openKey).toBe('beispiel.de'); + expect(h.calls).toHaveLength(1); + }); +}); + describe('refreshOpenOrders', () => { it('SUBMITTED mit Auftrag: SUCCESS -> SUCCESS, openKey bleibt, Zwischenspeicher geleert', async () => { const h = makeHarness(); @@ -861,7 +1023,7 @@ describe('refreshOpenOrders', () => { expect(h.state.orders[0].lastCheckedAt).toBeNull(); }); - it('ein alter Auftrag ohne lesbare Anlagezeit zaehlt nicht', async () => { + it('ein Auftrag der Domain ohne lesbare Anlagezeit ist unklar: lastCheckedAt bleibt leer', async () => { const h = makeHarness(); h.addOrder({ status: 'UNKNOWN', confirmedAt }); h.respond((call) => @@ -871,6 +1033,93 @@ describe('refreshOpenOrders', () => { ); await h.service.refreshOpenOrders('t1'); expect(h.state.orders[0]).toMatchObject({ status: 'UNKNOWN', jobId: null }); + expect(h.state.orders[0].lastCheckedAt).toBeNull(); + }); + + it('ein Auftrag ohne lesbares Objekt ist unklar: lastCheckedAt bleibt leer', async () => { + const h = makeHarness(); + h.addOrder({ status: 'UNKNOWN', confirmedAt }); + h.respond((call) => + call.url.endsWith('/domain/beispiel.de') + ? reply(404, envelope([], { type: 'ERROR', messages: [{ text: 'nf', status: 'ERROR' }] })) + : reply( + 200, + envelope([{ job: { id: 7, status: 'RUNNING', created: new Date().toISOString() } }]), + ), + ); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders[0].lastCheckedAt).toBeNull(); + }); + + it('ein alter, lesbar datierter Auftrag zaehlt nicht und belegt "nichts gefunden"', async () => { + const h = makeHarness(); + h.addOrder({ status: 'UNKNOWN', confirmedAt }); + h.respond((call) => + call.url.endsWith('/domain/beispiel.de') + ? reply(404, envelope([], { type: 'ERROR', messages: [{ text: 'nf', status: 'ERROR' }] })) + : reply( + 200, + envelope([ + { + job: { id: 7, status: 'FAILED', created: '2020-01-01T00:00:00Z' }, + object: 'beispiel.de', + }, + ]), + ), + ); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders[0]).toMatchObject({ status: 'UNKNOWN', jobId: null }); + expect(h.state.orders[0].lastCheckedAt).toBeInstanceOf(Date); + }); + + it('eine volle Trefferliste ohne passenden Auftrag ist unklar (koennte abgeschnitten sein)', async () => { + const h = makeHarness(); + h.addOrder({ status: 'UNKNOWN', confirmedAt }); + const jobs = Array.from({ length: 10 }, (_, i) => ({ + job: { id: 100 + i, status: 'SUCCESS', created: new Date().toISOString() }, + object: `andere${i}.de`, + })); + h.respond((call) => + call.url.endsWith('/domain/beispiel.de') + ? reply(404, envelope([], { type: 'ERROR', messages: [{ text: 'nf', status: 'ERROR' }] })) + : reply(200, envelope(jobs)), + ); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders[0].lastCheckedAt).toBeNull(); + }); + + it.each([ + 408, 425, 429, 500, 502, 504, + ])('GET /domain/{name} mit HTTP %i und Huelle ist KEIN "gibt es nicht": nichts geschrieben, keine Auftragssuche', async (status) => { + const h = makeHarness(); + h.addOrder({ status: 'UNKNOWN', confirmedAt }); + h.respond(() => + reply( + status, + envelope([], { type: 'ERROR', messages: [{ text: 'Timeout', status: 'ERROR' }] }), + ), + ); + await h.service.refreshOpenOrders('t1'); + expect(h.calls.map((c) => c.url)).toEqual([ + 'https://api.demo.autodns.com/v1/domain/beispiel.de', + ]); + expect(h.state.orders[0].status).toBe('UNKNOWN'); + expect(h.state.orders[0].lastCheckedAt).toBeNull(); + }); + + it('die Auftragssuche mit HTTP 500 und Huelle belegt "nichts gefunden" nicht', async () => { + const h = makeHarness(); + h.addOrder({ status: 'UNKNOWN', confirmedAt }); + h.respond((call) => + call.url.endsWith('/domain/beispiel.de') + ? reply(404, envelope([], { type: 'ERROR', messages: [{ text: 'nf', status: 'ERROR' }] })) + : reply( + 500, + envelope([], { type: 'ERROR', messages: [{ text: 'boom', status: 'ERROR' }] }), + ), + ); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders[0].lastCheckedAt).toBeNull(); }); }); @@ -926,22 +1175,116 @@ describe('cancelOrder', () => { expect(h.state.orders[0]).toMatchObject({ status: 'CANCELED', openKey: null }); }); - it('UNKNOWN mit gesetztem lastCheckedAt -> CANCELED', async () => { + const notFound = (call: Call) => + call.url.endsWith('/domain/beispiel.de') + ? reply(404, envelope([], { type: 'ERROR', messages: [{ text: 'nf', status: 'ERROR' }] })) + : reply(200, envelope([])); + + it('UNKNOWN: Verwerfen fragt im Augenblick des Verwerfens erneut bei AutoDNS nach', async () => { const h = makeHarness(); - const order = h.addOrder({ status: 'UNKNOWN', lastCheckedAt: new Date() }); + const order = h.addOrder({ + status: 'UNKNOWN', + confirmedAt: new Date(Date.now() - 60 * 60_000), + // Der fruehere Abgleich ist Stunden alt und zaehlt nicht. + lastCheckedAt: new Date(Date.now() - 3 * 60 * 60_000), + }); + h.respond(notFound); + const view = await h.service.cancelOrder('t1', order.id); + expect(view.status).toBe('CANCELED'); + expect(h.calls.map((c) => c.url)).toEqual([ + 'https://api.demo.autodns.com/v1/domain/beispiel.de', + 'https://api.demo.autodns.com/v1/job/_search', + ]); + expect(h.state.orders[0]).toMatchObject({ status: 'CANCELED', openKey: null }); + }); + + it('UNKNOWN ohne frueheren Abgleich wird bei ausdruecklichem "nicht gefunden" verworfen', async () => { + const h = makeHarness(); + const order = h.addOrder({ status: 'UNKNOWN', confirmedAt: new Date(Date.now() - 600_000) }); + h.respond(notFound); await h.service.cancelOrder('t1', order.id); expect(h.state.orders[0]).toMatchObject({ status: 'CANCELED', openKey: null }); }); - it('UNKNOWN ohne Abgleich -> 409 checkFirst', async () => { + it('UNKNOWN: Domain inzwischen bei AutoDNS vorhanden -> 409 orderFound, Auftrag wird SUCCESS', async () => { const h = makeHarness(); - const order = h.addOrder({ status: 'UNKNOWN' }); + const order = h.addOrder({ status: 'UNKNOWN', lastCheckedAt: new Date(Date.now() - 600_000) }); + h.respond(() => reply(200, envelope([{ name: 'beispiel.de' }]))); + await expect(h.service.cancelOrder('t1', order.id)).rejects.toMatchObject({ + status: 409, + response: { code: 'orderFound' }, + }); + expect(h.state.orders[0]).toMatchObject({ status: 'SUCCESS', openKey: 'beispiel.de' }); + }); + + it('UNKNOWN: Auftrag inzwischen bei AutoDNS vorhanden -> 409 orderFound, openKey bleibt', async () => { + const h = makeHarness(); + const order = h.addOrder({ + status: 'UNKNOWN', + confirmedAt: new Date(Date.now() - 600_000), + lastCheckedAt: new Date(Date.now() - 600_000), + }); + h.respond((call) => + call.url.endsWith('/domain/beispiel.de') + ? notFound(call) + : reply( + 200, + envelope([ + { + job: { id: 555, status: 'RUNNING', created: new Date().toISOString() }, + object: 'beispiel.de', + }, + ]), + ), + ); + await expect(h.service.cancelOrder('t1', order.id)).rejects.toMatchObject({ + response: { code: 'orderFound' }, + }); + expect(h.state.orders[0]).toMatchObject({ + status: 'SUBMITTED', + jobId: '555', + openKey: 'beispiel.de', + }); + }); + + it('UNKNOWN: AutoDNS antwortet mit 500 + Huelle -> 409 checkFirst, nichts verworfen', async () => { + const h = makeHarness(); + const order = h.addOrder({ status: 'UNKNOWN', lastCheckedAt: new Date(Date.now() - 600_000) }); + h.respond(() => + reply(500, envelope([], { type: 'ERROR', messages: [{ text: 'boom', status: 'ERROR' }] })), + ); + await expect(h.service.cancelOrder('t1', order.id)).rejects.toMatchObject({ + response: { code: 'checkFirst' }, + }); + expect(h.state.orders[0]).toMatchObject({ status: 'UNKNOWN', openKey: 'beispiel.de' }); + }); + + it('UNKNOWN: Zeitablauf beim Nachsehen -> 409 checkFirst, nichts verworfen', async () => { + const h = makeHarness({ timeoutMs: 20 }); + const order = h.addOrder({ status: 'UNKNOWN', lastCheckedAt: new Date(Date.now() - 600_000) }); + h.respond(() => new Promise(() => undefined)); await expect(h.service.cancelOrder('t1', order.id)).rejects.toMatchObject({ response: { code: 'checkFirst' }, }); expect(h.state.orders[0].status).toBe('UNKNOWN'); }); + it('UNKNOWN eines anderen Systems -> 409 environmentChanged, kein Netz', async () => { + const h = makeHarness({ environment: 'LIVE' }); + const order = h.addOrder({ status: 'UNKNOWN', environment: 'DEMO' }); + await expect(h.service.cancelOrder('t1', order.id)).rejects.toMatchObject({ + response: { code: 'environmentChanged' }, + }); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + + it('DRAFT wird ohne Netz verworfen', async () => { + const h = makeHarness(); + const order = h.addOrder(); + await h.service.cancelOrder('t1', order.id); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + it.each([ 'SUBMITTING', 'SUBMITTED', diff --git a/apps/api/src/domains/domains-orders.service.ts b/apps/api/src/domains/domains-orders.service.ts index a9e6664..8e833fc 100644 --- a/apps/api/src/domains/domains-orders.service.ts +++ b/apps/api/src/domains/domains-orders.service.ts @@ -43,9 +43,13 @@ export const SUBMITTING_STALE_MS = 120_000; /** Ein Auftrag aus der Auftragssuche zaehlt ab dieser Toleranz vor der Bestaetigung. */ const JOB_SEARCH_TOLERANCE_MS = 5 * 60_000; const LIST_MAX_ORDERS = 200; +/** Trefferzahl der Auftragssuche beim Abgleich; eine volle Liste kann abgeschnitten sein. */ +const JOB_SEARCH_LIMIT = 10; const MIN_NAMESERVERS = 2; const MAX_NAMESERVERS = 6; +type ReconcileOutcome = 'found' | 'not-found' | 'inconclusive'; + interface OrderPayload { ownerContactId: number; adminContactId: number; @@ -116,6 +120,12 @@ export interface OrderSummaryView { }; nameServers: string[]; periodYears: 1; + /** + * Stand des Entwurfs (`updatedAt`, ISO). Der Browser schickt ihn beim + * verbindlichen Bestellen zurueck: bestellt wird nur genau der Stand, den der + * Benutzer gesehen hat. + */ + version: string; } const ERR = { @@ -139,7 +149,17 @@ const ERR = { checkFirst: { code: 'checkFirst', message: - 'Bitte aktualisieren Sie den Auftrag zuerst, damit Tessera bei AutoDNS nachsehen kann.', + 'Tessera konnte bei AutoDNS gerade nicht verlässlich nachsehen. Bitte aktualisieren Sie den Auftrag und versuchen Sie es erneut. Bestellen Sie die Domain bitte nicht erneut.', + }, + orderFound: { + code: 'orderFound', + message: + 'Bei AutoDNS wurde zu dieser Domain ein Auftrag oder die Domain selbst gefunden. Der Auftrag kann deshalb nicht verworfen werden.', + }, + orderChanged: { + code: 'orderChanged', + message: + 'Der Auftrag wurde inzwischen geändert. Bitte prüfen Sie die Zusammenfassung erneut, bevor Sie bestellen.', }, notCancelable: { code: 'notCancelable', @@ -390,7 +410,12 @@ export class DomainsOrdersService { }); // Zwischenzeitlich abgeschickt: nicht mehr anfassen. if (count !== 1) throw new ConflictException(ERR.orderOpen); - row = { ...existing, payload }; + // Neuer Stand (`updatedAt`) fuer die Bindung der Bestaetigung. + const reread = (await tenantPrisma.domainsOrder.findFirst({ + where: { id: existing.id, tenantId }, + })) as OrderRow | null; + if (reread?.status !== 'DRAFT') throw new ConflictException(ERR.orderOpen); + row = reread; } else { try { row = (await tenantPrisma.domainsOrder.create({ @@ -424,6 +449,7 @@ export class DomainsOrdersService { }, nameServers: payload.nameServers, periodYears: 1, + version: row.updatedAt.toISOString(), }; } @@ -444,6 +470,11 @@ export class DomainsOrdersService { * `POST /domain` startet. Stuerzt der Prozess danach ab, bleibt der * Auftrag in SUBMITTING und wird beim Abgleich zu UNKNOWN — nie wieder * zu DRAFT, denn der Aufruf kann schon draussen gewesen sein. + * - STAND IM ANSPRUCH: Der Browser schickt den Stand (`updatedAt`) des + * Entwurfs mit, den der Benutzer in der Zusammenfassung gesehen hat. Er + * steht in der `where`-Klausel des Anspruchs. Hat jemand den Entwurf + * inzwischen ueberschrieben (andere Kontakte, andere Nameserver), greift der + * Anspruch nicht: 409 `orderChanged`, kein Netzaufruf. * - SYSTEM IM ANSPRUCH: Das aktive System steht in der `where`-Klausel. Ein * Entwurf aus dem Demo-System kann nach einem Wechsel nie im Live-System * (oder umgekehrt) bestellt werden; stattdessen 409 `environmentChanged`. @@ -457,6 +488,7 @@ export class DomainsOrdersService { tenantId: string, user: { id: string; username: string }, id: string, + version: string, ): Promise { // Zugang zuerst: scheitert er, wurde noch nichts angespruchen. const { environment, credentials } = await this.settings.getActiveCredentials(tenantId); @@ -469,6 +501,7 @@ export class DomainsOrdersService { if (!before) throw new NotFoundException(ERR.orderNotFound); if (before.environment !== environment) throw new ConflictException(ERR.environmentChanged); if (before.status !== 'DRAFT') throw new ConflictException(ERR.alreadySubmitted); + if (!this.sameVersion(before.updatedAt, version)) throw new ConflictException(ERR.orderChanged); const payload = readPayload(before.payload); if (!payload) { throw new BadRequestException({ @@ -478,7 +511,7 @@ export class DomainsOrdersService { } const claim = await tenantPrisma.domainsOrder.updateMany({ - where: { id, tenantId, status: 'DRAFT', environment }, + where: { id, tenantId, status: 'DRAFT', environment, updatedAt: before.updatedAt }, data: { status: 'SUBMITTING', confirmedAt: new Date(), @@ -493,6 +526,8 @@ export class DomainsOrdersService { })) as OrderRow | null; if (!current) throw new NotFoundException(ERR.orderNotFound); if (current.environment !== environment) throw new ConflictException(ERR.environmentChanged); + // Noch Entwurf, aber anderer Stand: jemand hat ihn zwischen Lesen und Anspruch geaendert. + if (current.status === 'DRAFT') throw new ConflictException(ERR.orderChanged); throw new ConflictException(ERR.alreadySubmitted); } @@ -518,11 +553,18 @@ export class DomainsOrdersService { } // Bewusst OHNE `lastCheckedAt`: bei UNKNOWN heisst das "noch nicht bei AutoDNS - // nachgesehen" und sperrt das Verwerfen, bis ein Abgleich stattgefunden hat. - await tenantPrisma.domainsOrder.updateMany({ - where: { id, tenantId, status: 'SUBMITTING' }, - data: outcome, - }); + // nachgesehen". + try { + await tenantPrisma.domainsOrder.updateMany({ + where: { id, tenantId, status: 'SUBMITTING' }, + data: outcome, + }); + } catch (error) { + // Der Aufruf ist raus, nur das Speichern des Ergebnisses scheiterte. Nie zu + // DRAFT zurueck: erst UNKNOWN versuchen (der Abgleich holt es nach), sonst + // bleibt SUBMITTING und wird nach SUBMITTING_STALE_MS zu UNKNOWN. + return this.recoverFailedOutcomeWrite(tenantPrisma, tenantId, before, user, outcome, error); + } this.logger.log(`Auftrag ${id}: Registrierung abgeschickt, Ergebnis ${String(outcome.status)}`); if (outcome.status === 'SUBMITTED') this.directory.invalidate(tenantId); @@ -533,6 +575,54 @@ export class DomainsOrdersService { return toView(after); } + /** Gleicher Entwurfsstand? (ISO-Zeichenkette gegen `updatedAt`, auf die Millisekunde.) */ + private sameVersion(updatedAt: Date, version: string): boolean { + const wanted = new Date(version).getTime(); + return Number.isFinite(wanted) && wanted === updatedAt.getTime(); + } + + /** + * Das Speichern des Ergebnisses nach `POST /domain` ist fehlgeschlagen + * (Datenbank kurz weg). Der Auftrag kann bei AutoDNS angekommen sein; deshalb + * geht es nie zurueck zu DRAFT und nie zu FAILED. Der Zustand wird auf + * UNKNOWN gebracht, damit der Abgleich ihn aufnimmt. Gelingt auch das nicht, + * bleibt SUBMITTING stehen und wird beim naechsten Abgleich nach zwei Minuten + * zu UNKNOWN. Der Benutzer bekommt in beiden Faellen eine UNKNOWN-Ansicht + * statt eines 500. + */ + private async recoverFailedOutcomeWrite( + tenantPrisma: ReturnType, + tenantId: string, + before: OrderRow, + user: { id: string; username: string }, + outcome: Record, + error: unknown, + ): Promise { + const jobHint = + typeof outcome.jobId === 'string' ? `, AutoDNS-Auftragsnummer ${outcome.jobId}` : ''; + this.logger.error( + `Auftrag ${before.id}: POST /domain ist abgeschickt (Ergebnis ${String(outcome.status)}${jobHint}), das Speichern des Ergebnisses schlug fehl: ${error instanceof Error ? error.name : 'unbekannt'}`, + ); + if (outcome.status === 'SUBMITTED') this.directory.invalidate(tenantId); + try { + await tenantPrisma.domainsOrder.updateMany({ + where: { id: before.id, tenantId, status: 'SUBMITTING' }, + data: { status: 'UNKNOWN' }, + }); + } catch { + this.logger.error( + `Auftrag ${before.id}: Auch UNKNOWN konnte nicht gespeichert werden; der Auftrag bleibt in SUBMITTING und wird beim Abgleich zu UNKNOWN.`, + ); + } + return toView({ + ...before, + status: 'UNKNOWN', + confirmedByUserId: user.id, + confirmedByUsername: user.username, + confirmedAt: new Date(), + }); + } + /** Abbildung der Antwort von `POST /domain` auf die zu speichernden Felder (D-M). */ private outcomeOfSubmit(result: AutodnsResult): Record { if (result.ok) { @@ -544,13 +634,16 @@ export class DomainsOrdersService { }; } switch (result.kind) { - // AutoDNS hat ausdruecklich abgelehnt (Umschlag bzw. Anmeldefehler): nichts wurde bestellt. + // AutoDNS hat ausdruecklich abgelehnt (Client-Fehler mit Umschlag bzw. + // Anmeldefehler 401/403): nichts wurde bestellt. case 'auth': case 'forbidden': - case 'rate-limit': case 'business': return { status: 'FAILED', errorText: failureText(result), openKey: null }; - // Zeitablauf, Netz, TLS, unlesbare oder umschlaglose Antwort: Ausgang unbekannt. + // Ausgang unbekannt — es wird nie geraten: Zeitablauf, Netz, TLS, unlesbare + // Antwort, Serverfehler (5xx/408/425, auch mit Umschlag) und 429. Ein 429 + // kann ebenso von einem vorgeschalteten Gateway wie von AutoDNS kommen; dass + // AutoDNS vor der Verarbeitung abgelehnt hat, ist nicht belegt. default: return { status: 'UNKNOWN' }; } @@ -695,16 +788,23 @@ export class DomainsOrdersService { /** * UNKNOWN (oder SUBMITTED ohne Auftragsnummer): erst `GET /domain/{name}` — * gibt es die Domain, ist sie registriert. Sonst Auftragssuche nach dem - * Domainnamen, nur Auftraege ab 5 Minuten vor der Bestaetigung. Nur ein - * ERGEBNIS ("nicht gefunden") setzt `lastCheckedAt`; ein Fehler beim Nachsehen - * (Zeitablauf, Anmeldung, Gateway) laesst den Auftrag unveraendert, damit - * "Verwerfen" nie auf einer Pruefung beruht, die gar nichts geprueft hat. + * Domainnamen, nur Auftraege ab 5 Minuten vor der Bestaetigung. + * + * Ergebnis: + * - `found`: Domain oder Auftrag gefunden, der Auftrag wurde fortgeschrieben. + * - `not-found`: AutoDNS hat ausdruecklich geantwortet, dass es weder Domain + * noch passenden Auftrag gibt; `lastCheckedAt` ist gesetzt. + * - `inconclusive`: das Nachsehen hat nichts bewiesen (Zeitablauf, Anmeldung, + * Serverfehler/Gateway, Auftrag ohne lesbares Datum oder mit unlesbarem + * Objekt, moeglicherweise abgeschnittene Liste). Der Auftrag bleibt + * unveraendert, `lastCheckedAt` wird NICHT gesetzt — "Verwerfen" beruht nie + * auf einer Pruefung, die gar nichts geprueft hat. */ private async reconcile( tenantId: string, credentials: AutodnsCredentials, row: OrderRow, - ): Promise { + ): Promise { const domain = await this.callRaw( credentials, 'GET', @@ -712,38 +812,62 @@ export class DomainsOrdersService { ); if (domain.ok && domain.data.length > 0) { await this.writeIf(tenantId, row, { status: 'SUCCESS', lastCheckedAt: new Date() }); - return; + return 'found'; } - // Nur eine ausdrueckliche AutoDNS-Antwort "gibt es nicht" ist ein Ergebnis. - if (!domain.ok && domain.kind !== 'business') return; + // Nur eine ausdrueckliche AutoDNS-Antwort "gibt es nicht" ist ein Ergebnis + // (Client-Fehler mit Huelle). Serverfehler, Gateway, Zeitablauf: nichts bewiesen. + if (!domain.ok && domain.kind !== 'business') return 'inconclusive'; const search = await this.callRaw(credentials, 'POST', '/job/_search', { filters: [{ key: 'object', operator: 'EQUAL', value: row.domainName }], - view: { limit: 10, offset: 0 }, + view: { limit: JOB_SEARCH_LIMIT, offset: 0 }, orders: [{ key: 'created', type: 'DESC' }], }); - if (!search.ok) return; + if (!search.ok) return 'inconclusive'; const since = (row.confirmedAt ?? row.createdAt).getTime() - JOB_SEARCH_TOLERANCE_MS; - const candidates = parseJobList(search.data) + const entries = parseJobList(search.data); + const candidates = entries .filter((j) => j.object === row.domainName && j.created && j.created.getTime() >= since) .sort((a, b) => (b.created?.getTime() ?? 0) - (a.created?.getTime() ?? 0)); - const checkedAt = new Date(); const job = candidates[0]; if (job) { await this.writeIf(tenantId, row, { status: 'SUBMITTED', jobId: job.id, jobStatus: job.status, - lastCheckedAt: checkedAt, + lastCheckedAt: new Date(), }); - return; + return 'found'; } - await this.writeIf(tenantId, row, { lastCheckedAt: checkedAt }); + + // Nichts Passendes belegt. "Nichts gefunden" gilt aber nur, wenn die Antwort + // das auch hergibt: + // - ein Auftrag, der sich nicht datieren oder nicht eindeutig der Domain + // zuordnen laesst, koennte unserer sein; + // - war die Liste voll, kann unser Auftrag abgeschnitten worden sein + // (z. B. wenn der Filter nicht greift). + const unreadable = entries.some( + (j) => (j.object === row.domainName && !j.created) || j.object === null, + ); + const mayBeTruncated = Array.isArray(search.data) && search.data.length >= JOB_SEARCH_LIMIT; + if (unreadable || mayBeTruncated) return 'inconclusive'; + + await this.writeIf(tenantId, row, { lastCheckedAt: new Date() }); + return 'not-found'; } // --- Verwerfen --------------------------------------------------------------- + /** + * Verwirft einen Entwurf oder einen unklaren Auftrag. + * + * Ein unklarer Auftrag wird NIE auf Grund eines frueheren Abgleichs verworfen: + * Auftraege bei AutoDNS koennen sich verzoegern, und ein verworfener Auftrag + * gibt die Domain fuer eine neue Bestellung frei (zweiter `POST /domain`, zweite + * Abbuchung). Deshalb fragt Tessera im Augenblick des Verwerfens noch einmal bei + * AutoDNS nach und verwirft nur bei einem ausdruecklichen "nicht gefunden". + */ async cancelOrder(tenantId: string, id: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const row = (await tenantPrisma.domainsOrder.findFirst({ @@ -751,17 +875,21 @@ export class DomainsOrdersService { })) as OrderRow | null; if (!row) throw new NotFoundException(ERR.orderNotFound); - if (row.status === 'UNKNOWN' && !row.lastCheckedAt) throw new ConflictException(ERR.checkFirst); if (row.status !== 'DRAFT' && row.status !== 'UNKNOWN') { throw new ConflictException(ERR.notCancelable); } + + if (row.status === 'UNKNOWN') { + const { environment, credentials } = await this.settings.getActiveCredentials(tenantId); + // Die Zugangsdaten des anderen Systems sind gerade nicht massgeblich. + if (row.environment !== environment) throw new ConflictException(ERR.environmentChanged); + const outcome = await this.reconcile(tenantId, credentials, row); + if (outcome === 'found') throw new ConflictException(ERR.orderFound); + if (outcome === 'inconclusive') throw new ConflictException(ERR.checkFirst); + } + const { count } = await tenantPrisma.domainsOrder.updateMany({ - where: { - id, - tenantId, - status: row.status, - ...(row.status === 'UNKNOWN' ? { lastCheckedAt: { not: null } } : {}), - }, + where: { id, tenantId, status: row.status }, data: { status: 'CANCELED', openKey: null }, }); if (count !== 1) throw new ConflictException(ERR.notCancelable); diff --git a/apps/api/src/domains/domains.controller.spec.ts b/apps/api/src/domains/domains.controller.spec.ts index 512bb61..5aae9fd 100644 --- a/apps/api/src/domains/domains.controller.spec.ts +++ b/apps/api/src/domains/domains.controller.spec.ts @@ -226,13 +226,18 @@ describe('DomainsController — Auftraege: Verhalten', () => { await c.listOrders(req('t1')); await c.createOrder(req('t1'), user, { domain: 'beispiel.de' } as any); await c.refreshOrders(req('t1')); - await c.submitOrder(req('t1'), user, id); + await c.submitOrder(req('t1'), user, id, { version: '2026-10-08T10:00:00.000Z' }); await c.cancelOrder(req('t1'), id); expect(orders.checkAvailability).toHaveBeenCalledWith('t1', 'beispiel.de'); expect(orders.listOrders).toHaveBeenCalledWith('t1'); expect(orders.createOrder).toHaveBeenCalledWith('t1', 'u1', { domain: 'beispiel.de' }); expect(orders.refreshOpenOrders).toHaveBeenCalledWith('t1'); - expect(orders.submitOrder).toHaveBeenCalledWith('t1', { id: 'u1', username: 'anna' }, id); + expect(orders.submitOrder).toHaveBeenCalledWith( + 't1', + { id: 'u1', username: 'anna' }, + id, + '2026-10-08T10:00:00.000Z', + ); expect(orders.cancelOrder).toHaveBeenCalledWith('t1', id); }); @@ -242,6 +247,8 @@ describe('DomainsController — Auftraege: Verhalten', () => { makeDirectory() as any, makeOrders() as any, ); - await expect(c.submitOrder(req(undefined), user, id)).rejects.toBeInstanceOf(ForbiddenException); + await expect( + c.submitOrder(req(undefined), user, id, { version: '2026-10-08T10:00:00.000Z' }), + ).rejects.toBeInstanceOf(ForbiddenException); }); }); diff --git a/apps/api/src/domains/domains.controller.ts b/apps/api/src/domains/domains.controller.ts index cb592e4..3acfa3c 100644 --- a/apps/api/src/domains/domains.controller.ts +++ b/apps/api/src/domains/domains.controller.ts @@ -20,7 +20,11 @@ import { DomainsOrdersService } from './domains-orders.service'; import { DomainsSettingsService } from './domains-settings.service'; import { AssignDomainsContactsDto, CreateDomainsContactDto } from './dto/domains-contact.dto'; import { DomainsCustomerDto } from './dto/domains-customer.dto'; -import { CheckAvailabilityDto, CreateDomainsOrderDto } from './dto/domains-order.dto'; +import { + CheckAvailabilityDto, + CreateDomainsOrderDto, + SubmitDomainsOrderDto, +} from './dto/domains-order.dto'; import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-settings.dto'; /** `?refresh=1` oder `?refresh=true` verlangt, den Zwischenspeicher zu umgehen. */ @@ -195,11 +199,13 @@ export class DomainsController { @Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SubmitDomainsOrderDto, ) { return this.orders.submitOrder( this.requireTenantId(req), { id: user.id, username: user.username }, id, + dto.version, ); } diff --git a/apps/api/src/domains/dto/domains-order.dto.spec.ts b/apps/api/src/domains/dto/domains-order.dto.spec.ts new file mode 100644 index 0000000..07f2b44 --- /dev/null +++ b/apps/api/src/domains/dto/domains-order.dto.spec.ts @@ -0,0 +1,19 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, expect, it } from 'vitest'; +import { SubmitDomainsOrderDto } from './domains-order.dto'; + +async function errorsOf(plain: Record) { + const errors = await validate(plainToInstance(SubmitDomainsOrderDto, plain)); + return errors.map((e) => e.property).sort(); +} + +describe('SubmitDomainsOrderDto', () => { + it('verlangt den Stand der Zusammenfassung als ISO-Zeitpunkt', async () => { + expect(await errorsOf({})).toEqual(['version']); + expect(await errorsOf({ version: 'gestern' })).toEqual(['version']); + expect(await errorsOf({ version: null })).toEqual(['version']); + expect(await errorsOf({ version: '2026-10-08T10:00:00.000Z' })).toEqual([]); + }); +}); diff --git a/apps/api/src/domains/dto/domains-order.dto.ts b/apps/api/src/domains/dto/domains-order.dto.ts index 6fee195..58af03d 100644 --- a/apps/api/src/domains/dto/domains-order.dto.ts +++ b/apps/api/src/domains/dto/domains-order.dto.ts @@ -3,6 +3,7 @@ import { ArrayMinSize, IsArray, IsInt, + IsISO8601, IsNotEmpty, IsString, MaxLength, @@ -50,3 +51,16 @@ export class CreateDomainsOrderDto { @MaxLength(253, { each: true }) nameServers!: string[]; } + +/** + * Verbindliche Bestaetigung: `version` ist der Stand des Entwurfs + * (`OrderSummaryView.version`), den der Benutzer in der Zusammenfassung gesehen + * hat. Ohne ihn wird nichts bestellt. + */ +export class SubmitDomainsOrderDto { + @IsString() + @IsNotEmpty() + @IsISO8601() + @MaxLength(40) + version!: string; +} diff --git a/apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx b/apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx index b8db55a..44602bf 100644 --- a/apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx +++ b/apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx @@ -102,6 +102,8 @@ export function OrdersTab({ canManage }: { canManage: boolean }) { await refresh(); } catch (e) { setDiscardError(e instanceof DomainsRequestError ? e.message : tc('requestFailed')); + // Beim Verwerfen hat Tessera frisch bei AutoDNS nachgesehen; der Zustand kann sich geaendert haben. + void refresh(); } } diff --git a/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx index 549d3fb..0eb5594 100644 --- a/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx +++ b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx @@ -124,6 +124,7 @@ function summary(over: Partial = {}): OrderSummary { }, nameServers: ['ns1.example.com', 'ns2.example.com'], periodYears: 1, + version: '2026-10-08T10:00:00.000Z', ...over, }; } @@ -324,7 +325,8 @@ describe('RegisterTab — Zusammenfassung und verbindliche Bestaetigung', () => fireEvent.click(button); fireEvent.click(button); expect(mockSubmit).toHaveBeenCalledTimes(1); - expect(mockSubmit).toHaveBeenCalledWith('o1'); + // Der Stand aus der Zusammenfassung geht mit: bestellt wird nur, was der Benutzer gesehen hat. + expect(mockSubmit).toHaveBeenCalledWith('o1', '2026-10-08T10:00:00.000Z'); expect(screen.getByRole('button', { name: 'Wird übermittelt …' })).toBeTruthy(); resolveSubmit(order({ status: 'SUBMITTED', jobStatus: 'RUNNING' })); await screen.findByText(/wird bearbeitet/); @@ -383,6 +385,18 @@ describe('RegisterTab — Zusammenfassung und verbindliche Bestaetigung', () => expect(screen.queryByRole('button', { name: 'Jetzt verbindlich registrieren' })).toBeNull(); }); + it('hat sich der Entwurf geaendert, zeigt der Dialog die Meldung des Servers und bestellt nichts', async () => { + const message = + 'Der Auftrag wurde inzwischen geändert. Bitte prüfen Sie die Zusammenfassung erneut, bevor Sie bestellen.'; + mockSubmit.mockRejectedValue(new DomainsRequestError(409, 'orderChanged', message)); + renderTab(); + await reachSummary(); + fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByRole('button', { name: 'Jetzt verbindlich registrieren' })); + expect(await screen.findByText(message)).toBeTruthy(); + expect(mockSubmit).toHaveBeenCalledTimes(1); + }); + it('"Abbrechen" ruft cancelOrder auf und beginnt neu', async () => { renderTab(); await reachSummary(); diff --git a/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx index c144d9a..baf0b9e 100644 --- a/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx +++ b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx @@ -194,7 +194,7 @@ export function RegisterTab({ setSubmitting(true); setSubmitError(null); try { - setResult(await submitOrder(summary.order.id)); + setResult(await submitOrder(summary.order.id, summary.version)); } catch (e) { // Die Sperre bleibt: der Auftrag koennte angekommen sein. Nur "Zurueck" hebt sie auf. setSubmitError(e instanceof DomainsRequestError ? e.message : t('submitFailed')); diff --git a/apps/web/src/lib/domains-api.ts b/apps/web/src/lib/domains-api.ts index 65d6e51..5a3557b 100644 --- a/apps/web/src/lib/domains-api.ts +++ b/apps/web/src/lib/domains-api.ts @@ -304,6 +304,8 @@ export interface OrderSummary { }; nameServers: string[]; periodYears: 1; + /** Stand des Entwurfs; geht beim verbindlichen Bestellen unveraendert zurueck. */ + version: string; } export interface CreateOrderInput { @@ -323,9 +325,16 @@ export function createOrder(input: CreateOrderInput): Promise { return request('/orders', { method: 'POST', json: input }); } -/** Verbindlich: schickt die Registrierung an AutoDNS (kostet im Live-System Geld). */ -export function submitOrder(id: string): Promise { - return request(`/orders/${encodeURIComponent(id)}/submit`, { method: 'POST' }); +/** + * Verbindlich: schickt die Registrierung an AutoDNS (kostet im Live-System Geld). + * `version` ist der Stand aus der Zusammenfassung; hat sich der Entwurf seither + * geaendert, lehnt der Server mit 409 ab. + */ +export function submitOrder(id: string, version: string): Promise { + return request(`/orders/${encodeURIComponent(id)}/submit`, { + method: 'POST', + json: { version }, + }); } export function cancelOrder(id: string): Promise { diff --git a/docs/anleitung-anwender.md b/docs/anleitung-anwender.md index fd88a1d..e4bd88e 100644 --- a/docs/anleitung-anwender.md +++ b/docs/anleitung-anwender.md @@ -193,7 +193,7 @@ Tessera schickt eine Registrierung **höchstens ein einziges Mal** an AutoDNS | Ergebnis ungeklärt | Tessera weiß nicht, ob AutoDNS die Bestellung angenommen hat (zum Beispiel wegen einer Verbindungsstörung). | | Verworfen | Ein Entwurf oder ein geprüfter unklarer Auftrag, den jemand mit „Verwalten“ verworfen hat. | -**„Ergebnis ungeklärt“ – bestellen Sie nicht erneut.** Die Bestellung kann bei AutoDNS angekommen sein, auch wenn die Antwort fehlte. Klicken Sie unter „Aufträge“ auf „Aktualisieren“: Tessera fragt dann bei AutoDNS nach, ob es die Domain oder einen passenden Auftrag gibt, und stellt den Auftrag entsprechend richtig. Findet Tessera weder die Domain noch einen Auftrag, dürfen Sie den Auftrag verwerfen – prüfen Sie zur Sicherheit vorher in AutoDNS selbst, dass die Domain nicht bestellt wurde. Verwerfen und neu bestellen ist erst danach sicher. +**„Ergebnis ungeklärt“ – bestellen Sie nicht erneut.** Die Bestellung kann bei AutoDNS angekommen sein, auch wenn die Antwort fehlte. Klicken Sie unter „Aufträge“ auf „Aktualisieren“: Tessera fragt dann bei AutoDNS nach, ob es die Domain oder einen passenden Auftrag gibt, und stellt den Auftrag entsprechend richtig. Findet Tessera weder die Domain noch einen Auftrag, dürfen Sie den Auftrag verwerfen – prüfen Sie zur Sicherheit vorher in AutoDNS selbst, dass die Domain nicht bestellt wurde. Beim Verwerfen fragt Tessera noch einmal im selben Moment bei AutoDNS nach; ein früheres Ergebnis genügt nicht. Gibt es die Domain oder einen Auftrag inzwischen doch, oder kann AutoDNS gerade nicht verlässlich antworten, wird nichts verworfen. Verwerfen und neu bestellen ist erst danach sicher. ### Proxmox