Compare commits

..
17 Commits
Author SHA1 Message Date
vasyansk 5e12c4729b Merge feat/domains-filters: фильтры доменов по шаблону и статусу, сортировка по имени зоны 2026-08-19 19:30:09 +07:00
vasyansk c084c7cf84 Merge feat/custom-records: custom-записи в диффе и очистка исчезнувших зон 2026-08-19 19:30:09 +07:00
vasyanskandClaude Opus 5 caebc06076 feat(web): filter domains by template and status
The domains list had no way to narrow itself down, so an operator with
dozens of zones had to scan the whole table to find the drifted ones.

Add two client-side filters above the table (the full list already
arrives in one request, so server-side filtering would only add contract
surface). The status filter compares against the same derived value the
row's badge renders — a domain with no template shows "без шаблона" and
must not match "drift" just because it kept a stale last_check_status
from before its template was detached, so that derivation lives in one
function used by both.

Sorting is now decided in the store: ListDomains orders by zone_name
instead of created_at, so every consumer of the list gets the same order
and the filters never reorder anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
2026-08-19 19:24:58 +07:00
vasyanskandClaude Opus 5 98be357da8 fix(web): handle empty-body 2xx responses; fix(store): sync DeleteDomainsNotInZones query with generated columns
req() in the frontend api client called res.json() on every non-204 2xx
response, which throws SyntaxError on the empty body returned by
POST .../customs (201) and similar endpoints. Read the response body once
as text and parse it only when non-empty, so any bodyless 2xx resolves
instead of rejecting. Added web/src/api/client.test.ts coverage that
exercises the real req() against a stubbed fetch for both the 201
(addCustom) and 204 (removeCustom) empty-body cases.

Also brought internal/store/queries/domains.sql's DeleteDomainsNotInZones
query back in sync with the hand-maintained internal/store/db/domains.sql.go
(RETURNING * -> explicit column list) since sqlc isn't installed in this
environment and the two files are kept aligned by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
2026-08-19 19:04:13 +07:00
vasyansk 58a57ff4d0 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.
2026-08-19 18:45:52 +07:00
vasyansk d6d91f8826 feat(web): show created and removed counts after zone import 2026-08-19 18:37:56 +07:00
vasyansk 4d05271f10 feat: remove domains whose zones vanished at the provider on import 2026-08-19 18:32:10 +07:00
vasyansk e00648baf7 feat(web): customs section with mark and unmark actions 2026-08-19 18:22:32 +07:00
vasyansk 404f869d42 feat(web): api client and hooks for custom record marks 2026-08-19 18:16:42 +07:00
vasyansk da267b3cef feat(api): endpoints to mark and unmark custom records 2026-08-19 18:10:46 +07:00
vasyansk 208e73980d feat(service): apply custom marks to the computed diff 2026-08-19 18:02:55 +07:00
vasyansk 618d5c5bb6 feat(store): persist per-domain custom record marks 2026-08-19 17:57:06 +07:00
vasyansk e675c17123 feat(diff): mark records deliberately kept outside the template 2026-08-19 17:51:19 +07:00
vasyanskandClaude Opus 5 8f57a25af0 docs: implementation plan for custom records and orphan cleanup
Ten TDD tasks: mark support in the diff engine, the domain_custom_records
table and its store wrappers, wiring marks through LoadDomain into
resolve, the mark/unmark endpoints, the frontend client and Customs
section, import-as-sync with the empty-zone-list guard, and the
{created, removed} import contract on both sides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
2026-08-19 17:48:02 +07:00
vasyanskandClaude Opus 5 eead9f3f3d docs: spec for custom records and orphan domain cleanup
Two operational problems on the domain check page: records deliberately
added outside the template keep showing up as prunes (drift + scheduler
notifications), and domains whose zones were deleted at the provider are
never removed by a re-import.

Design decisions: custom marks are scoped per domain, the template wins
over a mark (a mark only applies to Kind == Delete), marks live in their
own domain_custom_records table, and orphan domains are removed inside
the import transaction with a guard that skips deletion when the provider
returns zero zones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
2026-08-19 17:42:53 +07:00
vasyansk ba58921dc7 Merge feat/clone-template: клонирование существующих шаблонов 2026-08-19 17:17:39 +07:00
vasyanskandClaude Opus 5 dcaf3e65f5 feat(web): clone existing templates
Add a "Clone" action to the templates table: it fills the create form
with a copy of the selected template's name and records instead of
creating the copy right away, so the operator can adjust the name or
records before saving. No new API — the existing POST /templates is
reused.

Cloning resets editingId so the submit goes to createTemplate even when
edit mode was active, and the copy name is picked to be free among the
listed templates ("X (копия)", then "X (копия 2)"). Template names have
no unique constraint in the DB, so this is a UX hint, not a guarantee.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
2026-08-19 17:17:34 +07:00
37 changed files with 3387 additions and 79 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 статус доставки.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,238 @@
# Custom-записи в диффе и очистка доменов удалённых зон
Дата: 2026-08-19
Статус: утверждён к реализации
## Задача
Две связанные проблемы эксплуатации, обе всплывают на странице `DOMAIN / CHECK`.
1. **Осознанные записи вне шаблона.** Оператор добавляет в зону запись, которой
нет в шаблоне (`CNAME admin.example.ru.`, `app`, `dav`). Каждый чек кладёт её
в `PRUNES`, домен получает статус `drift`, планировщик шлёт уведомление.
Убрать шум можно только внеся запись в шаблон — но шаблон общий для многих
зон, а запись специфична для одной. Нужна возможность пометить такую запись
«это осознанно, не проверять», с обратимым действием.
2. **Домены исчезнувших зон.** `ImportDomains` только создаёт домены
(`ON CONFLICT DO NOTHING`). Если зона удалена у провайдера, домен остаётся в
БД навсегда: планировщик чекает его, получает ошибку провайдера, домен висит
в статусе `error` и генерирует уведомления. Переимпорт ситуацию не лечит.
## Принятые решения
| Вопрос | Решение |
|---|---|
| Скоуп custom-записей | Привязка к домену. Исключение действует только в своей зоне |
| Конфликт с шаблоном | Шаблон побеждает: пометка действует только на `Kind == Delete` |
| Хранение | Отдельная таблица `domain_custom_records` |
| Orphan-домены | Удаляются в транзакции переимпорта, с гардом на пустой ответ провайдера |
## Часть 1. Custom-записи
### Модель данных
Миграция `0005_domain_custom_records.sql`:
```sql
CREATE TABLE domain_custom_records (
domain_id uuid NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
record_key text NOT NULL,
note text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (domain_id, record_key)
);
```
`record_key` — тот же `RecordDiff.Key()` = `"ТИП имя."` (нормализованный,
материализованный: с подставленным именем зоны, без `{{domain_name}}`). Ключ
идентифицирует RRset целиком, а не конкретные значения: если значения записи в
зоне изменятся, она останется скрытой. Это осознанный выбор — «эта запись живёт
вне шаблона, её содержимое сервис не контролирует».
Составной PK делает добавление идемпотентным (`ON CONFLICT DO NOTHING`).
Каскад от `domains` убирает строки вместе с доменом (в том числе при очистке
orphan-доменов из части 2).
`note` заполняется пустой строкой — колонка задел под «зачем добавлено»; UI её
пока не пишет и не показывает.
### Store
Запросы в `internal/store/queries/customs.sql`:
- `AddCustomRecord``INSERT ... ON CONFLICT (domain_id, record_key) DO NOTHING`
- `DeleteCustomRecord``DELETE WHERE domain_id = $1 AND record_key = $2`
- `ListCustomKeys``SELECT record_key ... WHERE domain_id = $1 ORDER BY record_key`
Тенант-скоуп обеспечивается на уровне store-методов: `AddCustom`/`DeleteCustom`
принимают `(domainID, projectID, key)` и сперва делают скоупленный
`GetDomain(domainID, projectID)` — тем же приёмом, что `SetDomainTemplate`
проверяет принадлежность шаблона. Домен чужого проекта → `pgx.ErrNoRows` → 404.
`internal/store/db/*.sql.go` правится вручную (sqlc в среде нет): порядок колонок
в SQL-строке, в `*Row`-структуре и в `row.Scan(...)` обязан совпадать 1:1.
### Дифф
`internal/diff`:
```go
type RecordDiff struct {
// ...
ReadOnly bool // NS/SOA — показываются, но не применяются
Custom bool // осознанная запись вне шаблона — показывается, но не считается дрифтом
}
// MarkCustom помечает диффы, чей ключ есть в keys, как Custom. Помечаются
// ТОЛЬКО Kind == Delete и !ReadOnly: шаблон побеждает — как только шаблон
// начинает описывать этот ключ, запись возвращается в Updates/InSync, а
// лежащая в БД пометка перестаёт действовать (но не удаляется).
func (c *Changeset) MarkCustom(keys []string)
// Customs возвращает помеченные диффы. Не пересекается с Updates()/Prunes().
func (c Changeset) Customs() []RecordDiff
```
`Actionable()`, `Updates()`, `Prunes()` пропускают `Custom` наравне с `ReadOnly`.
Следствия: `service.DeriveStatus` даёт `in_sync`, планировщик по таким доменам
молчит, а `service.Apply` физически не может применить custom-ключ — он
итерирует `cs.Prunes()`, откуда custom исключён (защита от фронта, приславшего
ключ со старого снапшота).
Инвариант, который держим тестом: `Updates()` и `Prunes()` по-прежнему разбивают
`Actionable()` на два непересекающихся множества, а `Customs()` и `ReadOnly`
живут вне `Actionable()`.
### Сервис
`service.DomainRef` получает поле `CustomKeys []string`; `store.LoadDomain`
дочитывает их отдельным запросом `ListCustomKeys`. `resolve()` вызывает
`cs.MarkCustom(ref.CustomKeys)` сразу после `diff.Diff(...)` — единственная точка
пометки, как `tmpl.Materialize` для плейсхолдеров.
### API
Обе ручки — под `RequireAuth` + `RequireProjectAccess`, домен грузится парой
`(did, pid)`:
```
POST /api/projects/{pid}/domains/{did}/customs {"key":"CNAME admin.example.ru."} → 201
DELETE /api/projects/{pid}/domains/{did}/customs?key=CNAME%20admin.example.ru. → 204
```
Пустой `key` → 400. Домен не найден в проекте → 404. Ключ в теле POST и в
query-параметре DELETE, а не в path-сегменте: ключ содержит пробел и точки, и
path-сегмент потребовал бы двойного кодирования.
Ответ чека расширяется полем `customs`, инициализируемым пустым слайсом (nil
даёт JSON `null`, на котором падает `.map` на фронте):
```go
type changesetResponse struct {
Updates []recordView `json:"updates"`
Prunes []recordView `json:"prunes"`
Customs []recordView `json:"customs"`
ReadOnly []recordView `json:"readOnly"`
InSync int `json:"inSyncCount"`
}
```
`recordView` получает поле `custom bool` — по симметрии с `readOnly`, чтобы
строку можно было отрисовать правильно вне зависимости от секции.
### Фронт
`ChangesetResponse` и `RecordView` в `web/src/api/types.ts` — плюс `customs` и
`custom`. Хуки `useAddCustom(domainId)` / `useRemoveCustom(domainId)`
инвалидируют ключ запроса чека.
`DiffView` получает четвёртый тон `custom` (иконка `BookmarkCheck`, свой
CSS-токен `--diff-custom`), секция рендерится между `Prunes` и `Read-only`.
Чекбоксов в ней нет — записи не применяются.
Действия на строках:
- в `Prunes` — кнопка «В customs», `aria-label={`В customs ${type} ${name}`}`
- в `Customs` — кнопка «Вернуть в дифф» (`Undo2`),
`aria-label={`Вернуть в дифф ${type} ${name}`}`
`RecordRow` получает необязательный слот `action?: ReactNode` справа от имени —
секции `update`/`readonly` его не передают.
## Часть 2. Очистка доменов удалённых зон
### Поведение
`ImportDomains` переименовывается по смыслу в синхронизацию: в одной транзакции
1. создаются домены для новых зон (как сейчас, `ON CONFLICT DO NOTHING`);
2. удаляются домены **этого** `provider_account_id`, чьих `zone_id` нет в ответе
провайдера.
Скоуп удаления — аккаунт, а не проект: у проекта может быть несколько
provider-аккаунтов, и зоны одного не должны влиять на домены другого.
### Гард на пустой ответ
Если `ListZones` вернул ноль зон — не удаляется ничего. Пустой список
неотличим от «аккаунт временно потерял доступ к зонам», а ценой ошибки будет
удаление всех доменов аккаунта вместе с историей чеков. Ошибка `ListZones`
поднимается наружу и до store не доходит, так что случай «частичный список без
ошибки» остаётся только теоретическим (в клиенте Selectel — при неувеличивающемся
`next_offset`); гард на пустой ответ закрывает практически значимую часть риска.
### Что удаляется каскадом
`check_runs` (история чеков) и `domain_custom_records` — обе таблицы ссылаются на
`domains` с `ON DELETE CASCADE`. Привязка шаблона у удаляемого домена просто
исчезает; сам шаблон не трогается. Операция необратима — поэтому и нужен гард.
### Контракт ответа
```go
type importResponse struct {
Created []domainResponse `json:"created"`
Removed []domainResponse `json:"removed"`
}
```
Это ломающее изменение: сейчас ручка отдаёт голый массив созданных доменов.
Фронт правится в том же коммите, иначе задеплоенный бандл получит объект там,
где ждёт массив. UI показывает «Создано N, удалено M».
## Тесты
**Go, без Docker:**
- `internal/diff``MarkCustom` метит только `Delete`; шаблон побеждает (ключ,
описанный шаблоном, остаётся в `Updates`); `Actionable/Updates/Prunes`
исключают `Custom`; `Customs()` возвращает помеченные
- `internal/service``resolve` метит диффы ключами из `DomainRef`; `Apply` не
применяет custom-ключ, присланный в `req.Prunes`; `DeriveStatus` = `in_sync`,
когда единственное расхождение — custom
- `internal/api` — POST/DELETE customs (201/204), пустой ключ → 400, чужой домен
→ 404, `customs` присутствует в ответе чека и не `null`; import отдаёт
`{created, removed}`
**Go, с Docker (testcontainers):**
- `internal/store` — add идемпотентен, delete снимает пометку, `ListCustomKeys`
скоуплен по домену, каскад при удалении домена; синхронизация импорта удаляет
только домены своего аккаунта и ничего не удаляет при пустом списке зон
**Фронт (Vitest + RTL):**
- секция `CUSTOMS` рендерится и не содержит чекбоксов
- «В customs» на строке prune дёргает мутацию с ключом записи
- «Вернуть в дифф» на строке custom дёргает мутацию удаления
- страница аккаунтов показывает «Создано N, удалено M» после импорта
## За рамками
- пометка по значению записи, а не по типу+имени
- массовое добавление в customs одним действием
- авто-очистка ключей, чьи записи исчезли из зоны (мусор безвреден: дифф их не
порождает, а `note`/`created_at` дают контекст при разборе)
- редактирование `note` из UI
- dry-run и подтверждение удаления orphan-доменов
+12 -1
View File
@@ -48,13 +48,22 @@ type TenantStore interface {
// domain's zone name (for the generated template's name) before creating it.
GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error)
DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error)
// ImportDomains synchronises an account's zones with its domains: new
// zones become domains, domains of vanished zones are deleted. Returns
// (created, removed).
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error)
SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error)
// SetDomainStatus persists the outcome of a manual check (handleCheck) so
// the domain's badge reflects reality immediately, instead of staying
// "unknown" until the scheduler's next tick. Scoped by projectID so a
// foreign domain ID can never have its status overwritten (IDOR-on-write).
SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error
// AddCustomRecord/DeleteCustomRecord manage the per-domain marks that keep
// a record out of the diff. Scoped by projectID: marking a domain outside
// the caller's project must fail (IDOR-on-write).
AddCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error
DeleteCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error
}
// Cipher encrypts/decrypts provider account secrets. *crypto.Cipher satisfies it.
@@ -132,6 +141,8 @@ func NewRouter(a *API) http.Handler {
r.Get("/history", a.handleDomainHistory)
r.Get("/records", a.handleZoneRecords)
r.Post("/template-from-zone", a.handleTemplateFromZone)
r.Post("/customs", a.handleAddCustom)
r.Delete("/customs", a.handleDeleteCustom)
})
})
+1 -1
View File
@@ -382,7 +382,7 @@ func TestChangesetResponseEmptyMarshalsToArrays(t *testing.T) {
t.Fatal(err)
}
s := string(b)
for _, want := range []string{`"updates":[]`, `"prunes":[]`, `"readOnly":[]`} {
for _, want := range []string{`"updates":[]`, `"prunes":[]`, `"customs":[]`, `"readOnly":[]`} {
if !strings.Contains(s, want) {
t.Fatalf("expected %s in %s", want, s)
}
+14 -1
View File
@@ -44,6 +44,13 @@ type applyRequest struct {
Prunes []string `json:"prunes"`
}
// customRequest is the body of POST .../customs — the key travels here
// (never as a path segment) because it contains a space and dots, e.g.
// "CNAME admin.example.com.".
type customRequest struct {
Key string `json:"key"`
}
type recordView struct {
Key string `json:"key"`
Kind string `json:"kind"`
@@ -52,17 +59,19 @@ type recordView struct {
Desired []string `json:"desired,omitempty"`
Actual []string `json:"actual,omitempty"`
ReadOnly bool `json:"readOnly"`
Custom bool `json:"custom"`
}
type changesetResponse struct {
Updates []recordView `json:"updates"`
Prunes []recordView `json:"prunes"`
Customs []recordView `json:"customs"`
ReadOnly []recordView `json:"readOnly"`
InSync int `json:"inSyncCount"`
}
func toRecordView(d diff.RecordDiff) recordView {
rv := recordView{Key: d.Key(), Kind: string(d.Kind), Type: string(d.Type), Name: d.Name, ReadOnly: d.ReadOnly}
rv := recordView{Key: d.Key(), Kind: string(d.Kind), Type: string(d.Type), Name: d.Name, ReadOnly: d.ReadOnly, Custom: d.Custom}
if d.Desired != nil {
rv.Desired = d.Desired.Values
}
@@ -80,6 +89,7 @@ func toChangesetResponse(cs diff.Changeset) changesetResponse {
resp := changesetResponse{
Updates: []recordView{},
Prunes: []recordView{},
Customs: []recordView{},
ReadOnly: []recordView{},
}
for _, d := range cs.Updates() {
@@ -88,6 +98,9 @@ func toChangesetResponse(cs diff.Changeset) changesetResponse {
for _, d := range cs.Prunes() {
resp.Prunes = append(resp.Prunes, toRecordView(d))
}
for _, d := range cs.Customs() {
resp.Customs = append(resp.Customs, toRecordView(d))
}
for _, d := range cs.Diffs {
if d.ReadOnly {
resp.ReadOnly = append(resp.ReadOnly, toRecordView(d))
+52
View File
@@ -98,6 +98,58 @@ func (a *API) handleApply(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
}
// handleAddCustom marks an RRset as deliberately kept outside the template:
// it moves from Prunes to its own section, stops counting as drift and can
// never be applied. The key travels in the body, not the path — it contains
// a space and dots ("CNAME admin.example.com.").
func (a *API) handleAddCustom(w http.ResponseWriter, r *http.Request) {
pid, _ := projectIDFrom(r.Context())
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
var req customRequest
if !decodeBody(w, r, &req) {
return
}
if req.Key == "" {
writeErr(w, http.StatusBadRequest, "key is required")
return
}
if err := a.Store.AddCustomRecord(r.Context(), did, pid, req.Key); err != nil {
// Either the domain doesn't exist or it belongs to another project —
// both are "not found" from this tenant's point of view.
log.Printf("api: add custom record failed: %v", err)
writeErr(w, http.StatusNotFound, "domain not found")
return
}
w.WriteHeader(http.StatusCreated)
}
// handleDeleteCustom removes the mark, returning the record to the regular
// diff. The key comes as a query parameter for the same reason it comes in
// the body on POST.
func (a *API) handleDeleteCustom(w http.ResponseWriter, r *http.Request) {
pid, _ := projectIDFrom(r.Context())
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
key := r.URL.Query().Get("key")
if key == "" {
writeErr(w, http.StatusBadRequest, "key is required")
return
}
if err := a.Store.DeleteCustomRecord(r.Context(), did, pid, key); err != nil {
log.Printf("api: delete custom record failed: %v", err)
writeErr(w, http.StatusNotFound, "domain not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleZoneRecords reads a zone's current records straight from the
// provider — no template required, no diff computed. Backs read-only zone
// viewing for domains that don't have a template attached (yet).
+8
View File
@@ -79,6 +79,14 @@ func toDomainResponse(d store.Domain) domainResponse {
return resp
}
// importResponse splits an import into what appeared and what vanished at
// the provider. Both slices are initialised so they marshal as [] and never
// as null — clients call .length/.map on them.
type importResponse struct {
Created []domainResponse `json:"created"`
Removed []domainResponse `json:"removed"`
}
// parseOptionalUUID parses s (may be nil/empty) into *uuid.UUID; returns ok=false on invalid input.
func parseOptionalUUID(s *string) (*uuid.UUID, bool) {
if s == nil || *s == "" {
+15 -8
View File
@@ -130,19 +130,26 @@ func (a *API) handleImportZones(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
// Imported atomically: either every zone becomes a domain or none does,
// so a mid-batch provider/DB error never leaves a partial import behind.
doms, err := a.Store.ImportDomains(r.Context(), pid, aid, zones)
// Imported atomically: either every zone becomes a domain (and vanished
// zones' domains are deleted) or none of it happens, so a mid-batch
// provider/DB error never leaves a partial sync behind.
doms, gone, err := a.Store.ImportDomains(r.Context(), pid, aid, zones)
if err != nil {
log.Printf("api: import: create domains failed: %v", err)
log.Printf("api: import: sync domains failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
created := make([]domainResponse, 0, len(doms))
for _, d := range doms {
created = append(created, toDomainResponse(d))
resp := importResponse{
Created: make([]domainResponse, 0, len(doms)),
Removed: make([]domainResponse, 0, len(gone)),
}
writeJSON(w, http.StatusCreated, created)
for _, d := range doms {
resp.Created = append(resp.Created, toDomainResponse(d))
}
for _, d := range gone {
resp.Removed = append(resp.Removed, toDomainResponse(d))
}
writeJSON(w, http.StatusCreated, resp)
}
// --- templates ---
+218 -6
View File
@@ -10,7 +10,10 @@ import (
"strings"
"testing"
"net/url"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
@@ -35,8 +38,13 @@ type mockTenantStore struct {
createDomains int
importDomains []store.Domain
importRemoved []store.Domain
importDomainsErr error
importCalled bool
// importDomainsFn, when set, overrides the default fake behavior of
// ImportDomains — used by tests that need to control created/removed
// independently (e.g. TestImportZonesReturnsCreatedAndRemoved).
importDomainsFn func(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error)
setDomainTemplateErr error
@@ -49,6 +57,23 @@ type mockTenantStore struct {
status string
}
setDomainStatusErr error
// addCustomRecordCalls/deleteCustomRecordCalls record every
// Add/DeleteCustomRecord(domainID, projectID, key) call, in order — tests
// assert both the scoping pair and the key the handler forwarded.
addCustomRecordCalls []struct {
domainID uuid.UUID
projectID uuid.UUID
key string
}
addCustomRecordErr error
deleteCustomRecordCalls []struct {
domainID uuid.UUID
projectID uuid.UUID
key string
}
deleteCustomRecordErr error
}
func (m *mockTenantStore) CreateAccount(_ context.Context, projectID uuid.UUID, prov, secretEnc, comment string) (store.Account, error) {
@@ -148,10 +173,34 @@ func (m *mockTenantStore) SetDomainStatus(_ context.Context, domainID, projectID
return m.setDomainStatusErr
}
func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) {
func (m *mockTenantStore) AddCustomRecord(_ context.Context, domainID, projectID uuid.UUID, key string) error {
m.addCustomRecordCalls = append(m.addCustomRecordCalls, struct {
domainID uuid.UUID
projectID uuid.UUID
key string
}{domainID, projectID, key})
return m.addCustomRecordErr
}
func (m *mockTenantStore) DeleteCustomRecord(_ context.Context, domainID, projectID uuid.UUID, key string) error {
m.deleteCustomRecordCalls = append(m.deleteCustomRecordCalls, struct {
domainID uuid.UUID
projectID uuid.UUID
key string
}{domainID, projectID, key})
return m.deleteCustomRecordErr
}
func (m *mockTenantStore) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error) {
m.importCalled = true
if m.importDomainsFn != nil {
created, removed, err := m.importDomainsFn(ctx, projectID, accountID, zones)
m.importDomains, m.importRemoved = created, removed
m.domains = append(m.domains, created...)
return created, removed, err
}
if m.importDomainsErr != nil {
return nil, m.importDomainsErr
return nil, nil, m.importDomainsErr
}
out := make([]store.Domain, 0, len(zones))
for _, z := range zones {
@@ -160,7 +209,7 @@ func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID
}
m.domains = append(m.domains, out...)
m.importDomains = out
return out, nil
return out, nil, nil
}
type mockCipher struct{}
@@ -393,12 +442,15 @@ func TestImportZones_CreatesDomainPerZone(t *testing.T) {
if len(ts.importDomains) != 2 {
t.Fatalf("expected 2 domains created via ImportDomains, got %d", len(ts.importDomains))
}
var resp []domainResponse
var resp importResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp) != 2 {
t.Fatalf("expected 2 domains in response, got %d", len(resp))
if len(resp.Created) != 2 {
t.Fatalf("expected 2 domains in response, got %d", len(resp.Created))
}
if len(resp.Removed) != 0 {
t.Fatalf("expected 0 removed domains in response, got %d", len(resp.Removed))
}
}
@@ -433,6 +485,39 @@ func TestImportZones_AtomicRollbackOnError(t *testing.T) {
}
}
// TestImportZonesReturnsCreatedAndRemoved covers the sync response shape:
// the endpoint now reports both what appeared (created) and what vanished
// at the provider (removed) rather than a bare array of created domains.
func TestImportZonesReturnsCreatedAndRemoved(t *testing.T) {
a, ts := newTenantTestAPI()
accID := uuid.New()
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
ts.importDomainsFn = func(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error) {
return []store.Domain{{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: "new.example.com.", ZoneID: "z1"}},
[]store.Domain{{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: "gone.example.com.", ZoneID: "z9"}},
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("expected 201, got %d: %s", w.Code, w.Body.String())
}
var got importResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("response must be an object with created/removed: %v (%s)", err, w.Body.String())
}
if len(got.Created) != 1 || got.Created[0].ZoneName != "new.example.com." {
t.Fatalf("unexpected created: %+v", got.Created)
}
if len(got.Removed) != 1 || got.Removed[0].ZoneName != "gone.example.com." {
t.Fatalf("unexpected removed: %+v", got.Removed)
}
}
func TestImportZones_BadAccountUUID(t *testing.T) {
a, _ := newTenantTestAPI()
router := NewRouter(a)
@@ -446,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)
@@ -620,6 +734,104 @@ func TestSetDomainTemplate_TemplateNotFound(t *testing.T) {
}
}
// --- custom records (Task 4: mark/unmark endpoints) ---
// TestAddCustomRecordReturns201 covers the mark-as-custom endpoint: the key
// travels in the JSON body (it contains a space and dots, e.g.
// "CNAME admin.example.com.", so it can't be a path segment), and the
// handler must scope the store call by (domainID, projectID) exactly as
// received — never trusting the domain id alone.
func TestAddCustomRecordReturns201(t *testing.T) {
a, ts := newTenantTestAPI()
router := NewRouter(a)
did := uuid.New()
body := `{"key":"CNAME admin.example.com."}`
req := requestWithSessionCookie(http.MethodPost,
"/api/v1/projects/"+testPID+"/domains/"+did.String()+"/customs", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if len(ts.addCustomRecordCalls) != 1 {
t.Fatalf("expected 1 AddCustomRecord call, got %d", len(ts.addCustomRecordCalls))
}
call := ts.addCustomRecordCalls[0]
wantPID := uuid.MustParse(testPID)
if call.domainID != did || call.projectID != wantPID {
t.Fatalf("handler must scope by (domain, project), got (%s, %s)", call.domainID, call.projectID)
}
if call.key != "CNAME admin.example.com." {
t.Fatalf("unexpected key %q", call.key)
}
}
// TestAddCustomRecordRejectsEmptyKey covers input validation: an empty key
// must be rejected with 400 before the store is ever called.
func TestAddCustomRecordRejectsEmptyKey(t *testing.T) {
a, ts := newTenantTestAPI()
router := NewRouter(a)
did := uuid.New()
body := `{"key":""}`
req := requestWithSessionCookie(http.MethodPost,
"/api/v1/projects/"+testPID+"/domains/"+did.String()+"/customs", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 on empty key, got %d", w.Code)
}
if len(ts.addCustomRecordCalls) != 0 {
t.Fatal("store must not be called with an empty key")
}
}
// TestDeleteCustomRecordReturns204 covers the unmark endpoint: the key
// travels as a URL-encoded query parameter and must reach the store
// url-decoded.
func TestDeleteCustomRecordReturns204(t *testing.T) {
a, ts := newTenantTestAPI()
router := NewRouter(a)
did := uuid.New()
req := requestWithSessionCookie(http.MethodDelete,
"/api/v1/projects/"+testPID+"/domains/"+did.String()+
"/customs?key="+url.QueryEscape("CNAME admin.example.com."), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
}
if len(ts.deleteCustomRecordCalls) != 1 || ts.deleteCustomRecordCalls[0].key != "CNAME admin.example.com." {
t.Fatalf("key must be url-decoded, got %+v", ts.deleteCustomRecordCalls)
}
}
// TestAddCustomRecordForeignDomainReturns404 covers the IDOR-on-write case:
// a domain that doesn't belong to the caller's project is indistinguishable
// from a missing one from this tenant's point of view, so the store's
// pgx.ErrNoRows must surface as 404, not 500.
func TestAddCustomRecordForeignDomainReturns404(t *testing.T) {
a, ts := newTenantTestAPI()
ts.addCustomRecordErr = pgx.ErrNoRows
router := NewRouter(a)
did := uuid.New()
body := `{"key":"CNAME admin.example.com."}`
req := requestWithSessionCookie(http.MethodPost,
"/api/v1/projects/"+testPID+"/domains/"+did.String()+"/customs", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for a domain outside the project, got %d", w.Code)
}
}
func TestDeleteDomain_BadUUID(t *testing.T) {
a, _ := newTenantTestAPI()
router := NewRouter(a)
+43 -3
View File
@@ -19,6 +19,10 @@ type RecordDiff struct {
Desired *model.Record // nil for Delete
Actual *model.Record // nil for Add
ReadOnly bool // NS/SOA — shown but never applied
// Custom marks a record the operator deliberately keeps outside the
// template: shown in its own section, never counted as drift, never
// applied. Set by MarkCustom, not by Diff.
Custom bool
}
// Key is the stable identifier of the RRset this diff targets, normalised the
@@ -37,7 +41,7 @@ type Changeset struct {
func (c Changeset) Actionable() []RecordDiff {
var out []RecordDiff
for _, d := range c.Diffs {
if d.ReadOnly || d.Kind == InSync {
if d.ReadOnly || d.Custom || d.Kind == InSync {
continue
}
out = append(out, d)
@@ -52,7 +56,7 @@ func (c Changeset) Actionable() []RecordDiff {
func (c Changeset) Updates() []RecordDiff {
var out []RecordDiff
for _, d := range c.Diffs {
if d.ReadOnly {
if d.ReadOnly || d.Custom {
continue
}
if d.Kind == Add || d.Kind == Update {
@@ -73,7 +77,7 @@ func (c Changeset) Updates() []RecordDiff {
func (c Changeset) Prunes() []RecordDiff {
var out []RecordDiff
for _, d := range c.Diffs {
if d.ReadOnly {
if d.ReadOnly || d.Custom {
continue
}
if d.Kind == Delete {
@@ -131,3 +135,39 @@ func index(recs []model.Record) map[string]model.Record {
}
return m
}
// MarkCustom flags the diffs whose Key() is in keys as Custom. Only
// Kind == Delete diffs are marked, and never read-only ones: the template
// wins. As soon as the template starts describing a key, its diff becomes
// Add/Update/InSync and the stored mark stops having any effect (it is not
// deleted — the operator may go back to a template without that record).
func (c *Changeset) MarkCustom(keys []string) {
if len(keys) == 0 {
return
}
set := make(map[string]bool, len(keys))
for _, k := range keys {
set[k] = true
}
for i := range c.Diffs {
d := &c.Diffs[i]
if d.ReadOnly || d.Kind != Delete {
continue
}
if set[d.Key()] {
d.Custom = true
}
}
}
// Customs returns diffs marked by MarkCustom. Disjoint from Updates() and
// Prunes(), and outside Actionable() — a custom record is never drift.
func (c Changeset) Customs() []RecordDiff {
var out []RecordDiff
for _, d := range c.Diffs {
if d.Custom {
out = append(out, d)
}
}
return out
}
+59
View File
@@ -242,3 +242,62 @@ func TestIndexDedupLastWriteWins(t *testing.T) {
t.Fatalf("expected exactly 1 diff for the duplicated key, got %d", count)
}
}
func TestMarkCustomOnlyMarksDeletes(t *testing.T) {
template := []model.Record{
{Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
}
actual := []model.Record{
{Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"2.2.2.2"}},
{Type: "CNAME", Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
}
cs := Diff(template, actual)
// Ключ записи, которую шаблон описывает (Update), и ключ лишней записи (Delete).
cs.MarkCustom([]string{"A www.example.com.", "CNAME admin.example.com."})
customs := cs.Customs()
if len(customs) != 1 {
t.Fatalf("expected exactly 1 custom diff, got %d: %+v", len(customs), customs)
}
if customs[0].Key() != "CNAME admin.example.com." {
t.Fatalf("expected the Delete diff to be custom, got %q", customs[0].Key())
}
// Шаблон побеждает: описанный шаблоном ключ остаётся обычным Update.
updates := cs.Updates()
if len(updates) != 1 || updates[0].Key() != "A www.example.com." {
t.Fatalf("expected the templated record to stay in Updates, got %+v", updates)
}
}
func TestCustomDiffsLeaveActionableAndPrunes(t *testing.T) {
actual := []model.Record{
{Type: "CNAME", Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
}
cs := Diff(nil, actual)
if len(cs.Prunes()) != 1 {
t.Fatalf("precondition: expected 1 prune before marking, got %+v", cs.Prunes())
}
cs.MarkCustom([]string{"CNAME admin.example.com."})
if got := cs.Prunes(); len(got) != 0 {
t.Fatalf("expected custom diff to leave Prunes, got %+v", got)
}
if got := cs.Actionable(); len(got) != 0 {
t.Fatalf("expected custom diff to leave Actionable, got %+v", got)
}
if got := cs.Customs(); len(got) != 1 {
t.Fatalf("expected 1 custom diff, got %+v", got)
}
}
func TestMarkCustomNeverMarksReadOnly(t *testing.T) {
actual := []model.Record{
{Type: "NS", Name: "example.com.", TTL: 300, Values: []string{"ns1.example.com."}},
}
cs := Diff(nil, actual)
cs.MarkCustom([]string{"NS example.com."})
if got := cs.Customs(); len(got) != 0 {
t.Fatalf("read-only diffs must never become custom, got %+v", got)
}
}
+6
View File
@@ -41,6 +41,11 @@ type DomainRef struct {
Provider string
SecretEnc string
Template dto.TemplateDoc
// CustomKeys are RecordDiff keys the operator marked as deliberately
// outside the template for this domain. Applied in resolve() right after
// the diff is computed — the single marking point, like tmpl.Materialize
// is the single materialisation point.
CustomKeys []string
}
// ZoneRef is the provider-access subset of a domain, without a template —
@@ -102,6 +107,7 @@ func (s *DomainService) resolve(ctx context.Context, projectID, domainID uuid.UU
return nil, provider.Credentials{}, ref, diff.Changeset{}, fmt.Errorf("%w: %v", ErrProviderUnavailable, err)
}
cs := diff.Diff(tmpl.Materialize(ref.Template, ref.ZoneName), actual)
cs.MarkCustom(ref.CustomKeys)
return p, creds, ref, cs, nil
}
+59 -1
View File
@@ -29,6 +29,7 @@ func testCipher(t *testing.T) *crypto.Cipher {
type fakeProvider struct {
actual []model.Record
applied diff.Changeset
applyCalls int
getErr error // when set, GetRecords fails with this error
applyErr error // when set, ApplyChanges fails with this error
}
@@ -44,6 +45,7 @@ func (f *fakeProvider) GetRecords(context.Context, provider.Credentials, string)
return f.actual, nil
}
func (f *fakeProvider) ApplyChanges(_ context.Context, _ provider.Credentials, _ string, cs diff.Changeset) error {
f.applyCalls++
if f.applyErr != nil {
return f.applyErr
}
@@ -70,12 +72,18 @@ type nopRecorder struct{}
func (nopRecorder) SaveCheckRun(context.Context, uuid.UUID, diff.Changeset) error { return nil }
func setup(t *testing.T, actual []model.Record, tmpl dto.TemplateDoc) (*DomainService, *fakeProvider) {
return setupWithCustomKeys(t, actual, tmpl, nil)
}
// setupWithCustomKeys mirrors setup but also lets a test populate
// DomainRef.CustomKeys, exercising the resolve() -> MarkCustom wiring.
func setupWithCustomKeys(t *testing.T, actual []model.Record, tmpl dto.TemplateDoc, customKeys []string) (*DomainService, *fakeProvider) {
fp := &fakeProvider{actual: actual}
reg := registry.New()
reg.Register(fp)
cipher := testCipher(t)
enc, _ := cipher.Encrypt([]byte("secret"))
loader := fakeLoader{ref: DomainRef{ZoneID: "z1", ZoneName: "example.com.", Provider: "selectel", SecretEnc: enc, Template: tmpl}}
loader := fakeLoader{ref: DomainRef{ZoneID: "z1", ZoneName: "example.com.", Provider: "selectel", SecretEnc: enc, Template: tmpl, CustomKeys: customKeys}}
return New(loader, nopRecorder{}, reg, cipher), fp
}
@@ -246,3 +254,53 @@ func TestResolveWrapsProviderError(t *testing.T) {
t.Fatalf("expected clean provider message, got %q", msg)
}
}
// TestResolveMarksCustomKeys covers the resolve() -> cs.MarkCustom(ref.CustomKeys)
// wiring: a zone record with no template counterpart, whose key is in
// DomainRef.CustomKeys, must be marked Custom rather than surfacing as a
// prune/drift.
func TestResolveMarksCustomKeys(t *testing.T) {
// Zone contains a record absent from the template, and its key is marked custom.
actual := []model.Record{
{Type: model.CNAME, Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
}
svc, _ := setupWithCustomKeys(t, actual, dto.TemplateDoc{Records: nil}, []string{"CNAME admin.example.com."})
cs, err := svc.Check(context.Background(), uuid.New(), uuid.New())
if err != nil {
t.Fatal(err)
}
if got := cs.Prunes(); len(got) != 0 {
t.Fatalf("custom record must not appear in Prunes, got %+v", got)
}
if got := cs.Customs(); len(got) != 1 {
t.Fatalf("expected 1 custom diff, got %+v", got)
}
if status := DeriveStatus(cs); status != StatusInSync {
t.Fatalf("a zone whose only deviation is custom must be in_sync, got %q", status)
}
}
// TestApplyIgnoresCustomKeySentAsPrune covers the case where a client sends a
// custom-marked key in ApplyRequest.Prunes anyway (stale UI state, replay,
// etc): since a custom diff is excluded from cs.Prunes(), Apply's key
// selection never picks it up and the provider is not called at all.
func TestApplyIgnoresCustomKeySentAsPrune(t *testing.T) {
actual := []model.Record{
{Type: model.CNAME, Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
}
svc, fp := setupWithCustomKeys(t, actual, dto.TemplateDoc{Records: nil}, []string{"CNAME admin.example.com."})
applied, err := svc.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{
Prunes: []string{"CNAME admin.example.com."},
})
if err != nil {
t.Fatal(err)
}
if len(applied.Diffs) != 0 {
t.Fatalf("custom key must never be applied, got %+v", applied.Diffs)
}
if fp.applyCalls != 0 {
t.Fatalf("provider must not be called with an empty change set, got %d calls", fp.applyCalls)
}
}
+66
View File
@@ -0,0 +1,66 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: customs.sql
package db
import (
"context"
"github.com/google/uuid"
)
const addCustomRecord = `-- name: AddCustomRecord :exec
INSERT INTO domain_custom_records (domain_id, record_key)
VALUES ($1, $2)
ON CONFLICT (domain_id, record_key) DO NOTHING
`
type AddCustomRecordParams struct {
DomainID uuid.UUID `json:"domain_id"`
RecordKey string `json:"record_key"`
}
func (q *Queries) AddCustomRecord(ctx context.Context, arg AddCustomRecordParams) error {
_, err := q.db.Exec(ctx, addCustomRecord, arg.DomainID, arg.RecordKey)
return err
}
const deleteCustomRecord = `-- name: DeleteCustomRecord :exec
DELETE FROM domain_custom_records WHERE domain_id = $1 AND record_key = $2
`
type DeleteCustomRecordParams struct {
DomainID uuid.UUID `json:"domain_id"`
RecordKey string `json:"record_key"`
}
func (q *Queries) DeleteCustomRecord(ctx context.Context, arg DeleteCustomRecordParams) error {
_, err := q.db.Exec(ctx, deleteCustomRecord, arg.DomainID, arg.RecordKey)
return err
}
const listCustomKeys = `-- name: ListCustomKeys :many
SELECT record_key FROM domain_custom_records WHERE domain_id = $1 ORDER BY record_key
`
func (q *Queries) ListCustomKeys(ctx context.Context, domainID uuid.UUID) ([]string, error) {
rows, err := q.db.Query(ctx, listCustomKeys, domainID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var record_key string
if err := rows.Scan(&record_key); err != nil {
return nil, err
}
items = append(items, record_key)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+42 -1
View File
@@ -75,6 +75,47 @@ func (q *Queries) DeleteDomain(ctx context.Context, arg DeleteDomainParams) erro
return err
}
const deleteDomainsNotInZones = `-- name: DeleteDomainsNotInZones :many
DELETE FROM domains
WHERE project_id = $1 AND provider_account_id = $2 AND zone_id <> ALL($3::text[])
RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status
`
type DeleteDomainsNotInZonesParams struct {
ProjectID uuid.UUID `json:"project_id"`
ProviderAccountID uuid.UUID `json:"provider_account_id"`
ZoneIds []string `json:"zone_ids"`
}
func (q *Queries) DeleteDomainsNotInZones(ctx context.Context, arg DeleteDomainsNotInZonesParams) ([]Domain, error) {
rows, err := q.db.Query(ctx, deleteDomainsNotInZones, arg.ProjectID, arg.ProviderAccountID, arg.ZoneIds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Domain
for rows.Next() {
var i Domain
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.ProviderAccountID,
&i.ZoneName,
&i.ZoneID,
&i.TemplateID,
&i.CreatedAt,
&i.LastCheckStatus,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getDomain = `-- name: GetDomain :one
SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE id = $1 AND project_id = $2
`
@@ -151,7 +192,7 @@ func (q *Queries) ImportDomain(ctx context.Context, arg ImportDomainParams) (Dom
}
const listDomains = `-- name: ListDomains :many
SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE project_id = $1 ORDER BY created_at
SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE project_id = $1 ORDER BY zone_name
`
func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, error) {
+7
View File
@@ -17,6 +17,13 @@ type CheckRun struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type DomainCustomRecord struct {
DomainID uuid.UUID `json:"domain_id"`
RecordKey string `json:"record_key"`
Note string `json:"note"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Domain struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
+5
View File
@@ -25,12 +25,17 @@ func (s *Store) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (
if row.Doc == nil {
return service.DomainRef{}, fmt.Errorf("store: domain %s has no template", domainID)
}
keys, err := s.ListCustomKeys(ctx, domainID)
if err != nil {
return service.DomainRef{}, err
}
return service.DomainRef{
ZoneID: row.ZoneID,
ZoneName: row.ZoneName,
Provider: row.Provider,
SecretEnc: row.SecretEnc,
Template: *row.Doc,
CustomKeys: keys,
}, nil
}
@@ -0,0 +1,11 @@
-- +goose Up
CREATE TABLE domain_custom_records (
domain_id uuid NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
record_key text NOT NULL,
note text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (domain_id, record_key)
);
-- +goose Down
DROP TABLE domain_custom_records;
+10
View File
@@ -0,0 +1,10 @@
-- name: AddCustomRecord :exec
INSERT INTO domain_custom_records (domain_id, record_key)
VALUES ($1, $2)
ON CONFLICT (domain_id, record_key) DO NOTHING;
-- name: DeleteCustomRecord :exec
DELETE FROM domain_custom_records WHERE domain_id = $1 AND record_key = $2;
-- name: ListCustomKeys :many
SELECT record_key FROM domain_custom_records WHERE domain_id = $1 ORDER BY record_key;
+6 -1
View File
@@ -17,7 +17,7 @@ RETURNING *;
SELECT * FROM domains WHERE id = $1 AND project_id = $2;
-- name: ListDomains :many
SELECT * FROM domains WHERE project_id = $1 ORDER BY created_at;
SELECT * FROM domains WHERE project_id = $1 ORDER BY zone_name;
-- name: DeleteDomain :exec
DELETE FROM domains WHERE id = $1 AND project_id = $2;
@@ -37,3 +37,8 @@ UPDATE domains SET last_check_status = $2 WHERE id = $1 AND project_id = $3;
-- name: CountDriftDomains :one
SELECT count(*) FROM domains WHERE last_check_status = 'drift';
-- name: DeleteDomainsNotInZones :many
DELETE FROM domains
WHERE project_id = $1 AND provider_account_id = $2 AND zone_id <> ALL($3::text[])
RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status;
+274 -6
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"reflect"
"testing"
"github.com/google/uuid"
@@ -103,13 +104,16 @@ func TestImportDomains_CommitsAllOnSuccess(t *testing.T) {
{ID: "z1", Name: "a.example.com"},
{ID: "z2", Name: "b.example.com"},
}
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
doms, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
if err != nil {
t.Fatal(err)
}
if len(doms) != 2 {
t.Fatalf("expected 2 domains returned, got %d", len(doms))
}
if len(removed) != 0 {
t.Fatalf("expected 0 removed domains, got %d", len(removed))
}
list, err := s.ListDomains(ctx, defaultProject)
if err != nil {
@@ -131,7 +135,7 @@ func TestImportDomains_RollsBackAllOnError(t *testing.T) {
{ID: "z1", Name: "a.example.com"},
{ID: "z2", Name: "b.example.com"},
}
if _, err := s.ImportDomains(ctx, defaultProject, bogusAccountID, zones); err == nil {
if _, _, err := s.ImportDomains(ctx, defaultProject, bogusAccountID, zones); err == nil {
t.Fatal("expected FK violation error, got nil")
}
@@ -161,7 +165,7 @@ func TestImportDomains_IdempotentOnRepeat(t *testing.T) {
{ID: "z1", Name: "a.example.com"},
{ID: "z2", Name: "b.example.com"},
}
first, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
first, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
if err != nil {
t.Fatal(err)
}
@@ -169,13 +173,16 @@ func TestImportDomains_IdempotentOnRepeat(t *testing.T) {
t.Fatalf("expected 2 domains on first import, got %d", len(first))
}
second, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
second, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
if err != nil {
t.Fatalf("expected repeat import to succeed idempotently, got error: %v", err)
}
if len(second) != 0 {
t.Fatalf("expected 0 newly-created domains on repeat import, got %d", len(second))
}
if len(removed) != 0 {
t.Fatalf("expected 0 removed domains on repeat import with the same zones, got %d", len(removed))
}
list, err := s.ListDomains(ctx, defaultProject)
if err != nil {
@@ -207,7 +214,7 @@ func TestSetDomainTemplate_ClosesImportCheckLoop(t *testing.T) {
if err != nil {
t.Fatal(err)
}
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
if err != nil {
t.Fatal(err)
}
@@ -255,7 +262,7 @@ func TestSetDomainTemplate_RejectsForeignProjectTemplate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
if err != nil {
t.Fatal(err)
}
@@ -277,3 +284,264 @@ func TestSetDomainTemplate_RejectsForeignProjectTemplate(t *testing.T) {
t.Fatal("expected error binding a template from a different project, got nil")
}
}
// seedDomain creates a provider account and imports a single domain for
// defaultProject, returning the resulting Domain — the shared setup used by
// the custom-records tests below.
func seedDomain(t *testing.T, s *Store, ctx context.Context) Domain {
t.Helper()
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
if err != nil {
t.Fatal(err)
}
return doms[0]
}
// TestCustomRecordsAddIsIdempotentAndScoped verifies that AddCustomRecord
// is idempotent (ON CONFLICT DO NOTHING — no PK-conflict error on repeat)
// and that it is scoped by projectID, closing the same IDOR-on-write gap
// SetDomainTemplate guards against.
func TestCustomRecordsAddIsIdempotentAndScoped(t *testing.T) {
s, ctx := newStore(t)
dom := seedDomain(t, s, ctx)
const key = "CNAME admin.example.com."
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
// Повторное добавление того же ключа не должно падать на PK-конфликте.
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatalf("second add must be a no-op, got %v", err)
}
keys, err := s.ListCustomKeys(ctx, dom.ID)
if err != nil {
t.Fatal(err)
}
if len(keys) != 1 || keys[0] != key {
t.Fatalf("expected exactly [%q], got %+v", key, keys)
}
// Чужой проект не может пометить этот домен.
if err := s.AddCustomRecord(ctx, dom.ID, uuid.New(), "A www.example.com."); err == nil {
t.Fatal("expected an error when marking a domain from another project")
}
}
// TestCustomRecordsDeleteAndCascade verifies DeleteCustomRecord removes the
// mark, and that deleting the domain cascades to remove any remaining marks
// (domain_custom_records.domain_id REFERENCES domains(id) ON DELETE CASCADE).
func TestCustomRecordsDeleteAndCascade(t *testing.T) {
s, ctx := newStore(t)
dom := seedDomain(t, s, ctx)
const key = "CNAME admin.example.com."
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
if err := s.DeleteCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
keys, err := s.ListCustomKeys(ctx, dom.ID)
if err != nil {
t.Fatal(err)
}
if len(keys) != 0 {
t.Fatalf("expected no keys after delete, got %+v", keys)
}
// Каскад: удаление домена уносит его пометки.
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
if err := s.DeleteDomain(ctx, dom.ID, dom.ProjectID); err != nil {
t.Fatal(err)
}
keys, err = s.ListCustomKeys(ctx, dom.ID)
if err != nil {
t.Fatal(err)
}
if len(keys) != 0 {
t.Fatalf("expected cascade to remove keys with the domain, got %+v", keys)
}
}
// seedAccount creates an additional provider account for an existing
// project — used to test that ImportDomains scopes its deletions to a
// single account, not the whole project.
func seedAccount(t *testing.T, s *Store, ctx context.Context, projectID uuid.UUID) Account {
t.Helper()
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: projectID, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
return accountFromDB(acc)
}
// TestImportDomainsRemovesVanishedZones verifies the sync behavior added to
// ImportDomains: a domain whose zone no longer appears in the provider's
// zone list is deleted on the next import, closing the orphan-domain gap
// (previously such a domain sat forever in status "error").
func TestImportDomainsRemovesVanishedZones(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
created, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
{ID: "z1", Name: "one.example.com."},
{ID: "z2", Name: "two.example.com."},
})
if err != nil {
t.Fatal(err)
}
if len(created) != 2 || len(removed) != 0 {
t.Fatalf("first import: expected 2 created / 0 removed, got %d/%d", len(created), len(removed))
}
// z2 disappeared at the provider — re-import must remove its domain.
created, removed, err = s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
{ID: "z1", Name: "one.example.com."},
})
if err != nil {
t.Fatal(err)
}
if len(created) != 0 {
t.Fatalf("expected nothing new to be created, got %+v", created)
}
if len(removed) != 1 || removed[0].ZoneID != "z2" {
t.Fatalf("expected the vanished zone's domain to be removed, got %+v", removed)
}
left, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
if len(left) != 1 || left[0].ZoneID != "z1" {
t.Fatalf("expected only z1 to survive, got %+v", left)
}
}
// TestImportDomainsEmptyZoneListRemovesNothing verifies the safety guard: an
// empty zone list from the provider is indistinguishable from a temporary
// loss of access, so it must never delete anything.
func TestImportDomainsEmptyZoneListRemovesNothing(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
if _, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
{ID: "z1", Name: "one.example.com."},
}); err != nil {
t.Fatal(err)
}
created, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, nil)
if err != nil {
t.Fatal(err)
}
if len(created) != 0 || len(removed) != 0 {
t.Fatalf("empty zone list must be a no-op, got %d created / %d removed", len(created), len(removed))
}
left, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
if len(left) != 1 {
t.Fatalf("expected the domain to survive an empty zone list, got %+v", left)
}
}
// TestImportDomainsOnlyTouchesItsOwnAccount verifies deletion is scoped to
// the account being imported: a project may hold several provider accounts,
// and one account's zone list says nothing about another account's domains.
func TestImportDomainsOnlyTouchesItsOwnAccount(t *testing.T) {
s, ctx := newStore(t)
accA, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
accB := seedAccount(t, s, ctx, defaultProject)
if _, _, err := s.ImportDomains(ctx, defaultProject, accB.ID, []provider.Zone{
{ID: "zb", Name: "b.example.com."},
}); err != nil {
t.Fatal(err)
}
if _, _, err := s.ImportDomains(ctx, defaultProject, accA.ID, []provider.Zone{
{ID: "za", Name: "a.example.com."},
}); err != nil {
t.Fatal(err)
}
// Re-importing accA with the same single zone must not touch accB's domains.
_, removed, err := s.ImportDomains(ctx, defaultProject, accA.ID, []provider.Zone{
{ID: "za", Name: "a.example.com."},
})
if err != nil {
t.Fatal(err)
}
if len(removed) != 0 {
t.Fatalf("import for one account must not remove another account's domains, got %+v", removed)
}
left, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
if len(left) != 2 {
t.Fatalf("expected both accounts' domains to survive, got %+v", left)
}
}
// TestListDomains_SortedByZoneName pins the list order: domains come back
// alphabetically by zone name, never in creation order. The domains page
// relies on this — its filters reorder nothing, so the store is the single
// place the order is decided.
func TestListDomains_SortedByZoneName(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
// Inserted in deliberately non-alphabetical order (one import call: a
// per-zone call would prune the previously imported domains), so a query
// still sorting by created_at would return zulu first.
if _, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
{ID: "z1", Name: "zulu.example.com"},
{ID: "z2", Name: "mike.example.com"},
{ID: "z3", Name: "alpha.example.com"},
}); err != nil {
t.Fatal(err)
}
list, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
got := make([]string, 0, len(list))
for _, d := range list {
got = append(got, d.ZoneName)
}
want := []string{"alpha.example.com", "mike.example.com", "zulu.example.com"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected domains sorted by zone name %v, got %v", want, got)
}
}
+62 -15
View File
@@ -187,25 +187,30 @@ func (s *Store) GetDomain(ctx context.Context, id, projectID uuid.UUID) (Domain,
return domainFromDB(d), nil
}
// ImportDomains creates one domain per zone inside a single transaction: if
// any zone fails to be created, the whole batch is rolled back so callers
// never observe a partially-imported set of domains.
// ImportDomains synchronises a provider account's zones with the domains
// stored for it, inside a single transaction: zones without a domain are
// created (ON CONFLICT DO NOTHING keeps repeated imports idempotent), and
// domains whose zone no longer exists at the provider are deleted. Deletion
// is scoped to accountID — a project may hold several provider accounts, and
// one account's zone list says nothing about another's domains.
//
// Import is idempotent: zones that already have a domain for this project
// (enforced by the domains_project_zone_uniq constraint) are silently
// skipped via ON CONFLICT DO NOTHING rather than erroring or duplicating —
// so a repeated POST .../import never creates duplicate domains. Only the
// zones that were actually newly created are returned.
func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]Domain, error) {
// An empty zone list removes nothing: it is indistinguishable from an account
// that temporarily lost access to its zones, and the cost of being wrong is
// every domain of the account gone along with its check history.
//
// Returns the domains actually created and the ones actually removed.
func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]Domain, []Domain, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
return nil, nil, err
}
defer tx.Rollback(ctx) // no-op once Commit has succeeded
q := s.q.WithTx(tx)
out := make([]Domain, 0, len(zones))
created := make([]Domain, 0, len(zones))
zoneIDs := make([]string, 0, len(zones))
for _, z := range zones {
zoneIDs = append(zoneIDs, z.ID)
d, err := q.ImportDomain(ctx, db.ImportDomainParams{
ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID,
ZoneName: z.Name, ZoneID: z.ID, TemplateID: nil,
@@ -216,14 +221,28 @@ func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUI
// for this project — skip it rather than fail the batch.
continue
}
return nil, err
return nil, nil, err
}
out = append(out, domainFromDB(d))
created = append(created, domainFromDB(d))
}
removed := make([]Domain, 0)
if len(zoneIDs) > 0 {
gone, err := q.DeleteDomainsNotInZones(ctx, db.DeleteDomainsNotInZonesParams{
ProjectID: projectID, ProviderAccountID: accountID, ZoneIds: zoneIDs,
})
if err != nil {
return nil, nil, err
}
for _, d := range gone {
removed = append(removed, domainFromDB(d))
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
return nil, nil, err
}
return out, nil
return created, removed, nil
}
// SetDomainTemplate attaches (or clears, when templateID is nil) the DNS
@@ -245,6 +264,34 @@ func (s *Store) SetDomainTemplate(ctx context.Context, domainID, projectID uuid.
return domainFromDB(d), nil
}
// AddCustomRecord marks an RRset (identified by its diff key, "TYPE name.")
// as deliberately kept outside the template for this domain. Idempotent.
// The scoped GetDomain first ensures the domain belongs to projectID —
// otherwise a foreign domain could be marked (IDOR-on-write), same guard
// SetDomainTemplate uses for templates.
func (s *Store) AddCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
if _, err := s.GetDomain(ctx, domainID, projectID); err != nil {
return err
}
return s.q.AddCustomRecord(ctx, db.AddCustomRecordParams{DomainID: domainID, RecordKey: key})
}
// DeleteCustomRecord removes the mark, returning the record to the regular
// diff. Scoped by projectID for the same reason as AddCustomRecord.
func (s *Store) DeleteCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
if _, err := s.GetDomain(ctx, domainID, projectID); err != nil {
return err
}
return s.q.DeleteCustomRecord(ctx, db.DeleteCustomRecordParams{DomainID: domainID, RecordKey: key})
}
// ListCustomKeys returns the marked record keys of a domain. Not scoped by
// project on purpose: the only caller is LoadDomain, which has already
// resolved the domain by (id, projectID).
func (s *Store) ListCustomKeys(ctx context.Context, domainID uuid.UUID) ([]string, error) {
return s.q.ListCustomKeys(ctx, domainID)
}
// GetDomainStatus returns the last known check status for a domain (Фаза 3
// scheduler/checker). Callers scope access to the domain themselves (e.g.
// via a prior GetDomain) — this lookup is by primary key alone.
+26
View File
@@ -201,4 +201,30 @@ describe("api client", () => {
expect.objectContaining({ method: "GET", credentials: "include" }),
)
})
// Empty-body 2xx responses: the real backend answers customs endpoints with
// no body at all (201 on add, 204 on remove). `res.json()` throws
// SyntaxError on an empty body, so `req()` must resolve instead of parsing
// when there is nothing to parse. Uses a fetch stub with no `json` method,
// mirroring a real empty-body Response, so an accidental `.json()` call
// fails the test instead of silently succeeding.
describe("empty-body 2xx responses", () => {
function mockEmptyBodyFetch(status: number) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status,
text: async () => "",
} as unknown as Response)
}
it("addCustom resolves on 201 with an empty body", async () => {
mockEmptyBodyFetch(201)
await expect(api.addCustom(PROJECT_ID, "d1", "A a.")).resolves.toBeUndefined()
})
it("removeCustom resolves on 204 with an empty body", async () => {
mockEmptyBodyFetch(204)
await expect(api.removeCustom(PROJECT_ID, "d1", "A a.")).resolves.toBeUndefined()
})
})
})
+21 -5
View File
@@ -2,7 +2,7 @@ import { API_ROOT } from "@/lib/config"
import type {
AuthState,
Account, CreateAccountInput, Template, CreateTemplateInput,
Domain, CreateDomainInput, ChangesetResponse, ApplyRequest,
Domain, CreateDomainInput, ImportResult, ChangesetResponse, ApplyRequest,
Schedule, Channel, CreateChannelInput, CheckRun, RecordDTO,
} from "./types"
@@ -20,14 +20,20 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
credentials: "include",
...init,
})
// Body can only be consumed once — read it as text up front, then parse
// (or not) from that string. Some responses (e.g. 201/204 on the customs
// endpoints) legitimately carry no body at all.
const raw = await res.text()
if (res.status === 401) throw new UnauthorizedError()
if (!res.ok) {
let msg = `HTTP ${res.status}`
try { const b = await res.json(); if (b?.error) msg = String(b.error) } catch { /* ignore */ }
if (raw) {
try { const b = JSON.parse(raw); if (b?.error) msg = String(b.error) } catch { /* ignore */ }
}
throw new Error(msg)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
if (!raw) return undefined as T
return JSON.parse(raw) as T
}
function projectPath(projectId: string, path: string): string {
@@ -70,7 +76,7 @@ export const api = {
deleteDomain: (projectId: string, id: string) =>
req<void>(projectPath(projectId, `/domains/${id}`), { method: "DELETE" }),
importZones: (projectId: string, accountId: string) =>
req<Domain[]>(projectPath(projectId, `/accounts/${accountId}/import`), { method: "POST" }),
req<ImportResult>(projectPath(projectId, `/accounts/${accountId}/import`), { method: "POST" }),
setDomainTemplate: (projectId: string, id: string, templateId: string | null) =>
req<Domain>(projectPath(projectId, `/domains/${id}`), { method: "PATCH", body: JSON.stringify({ templateId }) }),
@@ -83,6 +89,16 @@ export const api = {
req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/check`)),
applyDomain: (projectId: string, id: string, body: ApplyRequest) =>
req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/apply`), { method: "POST", body: JSON.stringify(body) }),
addCustom: (projectId: string, id: string, key: string) =>
req<void>(projectPath(projectId, `/domains/${id}/customs`), {
method: "POST",
body: JSON.stringify({ key }),
}),
removeCustom: (projectId: string, id: string, key: string) =>
req<void>(
projectPath(projectId, `/domains/${id}/customs?key=${encodeURIComponent(key)}`),
{ method: "DELETE" },
),
domainHistory: (projectId: string, id: string) =>
req<CheckRun[]>(projectPath(projectId, `/domains/${id}/history`)),
+3
View File
@@ -29,6 +29,7 @@ export interface CreateDomainInput {
zoneId: string
templateId?: string | null
}
export interface ImportResult { created: Domain[]; removed: Domain[] }
export interface Schedule { intervalSeconds: number; enabled: boolean }
@@ -45,10 +46,12 @@ export interface RecordView {
desired?: string[]
actual?: string[]
readOnly: boolean
custom: boolean // помечена оператором как живущая вне шаблона
}
export interface ChangesetResponse {
updates: RecordView[]
prunes: RecordView[]
customs: RecordView[]
readOnly: RecordView[]
inSyncCount: number
}
+10 -6
View File
@@ -4,9 +4,10 @@ import { DiffView } from "./DiffView"
import type { ChangesetResponse } from "@/api/types"
const cs: ChangesetResponse = {
updates: [{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }],
prunes: [{ key: "A old.example.com.", kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }],
readOnly: [{ key: "NS example.com.", kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }],
updates: [{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false, custom: false }],
prunes: [{ key: "A old.example.com.", kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false, custom: false }],
customs: [],
readOnly: [{ key: "NS example.com.", kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true, custom: false }],
inSyncCount: 3,
}
@@ -60,9 +61,11 @@ test("renders a very long unbreakable value (DKIM key) without crashing", () =>
desired: [longValue],
actual: [],
readOnly: false,
custom: false,
},
],
prunes: [],
customs: [],
readOnly: [],
inSyncCount: 0,
}
@@ -135,11 +138,12 @@ test("select-all header checkbox calls onToggleAllUpdates(true) when clicked whi
test("select-all header checkbox is indeterminate when only some update rows are selected", () => {
const csWithMultipleUpdates: ChangesetResponse = {
updates: [
{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false },
{ key: "A api.example.com.", kind: "update", type: "A", name: "api.example.com.", desired: ["1.1.1.2"], actual: ["9.9.9.8"], readOnly: false },
{ key: "A cdn.example.com.", kind: "update", type: "A", name: "cdn.example.com.", desired: ["1.1.1.3"], actual: ["9.9.9.7"], readOnly: false },
{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false, custom: false },
{ key: "A api.example.com.", kind: "update", type: "A", name: "api.example.com.", desired: ["1.1.1.2"], actual: ["9.9.9.8"], readOnly: false, custom: false },
{ key: "A cdn.example.com.", kind: "update", type: "A", name: "cdn.example.com.", desired: ["1.1.1.3"], actual: ["9.9.9.7"], readOnly: false, custom: false },
],
prunes: [],
customs: [],
readOnly: [],
inSyncCount: 0,
}
+55 -5
View File
@@ -1,11 +1,12 @@
import type { ReactNode } from "react"
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
import { ArrowRight, BookmarkCheck, CircleCheck, Lock, Pencil, Trash2, Undo2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
import type { ChangesetResponse, RecordView } from "@/api/types"
type Tone = "update" | "delete" | "readonly"
type Tone = "update" | "delete" | "custom" | "readonly"
const TONE_META: Record<
Tone,
@@ -25,6 +26,13 @@ const TONE_META: Record<
dot: "var(--diff-delete)",
ring: "ring-[color-mix(in_oklch,var(--diff-delete),transparent_78%)]",
},
custom: {
label: "Customs",
empty: "Нет записей, помеченных как осознанные.",
icon: BookmarkCheck,
dot: "var(--diff-custom)",
ring: "ring-[color-mix(in_oklch,var(--diff-custom),transparent_80%)]",
},
readonly: {
label: "Read-only",
empty: "Нет read-only записей.",
@@ -46,11 +54,13 @@ function RecordRow({
tone,
checked,
onToggle,
action,
}: {
record: RecordView
tone: Tone
checked?: boolean
onToggle?: (key: string) => void
action?: ReactNode
}) {
const meta = TONE_META[tone]
const showArrow = tone !== "delete"
@@ -99,6 +109,8 @@ function RecordRow({
read-only
</Badge>
)}
{action}
</div>
{/* Values line: plain block-level text (not flex) so a long
@@ -137,18 +149,21 @@ function Section({
selected,
onToggle,
onToggleAll,
renderAction,
}: {
tone: Tone
records: RecordView[]
selected?: Set<string>
onToggle?: (key: string) => void
onToggleAll?: (checked: boolean) => void
renderAction?: (record: RecordView) => ReactNode
}) {
const meta = TONE_META[tone]
const Icon = meta.icon
// Read-only (NS/SOA) records are never selectable — only update/delete
// sections receive selection props from DiffView.
const selectable = tone !== "readonly" && !!selected && !!onToggle && !!onToggleAll
// Read-only (NS/SOA) and custom records are never selectable — only
// update/delete sections receive selection props from DiffView.
const selectable =
tone !== "readonly" && tone !== "custom" && !!selected && !!onToggle && !!onToggleAll
const allSelected = selectable && records.length > 0 && records.every((r) => selected!.has(r.key))
const someSelected = selectable && records.some((r) => selected!.has(r.key))
const indeterminate = someSelected && !allSelected
@@ -191,6 +206,7 @@ function Section({
tone={tone}
checked={selectable ? selected!.has(record.key) : undefined}
onToggle={selectable ? onToggle : undefined}
action={renderAction?.(record)}
/>
))}
</div>
@@ -207,6 +223,8 @@ export function DiffView({
onTogglePrune,
onToggleAllUpdates,
onToggleAllPrunes,
onMarkCustom,
onUnmarkCustom,
footerExtra,
}: {
changeset: ChangesetResponse
@@ -216,6 +234,8 @@ export function DiffView({
onTogglePrune: (key: string) => void
onToggleAllUpdates: (checked: boolean) => void
onToggleAllPrunes: (checked: boolean) => void
onMarkCustom?: (key: string) => void
onUnmarkCustom?: (key: string) => void
footerExtra?: ReactNode
}) {
// Defensive: a field may arrive as null (e.g. a nil slice from an older
@@ -235,6 +255,36 @@ export function DiffView({
selected={selectedPrunes}
onToggle={onTogglePrune}
onToggleAll={onToggleAllPrunes}
renderAction={
onMarkCustom &&
((record) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={`В customs ${record.type} ${record.name}`}
onClick={() => onMarkCustom(record.key)}
>
<BookmarkCheck className="size-3.5" strokeWidth={1.75} />
</Button>
))
}
/>
<Section
tone="custom"
records={changeset.customs ?? []}
renderAction={
onUnmarkCustom &&
((record) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={`Вернуть в дифф ${record.type} ${record.name}`}
onClick={() => onUnmarkCustom(record.key)}
>
<Undo2 className="size-3.5" strokeWidth={1.75} />
</Button>
))
}
/>
<Section tone="readonly" records={changeset.readOnly ?? []} />
+30
View File
@@ -153,6 +153,36 @@ export function useApplyDomain(id: string) {
onSuccess: () => qc.invalidateQueries({ queryKey: ["check", project?.id, id] }),
})
}
export function useAddCustom(id: string) {
const { project } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: (key: string) => {
const pid = requireProjectId(project)
return api.addCustom(pid, id, key)
},
// Дифф пересчитывается на бэкенде — инвалидируем чек, а заодно статус
// домена в списке (custom-запись перестаёт быть дрифтом).
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["check", project?.id, id] })
qc.invalidateQueries({ queryKey: ["domains", project?.id] })
},
})
}
export function useRemoveCustom(id: string) {
const { project } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: (key: string) => {
const pid = requireProjectId(project)
return api.removeCustom(pid, id, key)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["check", project?.id, id] })
qc.invalidateQueries({ queryKey: ["domains", project?.id] })
},
})
}
export function useDomainHistory(id: string) {
const { project } = useAuth()
return useQuery({
+3
View File
@@ -43,6 +43,7 @@
--color-diff-delete: var(--diff-delete);
--color-diff-insync: var(--diff-insync);
--color-diff-readonly: var(--diff-readonly);
--color-diff-custom: var(--diff-custom);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@@ -96,6 +97,7 @@
--diff-delete: oklch(0.68 0.19 20); /* rose */
--diff-insync: oklch(0.55 0.02 260); /* muted */
--diff-readonly: oklch(0.5 0.02 260); /* dimmed */
--diff-custom: oklch(0.62 0.11 250); /* muted blue */
}
/* "Refined technical console" — dark by default (html.dark). Cool slate
@@ -139,6 +141,7 @@
--diff-delete: oklch(0.68 0.19 20);
--diff-insync: oklch(0.55 0.02 258);
--diff-readonly: oklch(0.42 0.014 258);
--diff-custom: oklch(0.62 0.11 250);
}
@layer base {
+67 -7
View File
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react"
import { render, screen, waitFor, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter, Routes, Route } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
@@ -43,11 +43,12 @@ beforeEach(() => {
test("default selection: updates checked, prunes unchecked; apply sends only selected keys", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false, custom: false }],
prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false, custom: false }],
customs: [],
readOnly: [], inSyncCount: 0,
})
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], customs: [], readOnly: [], inSyncCount: 0 })
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
const user = userEvent.setup()
renderPage()
@@ -78,11 +79,12 @@ test("default selection: updates checked, prunes unchecked; apply sends only sel
test("deselecting all records disables Apply", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false, custom: false }],
prunes: [],
customs: [],
readOnly: [], inSyncCount: 0,
})
vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], customs: [], readOnly: [], inSyncCount: 0 })
const user = userEvent.setup()
renderPage()
@@ -105,7 +107,7 @@ test("пока список доменов грузится — показан
}),
)
const checkSpy = vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [], prunes: [], readOnly: [], inSyncCount: 0,
updates: [], prunes: [], customs: [], readOnly: [], inSyncCount: 0,
})
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
renderPage()
@@ -171,3 +173,61 @@ test("создание шаблона из зоны вызывает templateFro
await waitFor(() => expect(templateFromZoneSpy).toHaveBeenCalledWith(PROJECT_ID, "d1"))
})
test("кнопка «В customs» на строке prune вызывает api.addCustom с ключом записи", async () => {
const addSpy = vi.spyOn(api, "addCustom").mockResolvedValue(undefined)
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [],
prunes: [
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: false },
],
customs: [],
readOnly: [],
inSyncCount: 0,
})
const user = userEvent.setup()
renderPage()
await user.click(await screen.findByRole("button", { name: /в customs cname admin\.example\.com\./i }))
await waitFor(() =>
expect(addSpy).toHaveBeenCalledWith(PROJECT_ID, "d1", "CNAME admin.example.com."),
)
})
test("кнопка «Вернуть в дифф» на строке custom вызывает api.removeCustom", async () => {
const removeSpy = vi.spyOn(api, "removeCustom").mockResolvedValue(undefined)
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [],
prunes: [],
customs: [
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: true },
],
readOnly: [],
inSyncCount: 0,
})
const user = userEvent.setup()
renderPage()
await user.click(await screen.findByRole("button", { name: /вернуть в дифф cname admin\.example\.com\./i }))
await waitFor(() =>
expect(removeSpy).toHaveBeenCalledWith(PROJECT_ID, "d1", "CNAME admin.example.com."),
)
})
test("в секции customs нет чекбоксов — такие записи не применяются", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [],
prunes: [],
customs: [
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: true },
],
readOnly: [],
inSyncCount: 0,
})
renderPage()
const section = await screen.findByRole("region", { name: /customs/i })
expect(within(section).queryByRole("checkbox")).toBeNull()
})
+12
View File
@@ -13,10 +13,12 @@ import {
TableRow,
} from "@/components/ui/table"
import {
useAddCustom,
useApplyDomain,
useCheckDomain,
useCreateTemplateFromZone,
useDomains,
useRemoveCustom,
useZoneRecords,
} from "@/hooks/useApi"
import { cn } from "@/lib/utils"
@@ -29,6 +31,8 @@ export function DomainDiffPage() {
const check = useCheckDomain(id, hasTemplate)
const apply = useApplyDomain(id)
const addCustom = useAddCustom(id)
const removeCustom = useRemoveCustom(id)
// Пока список доменов не загружен ИЛИ загрузка упала ошибкой, hasTemplate
// недостоверно (false по умолчанию из-за domain === undefined) — не
// дёргаем provider-запрос записей зоны, пока не будет точно известно
@@ -234,6 +238,8 @@ export function DomainDiffPage() {
onTogglePrune={togglePrune}
onToggleAllUpdates={toggleAllUpdates}
onToggleAllPrunes={toggleAllPrunes}
onMarkCustom={(key) => addCustom.mutate(key)}
onUnmarkCustom={(key) => removeCustom.mutate(key)}
/>
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
@@ -275,6 +281,12 @@ export function DomainDiffPage() {
Apply
</Button>
</div>
{(addCustom.isError || removeCustom.isError) && (
<span role="alert" className="font-dns text-xs text-destructive">
{(addCustom.error ?? removeCustom.error)?.message}
</span>
)}
</div>
</>
)}
+102 -1
View File
@@ -62,7 +62,7 @@ test("отрисовывает домены и ссылку на diff-стран
})
test("кнопка импорта вызывает api.importZones с выбранной учёткой", async () => {
const importSpy = vi.spyOn(api, "importZones").mockResolvedValue([])
const importSpy = vi.spyOn(api, "importZones").mockResolvedValue({ created: [], removed: [] })
const user = userEvent.setup()
renderPage()
@@ -76,6 +76,25 @@ test("кнопка импорта вызывает api.importZones с выбра
await waitFor(() => expect(importSpy).toHaveBeenCalledWith(PROJECT_ID, "acc2"))
})
test("после импорта показывает, сколько зон создано и сколько удалено", async () => {
vi.spyOn(api, "importZones").mockResolvedValue({
created: [
{ id: "d1", providerAccountId: "a1", zoneName: "new.example.com.", zoneId: "z1" },
],
removed: [
{ id: "d2", providerAccountId: "a1", zoneName: "gone.example.com.", zoneId: "z9" },
],
})
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()
})
test("привязка шаблона в строке домена вызывает api.setDomainTemplate", async () => {
const setTemplateSpy = vi.spyOn(api, "setDomainTemplate").mockResolvedValue(domains[0])
const user = userEvent.setup()
@@ -133,3 +152,85 @@ test("кнопки удаления домена нет", async () => {
expect(screen.queryByRole("button", { name: /удалить/i })).not.toBeInTheDocument()
})
test("фильтр по шаблону оставляет только домены с этим шаблоном", async () => {
const user = userEvent.setup()
renderPage()
await screen.findByText("example.com.")
await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i }))
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
expect(screen.getByText("test.org.")).toBeInTheDocument()
expect(screen.queryByText("example.com.")).not.toBeInTheDocument()
})
test("фильтр по шаблону «Без шаблона» оставляет только непривязанные домены", async () => {
const user = userEvent.setup()
renderPage()
await screen.findByText("example.com.")
await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i }))
await user.click(await screen.findByRole("option", { name: /^без шаблона$/i }))
expect(screen.getByText("example.com.")).toBeInTheDocument()
expect(screen.queryByText("test.org.")).not.toBeInTheDocument()
})
test("фильтр по статусу оставляет только домены с этим статусом", async () => {
const user = userEvent.setup()
renderPage()
await screen.findByText("example.com.")
await user.click(screen.getByRole("combobox", { name: /фильтр по статусу/i }))
await user.click(await screen.findByRole("option", { name: /^in sync$/i }))
expect(screen.getByText("test.org.")).toBeInTheDocument()
expect(screen.queryByText("example.com.")).not.toBeInTheDocument()
})
// d1 не имеет шаблона, но хранит протухший lastCheckStatus="drift". Бейдж
// показывает «без шаблона», поэтому и фильтр обязан считать его таким же —
// иначе фильтр и таблица говорят разное об одной строке.
test("домен без шаблона не попадает в фильтр «drift», хотя хранит такой статус", async () => {
const user = userEvent.setup()
renderPage()
await screen.findByText("example.com.")
await user.click(screen.getByRole("combobox", { name: /фильтр по статусу/i }))
await user.click(await screen.findByRole("option", { name: /^drift$/i }))
expect(screen.queryByText("example.com.")).not.toBeInTheDocument()
expect(await screen.findByText(/под фильтр ничего не попало/i)).toBeInTheDocument()
})
test("комбинация фильтров сужает список до пересечения", async () => {
const user = userEvent.setup()
renderPage()
await screen.findByText("example.com.")
await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i }))
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
await user.click(screen.getByRole("combobox", { name: /фильтр по статусу/i }))
await user.click(await screen.findByRole("option", { name: /^без шаблона$/i }))
expect(await screen.findByText(/под фильтр ничего не попало/i)).toBeInTheDocument()
})
test("счётчик показывает, сколько доменов отфильтровано", async () => {
const user = userEvent.setup()
renderPage()
await screen.findByText("example.com.")
expect(screen.getByText(/показано 2 из 2/i)).toBeInTheDocument()
await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i }))
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
expect(screen.getByText(/показано 1 из 2/i)).toBeInTheDocument()
})
+104 -1
View File
@@ -25,8 +25,31 @@ import {
useSetDomainTemplate,
useTemplates,
} from "@/hooks/useApi"
import type { Domain } from "@/api/types"
const NO_TEMPLATE = "__none__"
// Значения «фильтр не задан». Отдельные от NO_TEMPLATE: «без шаблона» — это
// осознанный выбор пользователя, а не отсутствие фильтра.
const ANY_TEMPLATE = "__any_template__"
const ANY_STATUS = "__any_status__"
const STATUS_FILTER_ITEMS = [
{ value: ANY_STATUS, label: "Все статусы" },
{ value: "in_sync", label: "in sync" },
{ value: "drift", label: "drift" },
{ value: "error", label: "error" },
{ value: "unknown", label: "unknown" },
{ value: "no_template", label: "без шаблона" },
]
// Статус, который пользователь реально видит в бейдже строки: у домена без
// шаблона диффа нет, поэтому его хранимый lastCheckStatus (возможно,
// протухший с тех пор, как шаблон отвязали) не показывается — и не должен
// фильтроваться. Единственная точка вычисления: иначе фильтр и таблица
// разойдутся в том, что за строка перед пользователем.
function displayStatus(domain: Domain): string {
return domain.templateId ? (domain.lastCheckStatus ?? "unknown") : "no_template"
}
export function DomainsPage() {
const domains = useDomains()
@@ -39,6 +62,30 @@ export function DomainsPage() {
const templateList = templates.data ?? []
const domainList = domains.data ?? []
const [templateFilter, setTemplateFilter] = useState<string>(ANY_TEMPLATE)
const [statusFilter, setStatusFilter] = useState<string>(ANY_STATUS)
// Порядок задаёт бэкенд (ListDomains сортирует по zone_name) — фильтры
// только выбрасывают строки, никогда не пересортировывают.
const visibleDomains = domainList.filter((d) => {
if (templateFilter === NO_TEMPLATE && d.templateId) return false
if (
templateFilter !== ANY_TEMPLATE &&
templateFilter !== NO_TEMPLATE &&
d.templateId !== templateFilter
) {
return false
}
if (statusFilter !== ANY_STATUS && displayStatus(d) !== statusFilter) return false
return true
})
const templateFilterItems = [
{ value: ANY_TEMPLATE, label: "Все шаблоны" },
{ value: NO_TEMPLATE, label: "Без шаблона" },
...templateList.map((t) => ({ value: t.id, label: t.name })),
]
const [importAccountId, setImportAccountId] = useState<string | null>(null)
const selectedImportAccount = importAccountId ?? accountList[0]?.id ?? null
@@ -102,6 +149,57 @@ export function DomainsPage() {
{importZones.isError && (
<span className="font-dns text-xs text-destructive">{importZones.error.message}</span>
)}
{importZones.isSuccess && (
<span className="font-dns text-xs text-muted-foreground">
Создано {importZones.data.created.length}, удалено {importZones.data.removed.length}
</span>
)}
</div>
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-border bg-card/60 p-4">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground">Шаблон</span>
<Select
items={templateFilterItems}
value={templateFilter}
onValueChange={(v) => setTemplateFilter(v as string)}
>
<SelectTrigger aria-label="Фильтр по шаблону" size="sm" className="min-w-48">
<SelectValue placeholder="Все шаблоны" />
</SelectTrigger>
<SelectContent>
{templateFilterItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground">Статус</span>
<Select
items={STATUS_FILTER_ITEMS}
value={statusFilter}
onValueChange={(v) => setStatusFilter(v as string)}
>
<SelectTrigger aria-label="Фильтр по статусу" size="sm" className="min-w-44">
<SelectValue placeholder="Все статусы" />
</SelectTrigger>
<SelectContent>
{STATUS_FILTER_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="font-dns ml-auto text-xs text-muted-foreground">
Показано {visibleDomains.length} из {domainList.length}
</span>
</div>
{setTemplate.isError && (
@@ -114,6 +212,11 @@ export function DomainsPage() {
<Inbox className="size-6" strokeWidth={1.5} />
Доменов пока нет импортируйте зоны из учётной записи.
</div>
) : visibleDomains.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
<Inbox className="size-6" strokeWidth={1.5} />
Под фильтр ничего не попало измените шаблон или статус.
</div>
) : (
<Table>
<TableHeader>
@@ -126,7 +229,7 @@ export function DomainsPage() {
</TableRow>
</TableHeader>
<TableBody>
{domainList.map((d) => {
{visibleDomains.map((d) => {
const templateItems = [
{ value: NO_TEMPLATE, label: "Без шаблона" },
...templateList.map((t) => ({ value: t.id, label: t.name })),
+54
View File
@@ -174,3 +174,57 @@ test("пустое состояние при отсутствии шаблоно
expect(await screen.findByText(/шаблонов пока нет/i)).toBeInTheDocument()
})
test("клонирование заполняет форму копией записей и именем «(копия)»", async () => {
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard")
await user.click(screen.getByRole("button", { name: /клонировать standard/i }))
expect(screen.getByLabelText(/имя шаблона/i)).toHaveValue("Standard (копия)")
expect(screen.getByLabelText(/имя записи 1/i)).toHaveValue("@")
expect(screen.getByLabelText(/значения записи 1/i)).toHaveValue("1.2.3.4")
})
test("клон существующей копии получает суффикс «(копия 2)»", async () => {
vi.spyOn(api, "listTemplates").mockResolvedValue([
...templates,
{ id: "t3", name: "Standard (копия)", records: [], version: 1 },
])
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard (копия)")
await user.click(screen.getByRole("button", { name: /клонировать standard$/i }))
expect(screen.getByLabelText(/имя шаблона/i)).toHaveValue("Standard (копия 2)")
})
test("клонирование во время редактирования уходит в createTemplate, а не в updateTemplate", async () => {
const createSpy = vi.spyOn(api, "createTemplate").mockResolvedValue({
id: "t3",
name: "Standard (копия)",
records: templates[0].records,
version: 1,
})
const updateSpy = vi.spyOn(api, "updateTemplate").mockResolvedValue(templates[0])
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard")
await user.click(screen.getByRole("button", { name: /редактировать standard/i }))
await user.click(screen.getByRole("button", { name: /клонировать standard$/i }))
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
await waitFor(() =>
expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, {
name: "Standard (копия)",
records: [{ type: "A", name: "@", ttl: 3600, values: ["1.2.3.4"] }],
}),
)
expect(updateSpy).not.toHaveBeenCalled()
})
+31 -1
View File
@@ -2,7 +2,7 @@ import { useId, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Inbox, Loader2, Pencil, Save, Trash2, X } from "lucide-react"
import { Copy, Inbox, Loader2, Pencil, Save, Trash2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -43,6 +43,18 @@ type TemplateForm = z.infer<typeof templateFormSchema>
const EMPTY_FORM: TemplateForm = { name: "", records: [] }
// uniqueCopyName подбирает имя клона, не занятое среди существующих шаблонов:
// «Standard (копия)», затем «Standard (копия 2)» и далее. Уникальность имени в БД
// не проверяется (нет constraint) — это UX-подсказка, а не гарантия.
function uniqueCopyName(base: string, templates: Template[]) {
const taken = new Set(templates.map((t) => t.name))
let candidate = `${base} (копия)`
for (let n = 2; taken.has(candidate); n++) {
candidate = `${base} (копия ${n})`
}
return candidate
}
function sanitizeRecords(records: TemplateForm["records"]) {
return records
.map((record) => ({
@@ -78,6 +90,16 @@ export function TemplatesPage() {
reset({ name: template.name, records: template.records })
}
// onClone кладёт копию в форму создания: editingId сбрасывается, поэтому
// сабмит уходит в createTemplate даже если до этого шло редактирование.
function onClone(template: Template) {
setEditingId(null)
reset({
name: uniqueCopyName(template.name, templateList),
records: template.records.map((r) => ({ ...r, values: [...r.values] })),
})
}
function onCancelEdit() {
setEditingId(null)
reset(EMPTY_FORM)
@@ -225,6 +247,14 @@ export function TemplatesPage() {
>
<Pencil className="size-3.5" strokeWidth={1.75} />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label={`Клонировать ${t.name}`}
onClick={() => onClone(t)}
>
<Copy className="size-3.5" strokeWidth={1.75} />
</Button>
<Button
variant="destructive"
size="icon-sm"