fix: reject snapshot when template already attached (409); handle domains-load error; drop orphaned useDeleteDomain
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -114,6 +114,16 @@ func (a *API) handleTemplateFromZone(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusNotFound, "домен не найден")
|
writeErr(w, http.StatusNotFound, "домен не найден")
|
||||||
return
|
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)
|
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("api: template-from-zone: zone records failed: %v", err)
|
log.Printf("api: template-from-zone: zone records failed: %v", err)
|
||||||
|
|||||||
@@ -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:
|
// TestZoneRecords_ProviderErrorReturns502 covers the provider-failure path:
|
||||||
// an error wrapping service.ErrProviderUnavailable (i.e. GetRecords itself
|
// an error wrapping service.ErrProviderUnavailable (i.e. GetRecords itself
|
||||||
// failed) must surface as 502 (bad gateway), not a generic 500 or 404.
|
// failed) must surface as 502 (bad gateway), not a generic 500 or 404.
|
||||||
|
|||||||
@@ -111,17 +111,6 @@ export function useSetDomainTemplate() {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }),
|
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) {
|
export function useCheckDomain(id: string, enabled = true) {
|
||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ const domainWithTemplate: Domain = {
|
|||||||
lastCheckStatus: "drift",
|
lastCheckStatus: "drift",
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage(qc: QueryClient = new QueryClient()) {
|
||||||
const qc = new QueryClient()
|
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
@@ -111,6 +110,22 @@ test("домен без шаблона показывает записи зон
|
|||||||
expect(checkSpy).not.toHaveBeenCalled()
|
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 () => {
|
test("создание шаблона из зоны вызывает templateFromZone", async () => {
|
||||||
vi.spyOn(api, "listDomains").mockResolvedValue([
|
vi.spyOn(api, "listDomains").mockResolvedValue([
|
||||||
{ id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null },
|
{ id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null },
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ export function DomainDiffPage() {
|
|||||||
|
|
||||||
const check = useCheckDomain(id, hasTemplate)
|
const check = useCheckDomain(id, hasTemplate)
|
||||||
const apply = useApplyDomain(id)
|
const apply = useApplyDomain(id)
|
||||||
// Пока список доменов не загружен, hasTemplate недостоверно (false по
|
// Пока список доменов не загружен ИЛИ загрузка упала ошибкой, hasTemplate
|
||||||
// умолчанию из-за domain === undefined) — не дёргаем provider-запрос
|
// недостоверно (false по умолчанию из-за domain === undefined) — не
|
||||||
// записей зоны, пока не будет точно известно, что шаблона нет.
|
// дёргаем provider-запрос записей зоны, пока не будет точно известно
|
||||||
const zoneRecords = useZoneRecords(id, !domains.isPending && !hasTemplate)
|
// (успешный ответ), что шаблона нет.
|
||||||
|
const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate)
|
||||||
const createTemplateFromZone = useCreateTemplateFromZone()
|
const createTemplateFromZone = useCreateTemplateFromZone()
|
||||||
const [applyPrunes, setApplyPrunes] = useState(false)
|
const [applyPrunes, setApplyPrunes] = useState(false)
|
||||||
const pruneCheckboxId = useId()
|
const pruneCheckboxId = useId()
|
||||||
@@ -64,6 +65,25 @@ export function DomainDiffPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Список доменов не загрузился — hasTemplate тут недостоверно (domain
|
||||||
|
// === undefined из-за domains.data === undefined даёт hasTemplate=false),
|
||||||
|
// поэтому без этой проверки страница молча уходит в ветку «без шаблона»
|
||||||
|
// и дёргает zoneRecords для несуществующего состояния. Показываем ошибку
|
||||||
|
// и не рендерим ни одну из веток решения.
|
||||||
|
if (domains.isError) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||||
|
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium">Не удалось загрузить список доменов</span>
|
||||||
|
<span className="font-dns text-xs opacity-90">{domains.error.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user