From 9f0938daeac54154dad7bc8baf0a198a9ec355b0 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 5 Jul 2026 12:54:52 +0700 Subject: [PATCH] fix: reject snapshot when template already attached (409); handle domains-load error; drop orphaned useDeleteDomain Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/api/handlers.go | 10 +++++++++ internal/api/tenant_test.go | 29 +++++++++++++++++++++++++++ web/src/hooks/useApi.ts | 11 ---------- web/src/pages/DomainDiffPage.test.tsx | 19 ++++++++++++++++-- web/src/pages/DomainDiffPage.tsx | 28 ++++++++++++++++++++++---- 5 files changed, 80 insertions(+), 17 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 7e75e23..e46ce21 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -114,6 +114,16 @@ func (a *API) handleTemplateFromZone(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusNotFound, "домен не найден") return } + // This endpoint only makes sense for a domain with no template attached + // yet — it snapshots the zone's live state into a brand-new template. + // If a template is already bound, re-attaching a fresh snapshot would + // silently orphan the existing one; re-pointing a domain to a different + // template is a separate, explicit action and must not happen as a side + // effect of a retried/duplicate POST here. + if dom.TemplateID != nil { + writeErr(w, http.StatusConflict, "шаблон уже привязан") + return + } recs, err := a.Svc.ZoneRecords(r.Context(), pid, did) if err != nil { log.Printf("api: template-from-zone: zone records failed: %v", err) diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index 9a82bc2..bf1e140 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -689,6 +689,35 @@ func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) { } } +// TestTemplateFromZone_AlreadyAttachedReturns409 covers the guard against +// re-snapshotting a domain that already has a template bound: a direct +// POST (e.g. curl or a client retry) must not silently create a new +// template and re-point the domain, orphaning the previously attached one. +func TestTemplateFromZone_AlreadyAttachedReturns409(t *testing.T) { + a, ts := newTenantTestAPI() + domID := uuid.New() + existingTemplateID := uuid.New() + ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1", TemplateID: &existingTemplateID}} + a.Svc = &mockCheckApplier{zoneRecords: []model.Record{ + {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, + }} + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/template-from-zone", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + if ts.createTemplate != nil { + t.Fatalf("expected CreateTemplate NOT to be called, got %+v", ts.createTemplate) + } + if ts.domains[0].TemplateID == nil || *ts.domains[0].TemplateID != existingTemplateID { + t.Fatalf("expected existing template binding untouched, got %+v", ts.domains[0].TemplateID) + } +} + // TestZoneRecords_ProviderErrorReturns502 covers the provider-failure path: // an error wrapping service.ErrProviderUnavailable (i.e. GetRecords itself // failed) must surface as 502 (bad gateway), not a generic 500 or 404. diff --git a/web/src/hooks/useApi.ts b/web/src/hooks/useApi.ts index ed7271d..3250ec1 100644 --- a/web/src/hooks/useApi.ts +++ b/web/src/hooks/useApi.ts @@ -111,17 +111,6 @@ export function useSetDomainTemplate() { onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }), }) } -export function useDeleteDomain() { - const { project } = useAuth() - const qc = useQueryClient() - return useMutation({ - mutationFn: (id: string) => { - const pid = requireProjectId(project) - return api.deleteDomain(pid, id) - }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }), - }) -} export function useCheckDomain(id: string, enabled = true) { const { project } = useAuth() return useQuery({ diff --git a/web/src/pages/DomainDiffPage.test.tsx b/web/src/pages/DomainDiffPage.test.tsx index f6eb08e..ba9e758 100644 --- a/web/src/pages/DomainDiffPage.test.tsx +++ b/web/src/pages/DomainDiffPage.test.tsx @@ -19,8 +19,7 @@ const domainWithTemplate: Domain = { lastCheckStatus: "drift", } -function renderPage() { - const qc = new QueryClient() +function renderPage(qc: QueryClient = new QueryClient()) { return render( @@ -111,6 +110,22 @@ test("домен без шаблона показывает записи зон expect(checkSpy).not.toHaveBeenCalled() }) +test("ошибка загрузки списка доменов показывает баннер ошибки и не уходит в ветку без шаблона", async () => { + vi.spyOn(api, "listDomains").mockRejectedValue(new Error("network down")) + const checkSpy = vi.spyOn(api, "checkDomain") + const zoneRecordsSpy = vi.spyOn(api, "zoneRecords") + // retry:false — иначе react-query переретраит listDomains с экспоненциальной + // задержкой и findByText не успевает дождаться финального isError. + renderPage(new QueryClient({ defaultOptions: { queries: { retry: false } } })) + + expect(await screen.findByText(/не удалось загрузить список доменов/i)).toBeInTheDocument() + expect(screen.getByText("network down")).toBeInTheDocument() + + expect(screen.queryByText(/шаблон не привязан/i)).not.toBeInTheDocument() + expect(checkSpy).not.toHaveBeenCalled() + expect(zoneRecordsSpy).not.toHaveBeenCalled() +}) + test("создание шаблона из зоны вызывает templateFromZone", async () => { vi.spyOn(api, "listDomains").mockResolvedValue([ { id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null }, diff --git a/web/src/pages/DomainDiffPage.tsx b/web/src/pages/DomainDiffPage.tsx index 6da5ad4..6c179b2 100644 --- a/web/src/pages/DomainDiffPage.tsx +++ b/web/src/pages/DomainDiffPage.tsx @@ -31,10 +31,11 @@ export function DomainDiffPage() { const check = useCheckDomain(id, hasTemplate) const apply = useApplyDomain(id) - // Пока список доменов не загружен, hasTemplate недостоверно (false по - // умолчанию из-за domain === undefined) — не дёргаем provider-запрос - // записей зоны, пока не будет точно известно, что шаблона нет. - const zoneRecords = useZoneRecords(id, !domains.isPending && !hasTemplate) + // Пока список доменов не загружен ИЛИ загрузка упала ошибкой, hasTemplate + // недостоверно (false по умолчанию из-за domain === undefined) — не + // дёргаем provider-запрос записей зоны, пока не будет точно известно + // (успешный ответ), что шаблона нет. + const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate) const createTemplateFromZone = useCreateTemplateFromZone() const [applyPrunes, setApplyPrunes] = useState(false) const pruneCheckboxId = useId() @@ -64,6 +65,25 @@ export function DomainDiffPage() { ) } + // Список доменов не загрузился — hasTemplate тут недостоверно (domain + // === undefined из-за domains.data === undefined даёт hasTemplate=false), + // поэтому без этой проверки страница молча уходит в ветку «без шаблона» + // и дёргает zoneRecords для несуществующего состояния. Показываем ошибку + // и не рендерим ни одну из веток решения. + if (domains.isError) { + return ( +
+
+ +
+ Не удалось загрузить список доменов + {domains.error.message} +
+
+
+ ) + } + return (