docs: custom records and import-as-sync invariants

Document the two Task 1-9 features in CLAUDE.md invariants and README
features. Also closes two review gaps: a Go test proving importResponse
marshals created/removed as [] (never null) on a no-op import, and a
frontend test-race fix in DomainsPage.test.tsx where the import button
was clicked before its disabled state cleared.
This commit is contained in:
2026-08-19 18:45:52 +07:00
parent d6d91f8826
commit 58a57ff4d0
4 changed files with 40 additions and 0 deletions
+2
View File
@@ -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
+7
View File
@@ -23,6 +23,13 @@
**перед** обновлениями — иначе провайдер отвергает конфликт (например
`CNAME` на имени, где ещё жива `A`-запись). При ошибке показывается
реальный ответ провайдера, а не generic-текст.
- **Custom-записи**: запись можно пометить как намеренно оставленную вне
шаблона — она уходит из Prunes, не считается drift и не может быть
применена, пока пометка не снята; как только шаблон начинает описывать
этот ключ сам, пометка автоматически перестаёт действовать.
- **Импорт зон — синхронизация**: повторный импорт аккаунта провайдера не
только заводит домены новых зон, но и удаляет домены зон, исчезнувших у
провайдера; пустой ответ провайдера ничего не удаляет.
- **Расписание проверок**: планировщик периодически гоняет read-only
check+notify (без Apply), пишет историю проверок и статус drift.
- **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки.
+29
View File
@@ -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)
+2
View File
@@ -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()