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:
@@ -42,6 +42,8 @@ cd web && npm run build # прод-сборка фронта
|
|||||||
- **Статус домена.** `last_check_status` ∈ `unknown|in_sync|drift|error`. Единый источник вычисления — `service.DeriveStatus`; константы в `internal/service`, планировщик использует их как алиасы. И ручной check, и планировщик пишут статус (ручной — без notify; notify только у планировщика по смене статуса).
|
- **Статус домена.** `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`.
|
- **Шаблоны с плейсхолдером.** Шаблон хранит записи с `{{domain_name}}`; `tmpl.Materialize` подставляет имя зоны (без завершающей точки) при diff/apply, `tmpl.Parameterize` — обратно при snapshot зоны в шаблон. Материализация — единственная точка, в `service.resolve`.
|
||||||
- **Ошибки провайдера наружу.** Провайдерские сбои оборачиваются в `service.ErrProviderUnavailable` → API отдаёт реальный текст провайдера (502); внутренние ошибки (decrypt/db/loader) остаются generic `internal error` (500).
|
- **Ошибки провайдера наружу.** Провайдерские сбои оборачиваются в `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
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,13 @@
|
|||||||
**перед** обновлениями — иначе провайдер отвергает конфликт (например
|
**перед** обновлениями — иначе провайдер отвергает конфликт (например
|
||||||
`CNAME` на имени, где ещё жива `A`-запись). При ошибке показывается
|
`CNAME` на имени, где ещё жива `A`-запись). При ошибке показывается
|
||||||
реальный ответ провайдера, а не generic-текст.
|
реальный ответ провайдера, а не generic-текст.
|
||||||
|
- **Custom-записи**: запись можно пометить как намеренно оставленную вне
|
||||||
|
шаблона — она уходит из Prunes, не считается drift и не может быть
|
||||||
|
применена, пока пометка не снята; как только шаблон начинает описывать
|
||||||
|
этот ключ сам, пометка автоматически перестаёт действовать.
|
||||||
|
- **Импорт зон — синхронизация**: повторный импорт аккаунта провайдера не
|
||||||
|
только заводит домены новых зон, но и удаляет домены зон, исчезнувших у
|
||||||
|
провайдера; пустой ответ провайдера ничего не удаляет.
|
||||||
- **Расписание проверок**: планировщик периодически гоняет read-only
|
- **Расписание проверок**: планировщик периодически гоняет read-only
|
||||||
check+notify (без Apply), пишет историю проверок и статус drift.
|
check+notify (без Apply), пишет историю проверок и статус drift.
|
||||||
- **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки.
|
- **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки.
|
||||||
|
|||||||
@@ -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) {
|
func TestCreateDomain_BadProjectUUID(t *testing.T) {
|
||||||
a, _ := newTenantTestAPI()
|
a, _ := newTenantTestAPI()
|
||||||
router := NewRouter(a)
|
router := NewRouter(a)
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ test("после импорта показывает, сколько зон со
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
|
await screen.findByText("example.com.")
|
||||||
|
|
||||||
await user.click(await screen.findByRole("button", { name: /импортировать зоны/i }))
|
await user.click(await screen.findByRole("button", { name: /импортировать зоны/i }))
|
||||||
|
|
||||||
expect(await screen.findByText(/создано 1, удалено 1/i)).toBeInTheDocument()
|
expect(await screen.findByText(/создано 1, удалено 1/i)).toBeInTheDocument()
|
||||||
|
|||||||
Reference in New Issue
Block a user