diff --git a/CLAUDE.md b/CLAUDE.md index 56fa593..01877e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,8 @@ cd web && npm run build # прод-сборка фронта - **Статус домена.** `last_check_status` ∈ `unknown|in_sync|drift|error`. Единый источник вычисления — `service.DeriveStatus`; константы в `internal/service`, планировщик использует их как алиасы. И ручной check, и планировщик пишут статус (ручной — без notify; notify только у планировщика по смене статуса). - **Шаблоны с плейсхолдером.** Шаблон хранит записи с `{{domain_name}}`; `tmpl.Materialize` подставляет имя зоны (без завершающей точки) при diff/apply, `tmpl.Parameterize` — обратно при snapshot зоны в шаблон. Материализация — единственная точка, в `service.resolve`. - **Ошибки провайдера наружу.** Провайдерские сбои оборачиваются в `service.ErrProviderUnavailable` → API отдаёт реальный текст провайдера (502); внутренние ошибки (decrypt/db/loader) остаются generic `internal error` (500). +- **Custom-записи.** Пометка живёт в `domain_custom_records` и привязана к домену. `Changeset.MarkCustom` метит только `Kind == Delete` и никогда read-only — шаблон побеждает: как только шаблон описывает ключ, пометка перестаёт действовать. `Actionable()/Updates()/Prunes()` исключают Custom, поэтому custom-запись не даёт drift и физически не может быть применена. +- **Импорт — синхронизация.** `store.ImportDomains` в одной транзакции создаёт домены новых зон и удаляет домены зон, исчезнувших у провайдера (скоуп — `provider_account_id`). Пустой список зон не удаляет ничего: он неотличим от временной потери доступа, а удаление уносит домены вместе с историей чеков и пометками. ### Security diff --git a/README.md b/README.md index 4c8d9c0..9d80a40 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ **перед** обновлениями — иначе провайдер отвергает конфликт (например `CNAME` на имени, где ещё жива `A`-запись). При ошибке показывается реальный ответ провайдера, а не generic-текст. +- **Custom-записи**: запись можно пометить как намеренно оставленную вне + шаблона — она уходит из Prunes, не считается drift и не может быть + применена, пока пометка не снята; как только шаблон начинает описывать + этот ключ сам, пометка автоматически перестаёт действовать. +- **Импорт зон — синхронизация**: повторный импорт аккаунта провайдера не + только заводит домены новых зон, но и удаляет домены зон, исчезнувших у + провайдера; пустой ответ провайдера ничего не удаляет. - **Расписание проверок**: планировщик периодически гоняет read-only check+notify (без Apply), пишет историю проверок и статус drift. - **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки. diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index baedfc6..39b0dd7 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -531,6 +531,35 @@ func TestImportZones_BadAccountUUID(t *testing.T) { } } +// TestImportResponseEmptyMarshalsToArrays guards the same белый-экран class +// of bug as TestChangesetResponseEmptyMarshalsToArrays (api_test.go), but for +// the import endpoint: a sync that created and removed nothing (empty zone +// list from the provider) must still marshal created/removed as [], not +// null — the frontend's .length/.map calls on the response would otherwise +// crash right after a no-op import. +func TestImportResponseEmptyMarshalsToArrays(t *testing.T) { + a, ts := newTenantTestAPI() + accID := uuid.New() + ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}} + a.Reg = &mockRegistry{zones: nil} + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/"+accID.String()+"/import", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, `"created":[]`) { + t.Fatalf("expected \"created\":[] in %s", body) + } + if !strings.Contains(body, `"removed":[]`) { + t.Fatalf("expected \"removed\":[] in %s", body) + } +} + func TestCreateDomain_BadProjectUUID(t *testing.T) { a, _ := newTenantTestAPI() router := NewRouter(a) diff --git a/web/src/pages/DomainsPage.test.tsx b/web/src/pages/DomainsPage.test.tsx index 80108ac..96c6947 100644 --- a/web/src/pages/DomainsPage.test.tsx +++ b/web/src/pages/DomainsPage.test.tsx @@ -88,6 +88,8 @@ test("после импорта показывает, сколько зон со const user = userEvent.setup() renderPage() + await screen.findByText("example.com.") + await user.click(await screen.findByRole("button", { name: /импортировать зоны/i })) expect(await screen.findByText(/создано 1, удалено 1/i)).toBeInTheDocument()