Compare commits
12
Commits
784e7bd822
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e538ecd509
|
||
|
|
650a8cff85
|
||
|
|
567d721311
|
||
|
|
5215678fe6
|
||
|
|
879e9e14b1
|
||
|
|
6f9958af60
|
||
|
|
e283e5f22a
|
||
|
|
2f1f5311ad
|
||
|
|
0b26923586
|
||
|
|
fc19678727
|
||
|
|
0b2b9c6e3e
|
||
|
|
27d70a987e
|
@@ -15,3 +15,5 @@ web/dist/
|
|||||||
# placeholder with the real built index.html.
|
# placeholder with the real built index.html.
|
||||||
internal/web/dist/*
|
internal/web/dist/*
|
||||||
!internal/web/dist/index.html
|
!internal/web/dist/index.html
|
||||||
|
|
||||||
|
.gograph/
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
DNS Autoresolver — multi-tenant сервис, сверяющий фактическое состояние DNS-зоны у провайдера (Selectel DNS API v2) с шаблоном записей: показывает диф и применяет изменения **только вручную**. Go-бэкенд со встроенным (go:embed) React SPA.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build # go build ./...
|
||||||
|
make test # go test ./... (см. caveat: store-тесты требуют Docker)
|
||||||
|
go test ./internal/service/ -run TestName -v # один тест / пакет
|
||||||
|
make web # npm ci + build фронта, копия в internal/web/dist (go:embed target)
|
||||||
|
make build-all # web + build
|
||||||
|
make docker-up # docker compose: app + postgres (собирает образ)
|
||||||
|
|
||||||
|
cd web && npm run test -- --run # фронт-тесты (Vitest)
|
||||||
|
cd web && npx tsc --noEmit # проверка типов
|
||||||
|
cd web && npm run build # прод-сборка фронта
|
||||||
|
```
|
||||||
|
|
||||||
|
Конфигурация только через env: `DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (base64, декодит в **ровно 32 байта**; `openssl rand -base64 32`), `DNS_AR_LISTEN` (default `:8080`). Миграции (goose) гоняются приложением на старте.
|
||||||
|
|
||||||
|
## Критичные подводные камни
|
||||||
|
|
||||||
|
- **sqlc НЕ установлен в среде.** Сгенерированный `internal/store/db/*.sql.go` правится **вручную** и держится синхронно с источником `internal/store/queries/*.sql`. При добавлении колонки в SELECT порядок в SQL-строке, в `*Row`-структуре и в `row.Scan(...)` обязан совпадать 1:1 — иначе данные молча разъезжаются по полям.
|
||||||
|
- **`internal/web/dist/` — go:embed target.** В git закоммичен только плейсхолдер `index.html`; `.gitignore` игнорирует остальное. `npm run build` перезаписывает `index.html` реальным бандлом — **перед коммитом всегда `git checkout internal/web/dist/index.html`**. Настоящий бандл собирается через `make web` перед прод/docker-сборкой. Если поменял API-контракт и не пересобрал фронт — задеплоенный бандл шлёт старый формат и «молча» не работает.
|
||||||
|
- **store integration-тесты используют testcontainers-go** → для `go test ./internal/store/...` нужен запущенный Docker.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
Поток: `cmd/server` (wiring + lifecycle) → `internal/api` (chi-роутер, DTO, auth-middleware) → `internal/service` (`DomainService`: resolve/Check/Apply) → `internal/provider` (registry + Selectel) + `internal/store` (pgx/sqlc) + `internal/diff` (движок диффа) + `internal/tmpl` (плейсхолдеры). Плюс `scheduler`, `notify`, `metrics`, `crypto`, `auth`, `model`, `config`, `web` (embed).
|
||||||
|
|
||||||
|
**Провайдер-нейтральность.** `provider.Provider` — интерфейс (ListZones/GetRecords/ApplyChanges/Validate). `provider.Credentials.Secret` — provider-specific расшифрованный секрет; для Selectel это зашифрованный JSON `{username,password,account_id,project_name}`, из которого клиент добывает project IAM-токен.
|
||||||
|
|
||||||
|
### Инварианты (нарушать нельзя)
|
||||||
|
|
||||||
|
- **Multi-tenancy / IDOR.** Каждый ресурс скоуплен по `projectID` из контекста (`RequireAuth` + `RequireProjectAccess` middleware). Все store-методы, читающие/пишущие ресурс, принимают `projectID` и фильтруют по нему (`WHERE id=$1 AND project_id=$2`). Загрузка домена/статуса/зоны — всегда по паре `(id, projectID)`.
|
||||||
|
- **Планировщик read-only.** `internal/scheduler` только Check + notify, **никогда** Apply. Apply — исключительно явное действие оператора через `POST /apply`.
|
||||||
|
- **Порядок apply: deletes перед updates.** `service.Apply` кладёт выбранные prunes ПЕРЕД updates — провайдер отвергает создание записи на имени, где ещё жива конфликтующая (CNAME vs A). Провайдерский `ApplyChanges` итерирует `cs.Diffs` в порядке слайса и НЕ переупорядочивает по Kind (задокументировано в интерфейсе).
|
||||||
|
- **Идентификация записей.** Диф матчит по `RecordDiff.Key()` = нормализованный `"ТИП имя."` (через `model.Record.Key()`). `Changeset.Actionable()`/`Updates()`/`Prunes()` исключают read-only NS/SOA. Фронт получает `key` в ответе и возвращает его же в Apply — ключ нигде не переконструируется.
|
||||||
|
- **Статус домена.** `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).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Секреты провайдера и `bot_token` каналов — AES-256-GCM (`internal/crypto`). Пароли — argon2id. Cookie-сессии: sha256-токен в БД, HttpOnly+Secure+SameSite=Lax.
|
||||||
|
- **Selectel IAM (v2).** Cloud DNS v2 требует project IAM-токен в `X-Auth-Token`, а не статический API-ключ. Клиент получает его через Identity API (`https://cloud.api.selcloud.ru/identity/v3/auth/tokens`) от сервисного пользователя, кэширует ~24ч. Ошибки авторизации намеренно generic — пароль не логируется и не возвращается.
|
||||||
|
- **Webhook SSRF-guard** (`internal/notify`): `net.Dialer.Control` пиннит фактический connecting IP (loopback/private/link-local/CGNAT заблокированы) — закрывает DNS-rebinding.
|
||||||
|
- `/metrics` публичный (без auth), отдаёт только агрегаты — никаких доменов/секретов.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
`web/` — React 19 + Vite + TypeScript + TanStack Query + react-router. UI — shadcn поверх **Base UI** (`@base-ui/react`, не Radix). Форма-стейт: react-hook-form + zod. Тесты — Vitest + RTL. Собирается и встраивается в Go-бинарь (`internal/web` go:embed); в dev — Vite dev-proxy на Go-бэкенд.
|
||||||
|
|
||||||
|
## Процесс разработки
|
||||||
|
|
||||||
|
Спеки — в `docs/superpowers/specs/`, планы задач — в `docs/superpowers/plans/`. Работа ведётся через subagent-driven development; прогресс фиксируется в `.superpowers/sdd/progress.md` (git-ignored). Каждая фича/фикс — отдельная ветка, финальное ревью, merge `--no-ff` в `main`.
|
||||||
@@ -8,12 +8,21 @@
|
|||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- **Multi-tenant**: проекты, аккаунты провайдера, домены — с авторизацией
|
- **Multi-tenant**: проекты, аккаунты провайдера, домены — с авторизацией
|
||||||
(регистрация/логин, сессии).
|
(регистрация/логин, сессии); всё изолировано по проекту.
|
||||||
- **Провайдер Selectel**: чтение зон/RRSet, диф против шаблона, ручной apply.
|
- **Провайдер Selectel (Cloud DNS v2)**: авторизация через project IAM-токен
|
||||||
- **Шаблоны записей**: неймспейс-независимая модель `Record`, движок диффа
|
сервисного пользователя (не статический API-ключ — см. ниже), чтение
|
||||||
шаблон ↔ зона.
|
зон/RRSet, импорт зон, диф против шаблона, ручной apply.
|
||||||
- **Диф + ручной apply**: изменения показываются перед применением, apply —
|
- **Шаблоны записей с плейсхолдером `{{domain_name}}`**: один шаблон
|
||||||
явное действие оператора.
|
переиспользуется на многих доменах — при проверке подставляется имя зоны.
|
||||||
|
Шаблон можно завести вручную или снять снимком с существующей зоны
|
||||||
|
(«создать шаблон из зоны», с авто-параметризацией имени домена).
|
||||||
|
- **Просмотр зоны без шаблона**: текущие записи зоны видны даже до привязки
|
||||||
|
шаблона; статус домена без шаблона — «без шаблона», а не `unknown`.
|
||||||
|
- **Диф + выборочный ручной apply**: чекбоксы на каждой записи (updates и
|
||||||
|
prunes), удаления по умолчанию сняты (opt-in). Удаления применяются
|
||||||
|
**перед** обновлениями — иначе провайдер отвергает конфликт (например
|
||||||
|
`CNAME` на имени, где ещё жива `A`-запись). При ошибке показывается
|
||||||
|
реальный ответ провайдера, а не generic-текст.
|
||||||
- **Расписание проверок**: планировщик периодически гоняет read-only
|
- **Расписание проверок**: планировщик периодически гоняет read-only
|
||||||
check+notify (без Apply), пишет историю проверок и статус drift.
|
check+notify (без Apply), пишет историю проверок и статус drift.
|
||||||
- **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки.
|
- **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки.
|
||||||
@@ -21,6 +30,35 @@
|
|||||||
- **Health-check**: `/healthz` — liveness-проба, используется как
|
- **Health-check**: `/healthz` — liveness-проба, используется как
|
||||||
Docker `HEALTHCHECK` через встроенный CLI-режим `app -healthcheck`.
|
Docker `HEALTHCHECK` через встроенный CLI-режим `app -healthcheck`.
|
||||||
|
|
||||||
|
## Учётные данные Selectel
|
||||||
|
|
||||||
|
Cloud DNS v2 требует **project IAM-токен**, а не статический API-ключ. При
|
||||||
|
добавлении аккаунта Selectel в UI указываются данные **сервисного
|
||||||
|
пользователя**:
|
||||||
|
|
||||||
|
- имя сервисного пользователя,
|
||||||
|
- пароль,
|
||||||
|
- номер аккаунта (`account_id`, вида `123456`),
|
||||||
|
- имя проекта.
|
||||||
|
|
||||||
|
Сервисный пользователь создаётся в панели Selectel (раздел
|
||||||
|
[Пользователи и роли](https://my.selectel.ru/iam/users)) и ему выдаётся роль
|
||||||
|
на нужный проект. Приложение само обменивает эти данные на 24-часовой
|
||||||
|
IAM-токен (Identity API `cloud.api.selcloud.ru`) и кэширует его; данные
|
||||||
|
хранятся зашифрованными (AES-256-GCM), пароль не логируется. Учётные данные
|
||||||
|
проверяются пробным логином прямо при добавлении аккаунта.
|
||||||
|
|
||||||
|
## Рабочий процесс
|
||||||
|
|
||||||
|
1. Зарегистрироваться (self-registration, автоматически создаётся личный
|
||||||
|
проект).
|
||||||
|
2. Добавить аккаунт Selectel (данные сервисного пользователя, см. выше).
|
||||||
|
3. Импортировать зоны аккаунта — на каждую зону заводится домен.
|
||||||
|
4. Привязать шаблон: создать снимком из зоны или собрать вручную с
|
||||||
|
плейсхолдерами `{{domain_name}}`; без шаблона доступен только просмотр
|
||||||
|
записей.
|
||||||
|
5. Открыть диф домена, отметить нужные изменения/удаления, применить.
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
Go 1.26 (statically-linked бинарь, SPA встроена через `embed`), React +
|
Go 1.26 (statically-linked бинарь, SPA встроена через `embed`), React +
|
||||||
@@ -82,11 +120,20 @@ Vite (SPA), PostgreSQL 17, Prometheus client, distroless/static-debian12
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build # go build ./...
|
make build # go build ./...
|
||||||
make test # go test ./...
|
make test # go test ./... (тесты internal/store требуют Docker — testcontainers)
|
||||||
make web # сборка SPA (npm ci && npm run build) в internal/web/dist
|
make web # сборка SPA (npm ci && npm run build) в internal/web/dist
|
||||||
make build-all # web + build
|
make build-all # web + build
|
||||||
|
|
||||||
|
go test ./internal/service/ -run TestName -v # один тест / пакет
|
||||||
|
cd web && npm run test -- --run # фронт-тесты (Vitest)
|
||||||
|
cd web && npx tsc --noEmit # проверка типов SPA
|
||||||
```
|
```
|
||||||
|
|
||||||
Для запуска бинаря напрямую нужны те же переменные окружения:
|
Для запуска бинаря напрямую нужны те же переменные окружения:
|
||||||
`DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (обязательные), `DNS_AR_LISTEN`
|
`DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (обязательные), `DNS_AR_LISTEN`
|
||||||
(по умолчанию `:8080`).
|
(по умолчанию `:8080`).
|
||||||
|
|
||||||
|
> `internal/web/dist/` — цель `go:embed`; в git коммитится только плейсхолдер
|
||||||
|
> `index.html`. `npm run build` перезаписывает его — перед коммитом выполнить
|
||||||
|
> `git checkout internal/web/dist/index.html`, а реальный бандл собирать через
|
||||||
|
> `make web`.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-dnsar}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-dnsar}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dnsar} -d ${POSTGRES_DB:-dnsar}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
dns:
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DNS_AR_DB_DSN: postgres://${POSTGRES_USER:-dnsar}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-dnsar}?sslmode=disable
|
||||||
|
DNS_AR_ENC_KEY: ${DNS_AR_ENC_KEY:?base64 32 bytes, see .env.example}
|
||||||
|
DNS_AR_LISTEN: ":8080"
|
||||||
|
expose:
|
||||||
|
- "${APP_PORT:-8080}"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/app", "-healthcheck"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 4s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
dns:
|
||||||
|
webproxy:
|
||||||
|
ipv4_address: 172.18.0.23
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dns:
|
||||||
|
name: dns
|
||||||
|
webproxy:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# Пер-записевый выбор в Apply + порядок «удаления раньше обновлений» Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** (1) Дать выбирать чекбоксами конкретные записи для применения (updates и prunes), а не «всё или ничего». (2) Применять удаления (prunes) ДО обновлений (updates), иначе провайдер отвергает конфликтующие изменения (нельзя создать CNAME на имени, где ещё живёт A-запись).
|
||||||
|
|
||||||
|
**Architecture:** `ApplyRequest` из двух булевых превращается в два списка выбранных ключей записей. Каждая запись в diff-ответе получает стабильный `key` (нормализованный `ТИП имя.`), которым оперируют чекбоксы фронта и по которому бэк фильтрует. `service.Apply` собирает выбранные prunes, затем выбранные updates — провайдер применяет удаления первыми.
|
||||||
|
|
||||||
|
**Tech Stack:** Go (diff/service/api), React + Vite.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Ключ записи: `RecordDiff.Key()` — нормализованный `ТИП имя.` (через `model.Record.Key()` по Type+Name). Фронт НЕ конструирует ключ сам — берёт `key` из ответа diff и возвращает его в Apply.
|
||||||
|
- Порядок применения — ИНВАРИАНТ: выбранные prunes (Delete) добавляются в набор ДО выбранных updates (Add/Update). Не опция.
|
||||||
|
- Только actionable-записи выбираемы; read-only (NS/SOA) чекбоксов не имеют и никогда не применяются.
|
||||||
|
- Multi-tenancy/IDOR: Apply уже скоуплен по projectID (resolve по pid) — не регрессировать.
|
||||||
|
- Комментарии в Go — на английском; в web — как в окружающих файлах. TDD. НЕ коммитить реальную сборку `internal/web/dist/*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Backend — ключи записей + выборочный Apply с порядком deletes-first
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/diff/diff.go` (RecordDiff.Key), `internal/api/dto.go` (recordView.Key + applyRequest), `internal/service/service.go` (ApplyRequest + Apply), `internal/api/handlers.go` (handleApply маппинг)
|
||||||
|
- Test: `internal/diff/diff_test.go`, `internal/service/service_test.go`, `internal/api/*_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `diff.RecordDiff.Key() string`; `recordView.Key`; `service.ApplyRequest{Updates []string, Prunes []string}`.
|
||||||
|
- Consumes: `model.Record.Key()`; `Changeset.Updates()/Prunes()`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Тест RecordDiff.Key**
|
||||||
|
|
||||||
|
`internal/diff/diff_test.go`:
|
||||||
|
```go
|
||||||
|
func TestRecordDiffKeyNormalizes(t *testing.T) {
|
||||||
|
d := RecordDiff{Kind: Delete, Type: model.A, Name: "Mail.Example.COM"}
|
||||||
|
if got := d.Key(); got != "A mail.example.com." {
|
||||||
|
t.Fatalf("key: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Run: `go test ./internal/diff/... -run RecordDiffKey` — Ожидание FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 2: RecordDiff.Key**
|
||||||
|
|
||||||
|
`internal/diff/diff.go`:
|
||||||
|
```go
|
||||||
|
// Key is the stable identifier of the RRset this diff targets, normalised the
|
||||||
|
// same way as model.Record.Key ("TYPE name."). Used to select individual diffs
|
||||||
|
// for a partial apply. Works for every Kind (Delete has no Desired, Add has no
|
||||||
|
// Actual) because Type/Name are always populated.
|
||||||
|
func (d RecordDiff) Key() string {
|
||||||
|
return model.Record{Type: d.Type, Name: d.Name}.Key()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Run: `go test ./internal/diff/... -run RecordDiffKey` — Ожидание PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: recordView.Key + applyRequest**
|
||||||
|
|
||||||
|
`internal/api/dto.go`:
|
||||||
|
```go
|
||||||
|
type recordView struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Desired []string `json:"desired,omitempty"`
|
||||||
|
Actual []string `json:"actual,omitempty"`
|
||||||
|
ReadOnly bool `json:"readOnly"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`toRecordView` — установить `Key: d.Key()` (первым полем).
|
||||||
|
```go
|
||||||
|
type applyRequest struct {
|
||||||
|
Updates []string `json:"updates"`
|
||||||
|
Prunes []string `json:"prunes"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(убрать старые ApplyUpdates/ApplyPrunes bool.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: service.ApplyRequest + deletes-first selective**
|
||||||
|
|
||||||
|
`internal/service/service.go`:
|
||||||
|
```go
|
||||||
|
type ApplyRequest struct {
|
||||||
|
Updates []string // record keys (RecordDiff.Key) to add/update
|
||||||
|
Prunes []string // record keys to delete
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`Apply`:
|
||||||
|
```go
|
||||||
|
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
|
||||||
|
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return diff.Changeset{}, err
|
||||||
|
}
|
||||||
|
selPrunes := toSet(req.Prunes)
|
||||||
|
selUpdates := toSet(req.Updates)
|
||||||
|
var toApply []diff.RecordDiff
|
||||||
|
// Deletes first: the provider rejects an Add/Update whose name still has a
|
||||||
|
// conflicting record (e.g. a CNAME cannot be created while an A on the same
|
||||||
|
// name exists). Pruning the old records before applying updates avoids that.
|
||||||
|
for _, d := range cs.Prunes() {
|
||||||
|
if selPrunes[d.Key()] {
|
||||||
|
toApply = append(toApply, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range cs.Updates() {
|
||||||
|
if selUpdates[d.Key()] {
|
||||||
|
toApply = append(toApply, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applied := diff.Changeset{Diffs: toApply}
|
||||||
|
if len(toApply) > 0 {
|
||||||
|
if err := p.ApplyChanges(ctx, creds, ref.ZoneID, applied); err != nil {
|
||||||
|
return diff.Changeset{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applied, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSet(keys []string) map[string]bool {
|
||||||
|
m := make(map[string]bool, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
m[k] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`ApplyChanges` итерирует `cs.Diffs` в порядке слайса — сверено; порядок toApply сохраняется, prunes применяются первыми.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: handleApply маппинг**
|
||||||
|
|
||||||
|
`internal/api/handlers.go` `handleApply`:
|
||||||
|
```go
|
||||||
|
cs, err := a.Svc.Apply(r.Context(), pid, did, service.ApplyRequest{
|
||||||
|
Updates: req.Updates, Prunes: req.Prunes,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
(пустое тело → пустые списки → ничего не применяется; сохранить существующую обработку EOF/битого JSON.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Тесты**
|
||||||
|
|
||||||
|
- `service_test.go`: changeset с 1 update + 1 prune; `Apply{Prunes:[pruneKey]}` → применён только prune; `Apply{Updates:[updKey]}` → только update; `Apply{Updates:[updKey], Prunes:[pruneKey]}` → **порядок toApply: prune ПЕРВЫМ, update вторым** (проверь через фейковый провайдер, записывающий порядок полученных Diffs — критический тест конфликта CNAME/A). Невыбранные ключи не применяются.
|
||||||
|
- `api_test.go`: POST `/apply` с `{"prunes":["A gitlocator.com."]}` → Svc.Apply получил Prunes с этим ключом, Updates пусто.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Прогон и коммит**
|
||||||
|
|
||||||
|
Run: `go build ./... && go test ./internal/...`. Ожидание PASS.
|
||||||
|
```bash
|
||||||
|
git add internal/
|
||||||
|
git commit -m "feat(apply): per-record selection + deletes-before-updates ordering"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Frontend — чекбоксы на записях, выборочный Apply
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/api/types.ts`, `web/src/api/client.ts`, `web/src/hooks/useApi.ts`, `web/src/components/DiffView.tsx`, `web/src/pages/DomainDiffPage.tsx`
|
||||||
|
- Test: `web/src/components/DiffView.test.tsx`, `web/src/pages/DomainDiffPage.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `recordView.key`; `POST /apply {updates:[], prunes:[]}`.
|
||||||
|
- Produces: чекбокс на каждой update/prune записи; выбор → Apply шлёт ключи; deletes-first обеспечен бэком.
|
||||||
|
|
||||||
|
- [ ] **Step 1: types + client + hooks**
|
||||||
|
|
||||||
|
`types.ts`: `RecordView += key: string`; `ApplyRequest` → `{ updates: string[]; prunes: string[] }`.
|
||||||
|
`client.ts` `applyDomain(projectId, id, body: ApplyRequest)` — тело `{updates, prunes}` (уже сериализует body).
|
||||||
|
`useApi.ts` `useApplyDomain` — тип body обновится; onSuccess как есть (инвалидация check).
|
||||||
|
|
||||||
|
- [ ] **Step 2: DiffView — чекбоксы + select-all**
|
||||||
|
|
||||||
|
`DiffView.tsx`: сделать секции Updates/Prunes выбираемыми. Расширить проп:
|
||||||
|
```tsx
|
||||||
|
export function DiffView({
|
||||||
|
changeset,
|
||||||
|
selectedUpdates, selectedPrunes, // Set<string>
|
||||||
|
onToggleUpdate, onTogglePrune, // (key: string) => void
|
||||||
|
onToggleAllUpdates, onToggleAllPrunes, // (checked: boolean) => void
|
||||||
|
footerExtra,
|
||||||
|
}: { ... })
|
||||||
|
```
|
||||||
|
- В `Section` для tone `update`/`delete` — чекбокс в заголовке секции (select-all: отмечен если все выбраны, indeterminate если часть) и чекбокс в каждой `RecordRow` (отмечен по `selected.has(record.key)`). Для tone `readonly` — без чекбоксов (не выбираемо).
|
||||||
|
- Используй существующий `Checkbox` из `@/components/ui/checkbox`. Ключ строки — `record.key`.
|
||||||
|
- Сохрани визуальный стиль; чекбокс слева от badge типа, выравнивание аккуратное.
|
||||||
|
|
||||||
|
- [ ] **Step 3: DomainDiffPage — состояние выбора + Apply**
|
||||||
|
|
||||||
|
`DomainDiffPage.tsx`:
|
||||||
|
- Состояние: `selectedUpdates: Set<string>`, `selectedPrunes: Set<string>`.
|
||||||
|
- При загрузке changeset — инициализировать: `selectedUpdates` = все ключи updates (default on), `selectedPrunes` = пусто (default off, удаление opt-in). Пересчитывать при смене changeset (useEffect на changeset).
|
||||||
|
- Тогглы: add/remove ключ; select-all: заполнить/очистить набор ключей секции.
|
||||||
|
- Убрать старый общий `applyPrunes` чекбокс и `pruneWarning`, завязанный на него. Вместо — предупреждение, если `selectedPrunes.size > 0`: «Будет удалено записей: N. Действие необратимо.»
|
||||||
|
- Apply: `apply.mutate({ updates: [...selectedUpdates], prunes: [...selectedPrunes] })`.
|
||||||
|
- Кнопка Apply disabled, если `selectedUpdates.size + selectedPrunes.size === 0`; текст статуса «Готово к применению» / «Изменений для применения нет».
|
||||||
|
|
||||||
|
- [ ] **Step 4: Тесты**
|
||||||
|
|
||||||
|
- `DiffView.test.tsx`: чекбоксы рендерятся для update/prune записей, не для read-only; клик по чекбоксу вызывает onToggle с `record.key`; select-all в заголовке.
|
||||||
|
- `DomainDiffPage.test.tsx`: по умолчанию updates отмечены, prunes сняты; отметка prune → предупреждение о количестве; Apply шлёт выбранные `{updates:[...], prunes:[...]}`; снятие всех → Apply disabled.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Прогон и коммит**
|
||||||
|
|
||||||
|
Run: `cd web && npm run test -- --run && npx tsc --noEmit && npm run build`.
|
||||||
|
```bash
|
||||||
|
cd .. && git checkout internal/web/dist/index.html
|
||||||
|
git add web/src/
|
||||||
|
git commit -m "feat(web): per-record apply checkboxes with select-all; prune opt-in"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итоговая проверка
|
||||||
|
|
||||||
|
- `go build ./... && go test ./...` — PASS.
|
||||||
|
- `cd web && npm run test -- --run && npm run build` — PASS.
|
||||||
|
- Ручная: на домене с конфликтом (A `mail` + желаемый CNAME `mail`) отметить prune A `mail` и update CNAME `mail`, Apply → удаление проходит раньше, CNAME создаётся без ошибки провайдера; чекбоксы позволяют применить подмножество записей.
|
||||||
+3
-2
@@ -52,8 +52,9 @@ type TenantStore interface {
|
|||||||
SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (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
|
// SetDomainStatus persists the outcome of a manual check (handleCheck) so
|
||||||
// the domain's badge reflects reality immediately, instead of staying
|
// the domain's badge reflects reality immediately, instead of staying
|
||||||
// "unknown" until the scheduler's next tick.
|
// "unknown" until the scheduler's next tick. Scoped by projectID so a
|
||||||
SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error
|
// foreign domain ID can never have its status overwritten (IDOR-on-write).
|
||||||
|
SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cipher encrypts/decrypts provider account secrets. *crypto.Cipher satisfies it.
|
// Cipher encrypts/decrypts provider account secrets. *crypto.Cipher satisfies it.
|
||||||
|
|||||||
+161
-12
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,6 +27,10 @@ type mockCheckApplier struct {
|
|||||||
// used by handleCheck status-persistence tests (drift/in_sync/error).
|
// used by handleCheck status-persistence tests (drift/in_sync/error).
|
||||||
checkCS *diff.Changeset
|
checkCS *diff.Changeset
|
||||||
checkErr error
|
checkErr error
|
||||||
|
|
||||||
|
// applyErr, when set, makes Apply fail with this error — used by the
|
||||||
|
// provider-error-surfacing tests (502 with real message vs 500 generic).
|
||||||
|
applyErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockCheckApplier) Check(context.Context, uuid.UUID, uuid.UUID) (diff.Changeset, error) {
|
func (m *mockCheckApplier) Check(context.Context, uuid.UUID, uuid.UUID) (diff.Changeset, error) {
|
||||||
@@ -40,6 +45,9 @@ func (m *mockCheckApplier) Check(context.Context, uuid.UUID, uuid.UUID) (diff.Ch
|
|||||||
}
|
}
|
||||||
func (m *mockCheckApplier) Apply(_ context.Context, _, _ uuid.UUID, req service.ApplyRequest) (diff.Changeset, error) {
|
func (m *mockCheckApplier) Apply(_ context.Context, _, _ uuid.UUID, req service.ApplyRequest) (diff.Changeset, error) {
|
||||||
m.lastReq = req
|
m.lastReq = req
|
||||||
|
if m.applyErr != nil {
|
||||||
|
return diff.Changeset{}, m.applyErr
|
||||||
|
}
|
||||||
return diff.Changeset{}, nil
|
return diff.Changeset{}, nil
|
||||||
}
|
}
|
||||||
func (m *mockCheckApplier) ZoneRecords(context.Context, uuid.UUID, uuid.UUID) ([]model.Record, error) {
|
func (m *mockCheckApplier) ZoneRecords(context.Context, uuid.UUID, uuid.UUID) ([]model.Record, error) {
|
||||||
@@ -84,12 +92,15 @@ func TestCheckEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyDefaultsPruneFalse(t *testing.T) {
|
// TestApplySendsSelectedKeys covers the per-record selection request shape:
|
||||||
|
// POST /apply with only "prunes" set must reach the service with that key in
|
||||||
|
// Prunes and an empty Updates.
|
||||||
|
func TestApplySendsSelectedKeys(t *testing.T) {
|
||||||
a, m := newTestAPI()
|
a, m := newTestAPI()
|
||||||
router := NewRouter(a)
|
router := NewRouter(a)
|
||||||
|
|
||||||
did := uuid.New().String()
|
did := uuid.New().String()
|
||||||
body := `{"applyUpdates":true}` // applyPrunes отсутствует → false
|
body := `{"prunes":["A gitlocator.com."]}`
|
||||||
req := requestWithSessionCookie(http.MethodPost,
|
req := requestWithSessionCookie(http.MethodPost,
|
||||||
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply",
|
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply",
|
||||||
strings.NewReader(body))
|
strings.NewReader(body))
|
||||||
@@ -99,7 +110,7 @@ func TestApplyDefaultsPruneFalse(t *testing.T) {
|
|||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if m.lastReq.ApplyPrunes != false || m.lastReq.ApplyUpdates != true {
|
if len(m.lastReq.Prunes) != 1 || m.lastReq.Prunes[0] != "A gitlocator.com." || len(m.lastReq.Updates) != 0 {
|
||||||
t.Fatalf("apply request mismatch: %+v", m.lastReq)
|
t.Fatalf("apply request mismatch: %+v", m.lastReq)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,8 +128,8 @@ func TestApplyEmptyBodyOK(t *testing.T) {
|
|||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if m.lastReq.ApplyPrunes != false {
|
if len(m.lastReq.Updates) != 0 || len(m.lastReq.Prunes) != 0 {
|
||||||
t.Fatalf("expected ApplyPrunes=false for empty body, got %+v", m.lastReq)
|
t.Fatalf("expected empty Updates/Prunes for empty body, got %+v", m.lastReq)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +138,7 @@ func TestApplyMalformedBody(t *testing.T) {
|
|||||||
router := NewRouter(a)
|
router := NewRouter(a)
|
||||||
|
|
||||||
did := uuid.New().String()
|
did := uuid.New().String()
|
||||||
body := `{"applyUpdates":`
|
body := `{"updates":`
|
||||||
req := requestWithSessionCookie(http.MethodPost,
|
req := requestWithSessionCookie(http.MethodPost,
|
||||||
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply",
|
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply",
|
||||||
strings.NewReader(body))
|
strings.NewReader(body))
|
||||||
@@ -172,8 +183,9 @@ func TestCheckEndpoint_PersistsDriftStatus(t *testing.T) {
|
|||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if len(ts.statusCalls) != 1 || ts.statusCalls[0].domainID != did || ts.statusCalls[0].status != service.StatusDrift {
|
wantPID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
||||||
t.Fatalf("expected SetDomainStatus(%s, drift), got %+v", did, ts.statusCalls)
|
if len(ts.statusCalls) != 1 || ts.statusCalls[0].domainID != did || ts.statusCalls[0].projectID != wantPID || ts.statusCalls[0].status != service.StatusDrift {
|
||||||
|
t.Fatalf("expected SetDomainStatus(%s, %s, drift), got %+v", did, wantPID, ts.statusCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +206,9 @@ func TestCheckEndpoint_PersistsInSyncStatus(t *testing.T) {
|
|||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if len(ts.statusCalls) != 1 || ts.statusCalls[0].status != service.StatusInSync {
|
wantPID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
||||||
t.Fatalf("expected SetDomainStatus(_, in_sync), got %+v", ts.statusCalls)
|
if len(ts.statusCalls) != 1 || ts.statusCalls[0].projectID != wantPID || ts.statusCalls[0].status != service.StatusInSync {
|
||||||
|
t.Fatalf("expected SetDomainStatus(_, %s, in_sync), got %+v", wantPID, ts.statusCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,8 +231,144 @@ func TestCheckEndpoint_ErrorPersistsErrorStatus(t *testing.T) {
|
|||||||
if w.Code != http.StatusInternalServerError {
|
if w.Code != http.StatusInternalServerError {
|
||||||
t.Fatalf("expected 500, got %d body %s", w.Code, w.Body.String())
|
t.Fatalf("expected 500, got %d body %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if len(ts.statusCalls) != 1 || ts.statusCalls[0].domainID != did || ts.statusCalls[0].status != service.StatusError {
|
wantPID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
||||||
t.Fatalf("expected SetDomainStatus(%s, error), got %+v", did, ts.statusCalls)
|
if len(ts.statusCalls) != 1 || ts.statusCalls[0].domainID != did || ts.statusCalls[0].projectID != wantPID || ts.statusCalls[0].status != service.StatusError {
|
||||||
|
t.Fatalf("expected SetDomainStatus(%s, %s, error), got %+v", did, wantPID, ts.statusCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckEndpoint_ErrorScopesStatusToCallerProject covers the HIGH
|
||||||
|
// IDOR-on-write fix: handleCheck's error branch must persist the failure
|
||||||
|
// status scoped to the caller's OWN project (pid from the URL/context), even
|
||||||
|
// when the domain ID in the URL belongs to (or doesn't exist in) a different
|
||||||
|
// tenant. The handler itself has no way to know whether did is foreign — the
|
||||||
|
// scoping guarantee comes from always passing pid through to
|
||||||
|
// Store.SetDomainStatus, which is enforced to be a no-op for a mismatched
|
||||||
|
// project_id at the store/SQL layer (see internal/store/schedule_test.go's
|
||||||
|
// TestSetDomainStatus_ScopedByProject_ForeignProjectIsNoOp). This test proves
|
||||||
|
// the handler holds up its side of that contract: pid, never a zero value or
|
||||||
|
// some other project, is what gets passed down.
|
||||||
|
func TestCheckEndpoint_ErrorScopesStatusToCallerProject(t *testing.T) {
|
||||||
|
a, m := newTestAPI()
|
||||||
|
ts := a.Store.(*mockTenantStore)
|
||||||
|
m.checkErr = errors.New("boom: provider unreachable")
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
callerPID := uuid.New()
|
||||||
|
// foreignDID stands in for a domain ID the caller does not own — from the
|
||||||
|
// handler's perspective it's just whatever {did} was in the URL; only the
|
||||||
|
// store layer can (and does) enforce that it isn't actually foreignPID's.
|
||||||
|
foreignDID := uuid.New()
|
||||||
|
req := requestWithSessionCookie(http.MethodGet,
|
||||||
|
"/api/v1/projects/"+callerPID.String()+"/domains/"+foreignDID.String()+"/check", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(ts.statusCalls) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 SetDomainStatus call, got %+v", ts.statusCalls)
|
||||||
|
}
|
||||||
|
call := ts.statusCalls[0]
|
||||||
|
if call.projectID != callerPID {
|
||||||
|
t.Fatalf("expected SetDomainStatus scoped to caller's own pid %s, got projectID %s (never empty/foreign)", callerPID, call.projectID)
|
||||||
|
}
|
||||||
|
if call.domainID != foreignDID || call.status != service.StatusError {
|
||||||
|
t.Fatalf("expected SetDomainStatus(%s, %s, error), got %+v", foreignDID, callerPID, call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyEndpoint_ProviderErrorSurfacesRealMessage covers the fix: when
|
||||||
|
// Svc.Apply fails with an error wrapping service.ErrProviderUnavailable (e.g.
|
||||||
|
// Selectel rejecting a change with a 409 conflict), the handler must respond
|
||||||
|
// 502 with the actual provider message in body.error — not a generic 500
|
||||||
|
// "internal error" that hides the real cause from the user.
|
||||||
|
func TestApplyEndpoint_ProviderErrorSurfacesRealMessage(t *testing.T) {
|
||||||
|
a, m := newTestAPI()
|
||||||
|
m.applyErr = fmt.Errorf("%w: %v", service.ErrProviderUnavailable,
|
||||||
|
errors.New("selectel POST /zones/x/rrset: 409: conflicting CNAME record exists"))
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
did := uuid.New().String()
|
||||||
|
req := requestWithSessionCookie(http.MethodPost,
|
||||||
|
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply",
|
||||||
|
strings.NewReader(`{}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("expected 502, got %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp["error"], "409") || !strings.Contains(resp["error"], "conflicting CNAME") {
|
||||||
|
t.Fatalf("expected real provider message in body, got %q", resp["error"])
|
||||||
|
}
|
||||||
|
if strings.Contains(resp["error"], "internal error") {
|
||||||
|
t.Fatalf("provider error must not be masked as internal error, got %q", resp["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyEndpoint_NonProviderErrorStaysGeneric covers the flip side: an
|
||||||
|
// unwrapped/local error (decrypt, db, loader) from Svc.Apply must still fall
|
||||||
|
// back to a generic 500 "internal error" — only provider errors get their
|
||||||
|
// real message surfaced.
|
||||||
|
func TestApplyEndpoint_NonProviderErrorStaysGeneric(t *testing.T) {
|
||||||
|
a, m := newTestAPI()
|
||||||
|
m.applyErr = errors.New("decrypt: cipher: message authentication failed")
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
did := uuid.New().String()
|
||||||
|
req := requestWithSessionCookie(http.MethodPost,
|
||||||
|
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply",
|
||||||
|
strings.NewReader(`{}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp["error"] != "internal error" {
|
||||||
|
t.Fatalf("expected generic internal error, got %q", resp["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckEndpoint_ProviderErrorSurfacesRealMessage mirrors the Apply case
|
||||||
|
// for /check: a provider-wrapped error must come back as 502 with the real
|
||||||
|
// provider message, while the existing status-persistence behavior (SetDomainStatus
|
||||||
|
// error before responding) is unaffected.
|
||||||
|
func TestCheckEndpoint_ProviderErrorSurfacesRealMessage(t *testing.T) {
|
||||||
|
a, m := newTestAPI()
|
||||||
|
ts := a.Store.(*mockTenantStore)
|
||||||
|
m.checkErr = fmt.Errorf("%w: %v", service.ErrProviderUnavailable,
|
||||||
|
errors.New("selectel GET /zones/x/rrset: 503: upstream unavailable"))
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
did := uuid.New()
|
||||||
|
req := requestWithSessionCookie(http.MethodGet,
|
||||||
|
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did.String()+"/check", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("expected 502, got %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp["error"], "503") || !strings.Contains(resp["error"], "upstream unavailable") {
|
||||||
|
t.Fatalf("expected real provider message in body, got %q", resp["error"])
|
||||||
|
}
|
||||||
|
if len(ts.statusCalls) != 1 || ts.statusCalls[0].status != service.StatusError {
|
||||||
|
t.Fatalf("expected SetDomainStatus(_, _, error) to still run, got %+v", ts.statusCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -40,11 +40,12 @@ func toAuthResponse(u store.User, p store.Project) authResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type applyRequest struct {
|
type applyRequest struct {
|
||||||
ApplyUpdates bool `json:"applyUpdates"`
|
Updates []string `json:"updates"`
|
||||||
ApplyPrunes bool `json:"applyPrunes"`
|
Prunes []string `json:"prunes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type recordView struct {
|
type recordView struct {
|
||||||
|
Key string `json:"key"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -61,7 +62,7 @@ type changesetResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toRecordView(d diff.RecordDiff) recordView {
|
func toRecordView(d diff.RecordDiff) recordView {
|
||||||
rv := recordView{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}
|
||||||
if d.Desired != nil {
|
if d.Desired != nil {
|
||||||
rv.Desired = d.Desired.Values
|
rv.Desired = d.Desired.Values
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,16 +39,23 @@ func (a *API) handleCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// Persist the failure so the domain badge reflects it instead of stale
|
// Persist the failure so the domain badge reflects it instead of stale
|
||||||
// "unknown"; the write error (if any) is logged, never masks the 500.
|
// "unknown"; the write error (if any) is logged, never masks the 500.
|
||||||
if serr := a.Store.SetDomainStatus(r.Context(), did, service.StatusError); serr != nil {
|
if serr := a.Store.SetDomainStatus(r.Context(), did, pid, service.StatusError); serr != nil {
|
||||||
log.Printf("api: set domain status (error) failed: %v", serr)
|
log.Printf("api: set domain status (error) failed: %v", serr)
|
||||||
}
|
}
|
||||||
log.Printf("api: check failed: %v", err)
|
log.Printf("api: check failed: %v", err)
|
||||||
|
// A provider failure (e.g. Selectel returning a 409 conflict) is safe
|
||||||
|
// and useful to show the user as-is; any other failure (decrypt/db/loader)
|
||||||
|
// stays a generic "internal error" to avoid leaking internals.
|
||||||
|
if errors.Is(err, service.ErrProviderUnavailable) {
|
||||||
|
writeErr(w, http.StatusBadGateway, service.ProviderMessage(err))
|
||||||
|
} else {
|
||||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Manual check persists status/history only — no notification. Notify
|
// Manual check persists status/history only — no notification. Notify
|
||||||
// remains the scheduler's responsibility (see internal/scheduler).
|
// remains the scheduler's responsibility (see internal/scheduler).
|
||||||
if serr := a.Store.SetDomainStatus(r.Context(), did, service.DeriveStatus(cs)); serr != nil {
|
if serr := a.Store.SetDomainStatus(r.Context(), did, pid, service.DeriveStatus(cs)); serr != nil {
|
||||||
log.Printf("api: set domain status failed: %v", serr)
|
log.Printf("api: set domain status failed: %v", serr)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
|
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
|
||||||
@@ -66,19 +73,26 @@ func (a *API) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
var req applyRequest
|
var req applyRequest
|
||||||
if r.Body != nil {
|
if r.Body != nil {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
|
||||||
// пустое тело допустимо → значения по умолчанию (prune=false);
|
// пустое тело допустимо → значения по умолчанию (пустые списки, ничего
|
||||||
// любая другая ошибка decode (битый JSON, неверные типы) → 400
|
// не применяется); любая другая ошибка decode (битый JSON, неверные
|
||||||
|
// типы) → 400
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cs, err := a.Svc.Apply(r.Context(), pid, did, service.ApplyRequest{
|
cs, err := a.Svc.Apply(r.Context(), pid, did, service.ApplyRequest{
|
||||||
ApplyUpdates: req.ApplyUpdates, ApplyPrunes: req.ApplyPrunes,
|
Updates: req.Updates, Prunes: req.Prunes,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("api: apply failed: %v", err)
|
log.Printf("api: apply failed: %v", err)
|
||||||
|
// Same distinction as handleCheck: surface the real provider message,
|
||||||
|
// keep everything else generic.
|
||||||
|
if errors.Is(err, service.ErrProviderUnavailable) {
|
||||||
|
writeErr(w, http.StatusBadGateway, service.ProviderMessage(err))
|
||||||
|
} else {
|
||||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
|
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ type mockTenantStore struct {
|
|||||||
|
|
||||||
setDomainTemplateErr error
|
setDomainTemplateErr error
|
||||||
|
|
||||||
// statusCalls records every SetDomainStatus(domainID, status) call, in
|
// statusCalls records every SetDomainStatus(domainID, projectID, status)
|
||||||
// order, so tests can assert what the handler persisted.
|
// call, in order, so tests can assert what the handler persisted — and,
|
||||||
|
// crucially, which projectID it scoped the write to (IDOR regression).
|
||||||
statusCalls []struct {
|
statusCalls []struct {
|
||||||
domainID uuid.UUID
|
domainID uuid.UUID
|
||||||
|
projectID uuid.UUID
|
||||||
status string
|
status string
|
||||||
}
|
}
|
||||||
setDomainStatusErr error
|
setDomainStatusErr error
|
||||||
@@ -135,12 +137,14 @@ func (m *mockTenantStore) SetDomainTemplate(_ context.Context, domainID, project
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetDomainStatus records the call for assertion instead of actually mutating
|
// SetDomainStatus records the call for assertion instead of actually mutating
|
||||||
// m.domains — handleCheck tests only need to verify what was written.
|
// m.domains — handleCheck tests only need to verify what was written, and
|
||||||
func (m *mockTenantStore) SetDomainStatus(_ context.Context, domainID uuid.UUID, status string) error {
|
// which projectID it was scoped to (IDOR regression coverage).
|
||||||
|
func (m *mockTenantStore) SetDomainStatus(_ context.Context, domainID, projectID uuid.UUID, status string) error {
|
||||||
m.statusCalls = append(m.statusCalls, struct {
|
m.statusCalls = append(m.statusCalls, struct {
|
||||||
domainID uuid.UUID
|
domainID uuid.UUID
|
||||||
|
projectID uuid.UUID
|
||||||
status string
|
status string
|
||||||
}{domainID, status})
|
}{domainID, projectID, status})
|
||||||
return m.setDomainStatusErr
|
return m.setDomainStatusErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ type RecordDiff struct {
|
|||||||
ReadOnly bool // NS/SOA — shown but never applied
|
ReadOnly bool // NS/SOA — shown but never applied
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key is the stable identifier of the RRset this diff targets, normalised the
|
||||||
|
// same way as model.Record.Key ("TYPE name."). Used to select individual diffs
|
||||||
|
// for a partial apply. Works for every Kind (Delete has no Desired, Add has no
|
||||||
|
// Actual) because Type/Name are always populated.
|
||||||
|
func (d RecordDiff) Key() string {
|
||||||
|
return model.Record{Type: d.Type, Name: d.Name}.Key()
|
||||||
|
}
|
||||||
|
|
||||||
type Changeset struct {
|
type Changeset struct {
|
||||||
Diffs []RecordDiff
|
Diffs []RecordDiff
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ func find(cs Changeset, key string) *RecordDiff {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRecordDiffKeyNormalizes pins RecordDiff.Key() down to the same
|
||||||
|
// normalization as model.Record.Key() (lowercase + trailing dot), for a
|
||||||
|
// Delete diff (which has no Desired, only Type/Name populated directly).
|
||||||
|
func TestRecordDiffKeyNormalizes(t *testing.T) {
|
||||||
|
d := RecordDiff{Kind: Delete, Type: model.A, Name: "Mail.Example.COM"}
|
||||||
|
if got := d.Key(); got != "A mail.example.com." {
|
||||||
|
t.Fatalf("key: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDiffAddUpdateDeleteInSync(t *testing.T) {
|
func TestDiffAddUpdateDeleteInSync(t *testing.T) {
|
||||||
tmpl := []model.Record{
|
tmpl := []model.Record{
|
||||||
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync
|
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ type Provider interface {
|
|||||||
Name() string
|
Name() string
|
||||||
ListZones(ctx context.Context, creds Credentials) ([]Zone, error)
|
ListZones(ctx context.Context, creds Credentials) ([]Zone, error)
|
||||||
GetRecords(ctx context.Context, creds Credentials, zoneID string) ([]model.Record, error)
|
GetRecords(ctx context.Context, creds Credentials, zoneID string) ([]model.Record, error)
|
||||||
|
// ApplyChanges MUST apply cs.Diffs in the order they are given and must
|
||||||
|
// not reorder or group them by Kind. The caller (service.Apply)
|
||||||
|
// deliberately places Delete diffs before Add/Update diffs, because some
|
||||||
|
// providers (e.g. Selectel) reject creating a CNAME on a name where a
|
||||||
|
// conflicting A record still exists. Implementations should apply diffs
|
||||||
|
// sequentially in the given order rather than batching by kind.
|
||||||
ApplyChanges(ctx context.Context, creds Credentials, zoneID string, cs diff.Changeset) error
|
ApplyChanges(ctx context.Context, creds Credentials, zoneID string, cs diff.Changeset) error
|
||||||
// Validate checks the credentials are usable (e.g. a trial auth), so a
|
// Validate checks the credentials are usable (e.g. a trial auth), so a
|
||||||
// bad account is rejected at creation time rather than at first import.
|
// bad account is rejected at creation time rather than at first import.
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ type SchedStore interface {
|
|||||||
TouchScheduleRun(ctx context.Context, projectID uuid.UUID, at time.Time) error
|
TouchScheduleRun(ctx context.Context, projectID uuid.UUID, at time.Time) error
|
||||||
ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error)
|
ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error)
|
||||||
GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string, error)
|
GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string, error)
|
||||||
SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error
|
// SetDomainStatus is scoped by projectID so a foreign domain ID can never
|
||||||
|
// have its status overwritten (IDOR-on-write) — see internal/store/tenant.go.
|
||||||
|
SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error
|
||||||
CountDriftDomains(ctx context.Context) (int, error)
|
CountDriftDomains(ctx context.Context) (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,12 +146,12 @@ func (s *Scheduler) checkDomain(ctx context.Context, projectID uuid.UUID, d stor
|
|||||||
cs, checkErr := s.checker.Check(ctx, projectID, d.ID)
|
cs, checkErr := s.checker.Check(ctx, projectID, d.ID)
|
||||||
dur := time.Since(start)
|
dur := time.Since(start)
|
||||||
|
|
||||||
newStatus := StatusInSync
|
// Derive the status via the same helper the manual check handler uses
|
||||||
switch {
|
// (internal/api/handlers.go) so both paths agree on what counts as
|
||||||
case checkErr != nil:
|
// "drift" vs. "in sync" — a failed check is always "error" regardless.
|
||||||
newStatus = StatusError
|
newStatus := StatusError
|
||||||
case len(cs.Actionable()) > 0:
|
if checkErr == nil {
|
||||||
newStatus = StatusDrift
|
newStatus = service.DeriveStatus(cs)
|
||||||
}
|
}
|
||||||
s.metrics.ObserveCheck(newStatus, dur)
|
s.metrics.ObserveCheck(newStatus, dur)
|
||||||
|
|
||||||
@@ -164,7 +166,7 @@ func (s *Scheduler) checkDomain(ctx context.Context, projectID uuid.UUID, d stor
|
|||||||
// check (drift or in_sync). Calling it again here would double-write
|
// check (drift or in_sync). Calling it again here would double-write
|
||||||
// check_runs history for the same check.
|
// check_runs history for the same check.
|
||||||
|
|
||||||
if err := s.store.SetDomainStatus(ctx, d.ID, newStatus); err != nil {
|
if err := s.store.SetDomainStatus(ctx, d.ID, projectID, newStatus); err != nil {
|
||||||
log.Printf("scheduler: set domain status for %s failed: %v", d.ID, err)
|
log.Printf("scheduler: set domain status for %s failed: %v", d.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ func (m *mockStore) GetDomainStatus(ctx context.Context, domainID uuid.UUID) (st
|
|||||||
return StatusUnknown, nil
|
return StatusUnknown, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockStore) SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error {
|
// SetDomainStatus ignores projectID here — this in-memory fake is keyed by
|
||||||
|
// domainID alone and isn't exercising the IDOR scoping itself (that's
|
||||||
|
// covered at the store layer / API handler level); it exists only to match
|
||||||
|
// the SchedStore interface signature.
|
||||||
|
func (m *mockStore) SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
m.status[domainID] = status
|
m.status[domainID] = status
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
@@ -23,6 +24,16 @@ import (
|
|||||||
// pick 502 vs 404 without leaking provider error details as "not found".
|
// pick 502 vs 404 without leaking provider error details as "not found".
|
||||||
var ErrProviderUnavailable = errors.New("service: provider unavailable")
|
var ErrProviderUnavailable = errors.New("service: provider unavailable")
|
||||||
|
|
||||||
|
// ProviderMessage extracts the provider's own error text from an error
|
||||||
|
// wrapped with ErrProviderUnavailable, stripping the sentinel prefix so
|
||||||
|
// callers can surface it to the user as-is (e.g. Selectel's "409: conflicting
|
||||||
|
// CNAME record exists"). Only meant to be called on errors that
|
||||||
|
// errors.Is(err, ErrProviderUnavailable) — otherwise it just returns
|
||||||
|
// err.Error() unchanged.
|
||||||
|
func ProviderMessage(err error) string {
|
||||||
|
return strings.TrimPrefix(err.Error(), ErrProviderUnavailable.Error()+": ")
|
||||||
|
}
|
||||||
|
|
||||||
// DomainRef is the minimal data the service needs about a domain.
|
// DomainRef is the minimal data the service needs about a domain.
|
||||||
type DomainRef struct {
|
type DomainRef struct {
|
||||||
ZoneID string
|
ZoneID string
|
||||||
@@ -50,8 +61,8 @@ type Recorder interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ApplyRequest struct {
|
type ApplyRequest struct {
|
||||||
ApplyUpdates bool
|
Updates []string // record keys (RecordDiff.Key) to add/update
|
||||||
ApplyPrunes bool
|
Prunes []string // record keys to delete
|
||||||
}
|
}
|
||||||
|
|
||||||
type DomainService struct {
|
type DomainService struct {
|
||||||
@@ -84,7 +95,11 @@ func (s *DomainService) resolve(ctx context.Context, projectID, domainID uuid.UU
|
|||||||
creds := provider.Credentials{Secret: string(secret)}
|
creds := provider.Credentials{Secret: string(secret)}
|
||||||
actual, err := p.GetRecords(ctx, creds, ref.ZoneID)
|
actual, err := p.GetRecords(ctx, creds, ref.ZoneID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
|
// Only a failure of the provider call itself is "provider unavailable" —
|
||||||
|
// LoadDomain/ByName/Decrypt errors above are local resolution failures
|
||||||
|
// (e.g. domain not found, bad stored credentials) and must not be
|
||||||
|
// conflated with it.
|
||||||
|
return nil, provider.Credentials{}, ref, diff.Changeset{}, fmt.Errorf("%w: %v", ErrProviderUnavailable, err)
|
||||||
}
|
}
|
||||||
cs := diff.Diff(tmpl.Materialize(ref.Template, ref.ZoneName), actual)
|
cs := diff.Diff(tmpl.Materialize(ref.Template, ref.ZoneName), actual)
|
||||||
return p, creds, ref, cs, nil
|
return p, creds, ref, cs, nil
|
||||||
@@ -128,24 +143,43 @@ func (s *DomainService) ZoneRecords(ctx context.Context, projectID, domainID uui
|
|||||||
return recs, nil
|
return recs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes.
|
// Apply applies exactly the diffs whose keys are selected in req.Updates and
|
||||||
|
// req.Prunes. Selected prunes are added to the applied set BEFORE selected
|
||||||
|
// updates: deletes first is an invariant, not an option — the provider
|
||||||
|
// rejects an Add/Update whose name still has a conflicting record (e.g. a
|
||||||
|
// CNAME cannot be created while an A on the same name exists), so pruning the
|
||||||
|
// old records before applying updates avoids that.
|
||||||
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
|
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
|
||||||
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
|
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return diff.Changeset{}, err
|
return diff.Changeset{}, err
|
||||||
}
|
}
|
||||||
|
selPrunes := toSet(req.Prunes)
|
||||||
|
selUpdates := toSet(req.Updates)
|
||||||
var toApply []diff.RecordDiff
|
var toApply []diff.RecordDiff
|
||||||
if req.ApplyUpdates {
|
for _, d := range cs.Prunes() {
|
||||||
toApply = append(toApply, cs.Updates()...)
|
if selPrunes[d.Key()] {
|
||||||
|
toApply = append(toApply, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range cs.Updates() {
|
||||||
|
if selUpdates[d.Key()] {
|
||||||
|
toApply = append(toApply, d)
|
||||||
}
|
}
|
||||||
if req.ApplyPrunes {
|
|
||||||
toApply = append(toApply, cs.Prunes()...)
|
|
||||||
}
|
}
|
||||||
applied := diff.Changeset{Diffs: toApply}
|
applied := diff.Changeset{Diffs: toApply}
|
||||||
if len(toApply) > 0 {
|
if len(toApply) > 0 {
|
||||||
if err := p.ApplyChanges(ctx, creds, ref.ZoneID, applied); err != nil {
|
if err := p.ApplyChanges(ctx, creds, ref.ZoneID, applied); err != nil {
|
||||||
return diff.Changeset{}, err
|
return diff.Changeset{}, fmt.Errorf("%w: %v", ErrProviderUnavailable, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return applied, nil
|
return applied, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toSet(keys []string) map[string]bool {
|
||||||
|
m := make(map[string]bool, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
m[k] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -28,6 +29,8 @@ func testCipher(t *testing.T) *crypto.Cipher {
|
|||||||
type fakeProvider struct {
|
type fakeProvider struct {
|
||||||
actual []model.Record
|
actual []model.Record
|
||||||
applied diff.Changeset
|
applied diff.Changeset
|
||||||
|
getErr error // when set, GetRecords fails with this error
|
||||||
|
applyErr error // when set, ApplyChanges fails with this error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fakeProvider) Name() string { return "selectel" }
|
func (fakeProvider) Name() string { return "selectel" }
|
||||||
@@ -35,9 +38,15 @@ func (fakeProvider) ListZones(context.Context, provider.Credentials) ([]provider
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *fakeProvider) GetRecords(context.Context, provider.Credentials, string) ([]model.Record, error) {
|
func (f *fakeProvider) GetRecords(context.Context, provider.Credentials, string) ([]model.Record, error) {
|
||||||
|
if f.getErr != nil {
|
||||||
|
return nil, f.getErr
|
||||||
|
}
|
||||||
return f.actual, nil
|
return f.actual, nil
|
||||||
}
|
}
|
||||||
func (f *fakeProvider) ApplyChanges(_ context.Context, _ provider.Credentials, _ string, cs diff.Changeset) error {
|
func (f *fakeProvider) ApplyChanges(_ context.Context, _ provider.Credentials, _ string, cs diff.Changeset) error {
|
||||||
|
if f.applyErr != nil {
|
||||||
|
return f.applyErr
|
||||||
|
}
|
||||||
f.applied = cs
|
f.applied = cs
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -125,39 +134,115 @@ func TestZoneRecordsReadsProviderDirectly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyRespectsPruneGuard(t *testing.T) {
|
// TestApplySelectsByKeyAndOrdersPrunesBeforeUpdates covers the two goals of
|
||||||
// зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат
|
// selective apply: (1) only diffs whose key is present in the request are
|
||||||
|
// applied, and (2) when both an update and a prune are selected, the prune
|
||||||
|
// (Delete) must land BEFORE the update in the applied Changeset — this is the
|
||||||
|
// regression guard for the provider rejecting an Add/Update whose name still
|
||||||
|
// conflicts with an existing record (e.g. a CNAME cannot be created while an
|
||||||
|
// A on the same name still exists).
|
||||||
|
func TestApplySelectsByKeyAndOrdersPrunesBeforeUpdates(t *testing.T) {
|
||||||
|
// zone: a needs updating (9.9.9.9 -> 1.1.1.1), b is an extra record not in
|
||||||
|
// the template (prune candidate).
|
||||||
actual := []model.Record{
|
actual := []model.Record{
|
||||||
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"9.9.9.9"}},
|
||||||
{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}},
|
{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}},
|
||||||
}
|
}
|
||||||
tmpl := dto.TemplateDoc{Records: []dto.RecordDTO{
|
tmpl := dto.TemplateDoc{Records: []dto.RecordDTO{
|
||||||
{Type: "A", Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync
|
{Type: "A", Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // update
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// applyPrunes=false → удаление b НЕ применяется
|
const updKey = "A a.example.com."
|
||||||
|
const pruneKey = "A b.example.com."
|
||||||
|
|
||||||
|
// Only the prune selected -> only the delete is applied.
|
||||||
svc, fp := setup(t, actual, tmpl)
|
svc, fp := setup(t, actual, tmpl)
|
||||||
if _, err := svc.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{ApplyUpdates: true, ApplyPrunes: false}); err != nil {
|
if _, err := svc.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{Prunes: []string{pruneKey}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, d := range fp.applied.Diffs {
|
if len(fp.applied.Diffs) != 1 || fp.applied.Diffs[0].Kind != diff.Delete {
|
||||||
if d.Kind == diff.Delete {
|
t.Fatalf("expected only the selected prune applied, got %+v", fp.applied.Diffs)
|
||||||
t.Fatalf("prune must be skipped when ApplyPrunes=false, applied: %+v", fp.applied.Diffs)
|
}
|
||||||
|
|
||||||
|
// Only the update selected -> only the update is applied.
|
||||||
|
svc2, fp2 := setup(t, actual, tmpl)
|
||||||
|
if _, err := svc2.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{Updates: []string{updKey}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(fp2.applied.Diffs) != 1 || fp2.applied.Diffs[0].Kind != diff.Update {
|
||||||
|
t.Fatalf("expected only the selected update applied, got %+v", fp2.applied.Diffs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing selected -> nothing applied.
|
||||||
|
svc3, fp3 := setup(t, actual, tmpl)
|
||||||
|
if _, err := svc3.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(fp3.applied.Diffs) != 0 {
|
||||||
|
t.Fatalf("expected nothing applied when nothing is selected, got %+v", fp3.applied.Diffs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: both selected -> the prune (Delete) must be applied FIRST,
|
||||||
|
// the update SECOND. Regressing this order reintroduces the
|
||||||
|
// CNAME/A-conflict bug where the provider rejects the update because the
|
||||||
|
// stale conflicting record hasn't been deleted yet.
|
||||||
|
svc4, fp4 := setup(t, actual, tmpl)
|
||||||
|
if _, err := svc4.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{Updates: []string{updKey}, Prunes: []string{pruneKey}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(fp4.applied.Diffs) != 2 {
|
||||||
|
t.Fatalf("expected both selected diffs applied, got %+v", fp4.applied.Diffs)
|
||||||
|
}
|
||||||
|
if fp4.applied.Diffs[0].Kind != diff.Delete {
|
||||||
|
t.Fatalf("expected prune (Delete) FIRST in applied order, got %+v", fp4.applied.Diffs)
|
||||||
|
}
|
||||||
|
if fp4.applied.Diffs[1].Kind != diff.Update {
|
||||||
|
t.Fatalf("expected update SECOND in applied order, got %+v", fp4.applied.Diffs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyPrunes=true → удаление b применяется
|
// TestApplyWrapsProviderError covers the fix: a failure from the provider's
|
||||||
svc2, fp2 := setup(t, actual, tmpl)
|
// ApplyChanges call (e.g. Selectel rejecting a change with a 409 conflict)
|
||||||
if _, err := svc2.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{ApplyUpdates: true, ApplyPrunes: true}); err != nil {
|
// must be wrapped in ErrProviderUnavailable so the API layer can tell it
|
||||||
t.Fatal(err)
|
// apart from a local resolution failure and surface the real provider
|
||||||
|
// message instead of a generic "internal error".
|
||||||
|
func TestApplyWrapsProviderError(t *testing.T) {
|
||||||
|
actual := []model.Record{{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"9.9.9.9"}}}
|
||||||
|
tmpl := dto.TemplateDoc{Records: []dto.RecordDTO{
|
||||||
|
{Type: "A", Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||||
|
}}
|
||||||
|
svc, fp := setup(t, actual, tmpl)
|
||||||
|
fp.applyErr = errors.New("selectel POST /zones/z1/rrset: 409: conflicting CNAME record exists")
|
||||||
|
|
||||||
|
_, err := svc.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{Updates: []string{"A a.example.com."}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
var sawDelete bool
|
if !errors.Is(err, ErrProviderUnavailable) {
|
||||||
for _, d := range fp2.applied.Diffs {
|
t.Fatalf("expected error to wrap ErrProviderUnavailable, got %v", err)
|
||||||
if d.Kind == diff.Delete && d.Name == "b.example.com." {
|
}
|
||||||
sawDelete = true
|
msg := ProviderMessage(err)
|
||||||
|
if msg != "selectel POST /zones/z1/rrset: 409: conflicting CNAME record exists" {
|
||||||
|
t.Fatalf("expected clean provider message, got %q", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !sawDelete {
|
|
||||||
t.Fatalf("prune must be applied when ApplyPrunes=true, applied: %+v", fp2.applied.Diffs)
|
// TestResolveWrapsProviderError covers the resolve helper shared by Check and
|
||||||
|
// Apply: a GetRecords failure from the provider must also be wrapped in
|
||||||
|
// ErrProviderUnavailable, mirroring ZoneRecords' existing behavior.
|
||||||
|
func TestResolveWrapsProviderError(t *testing.T) {
|
||||||
|
svc, fp := setup(t, nil, dto.TemplateDoc{})
|
||||||
|
fp.getErr = errors.New("selectel GET /zones/z1/rrset: 503: upstream unavailable")
|
||||||
|
|
||||||
|
_, err := svc.Check(context.Background(), uuid.New(), uuid.New())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrProviderUnavailable) {
|
||||||
|
t.Fatalf("expected error to wrap ErrProviderUnavailable, got %v", err)
|
||||||
|
}
|
||||||
|
msg := ProviderMessage(err)
|
||||||
|
if msg != "selectel GET /zones/z1/rrset: 503: upstream unavailable" {
|
||||||
|
t.Fatalf("expected clean provider message, got %q", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,16 +218,17 @@ func (q *Queries) LoadDomainFull(ctx context.Context, arg LoadDomainFullParams)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const setDomainStatus = `-- name: SetDomainStatus :exec
|
const setDomainStatus = `-- name: SetDomainStatus :exec
|
||||||
UPDATE domains SET last_check_status = $2 WHERE id = $1
|
UPDATE domains SET last_check_status = $2 WHERE id = $1 AND project_id = $3
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetDomainStatusParams struct {
|
type SetDomainStatusParams struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
LastCheckStatus string `json:"last_check_status"`
|
LastCheckStatus string `json:"last_check_status"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SetDomainStatus(ctx context.Context, arg SetDomainStatusParams) error {
|
func (q *Queries) SetDomainStatus(ctx context.Context, arg SetDomainStatusParams) error {
|
||||||
_, err := q.db.Exec(ctx, setDomainStatus, arg.ID, arg.LastCheckStatus)
|
_, err := q.db.Exec(ctx, setDomainStatus, arg.ID, arg.LastCheckStatus, arg.ProjectID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ WHERE d.id = $1 AND d.project_id = $2;
|
|||||||
SELECT last_check_status FROM domains WHERE id = $1;
|
SELECT last_check_status FROM domains WHERE id = $1;
|
||||||
|
|
||||||
-- name: SetDomainStatus :exec
|
-- name: SetDomainStatus :exec
|
||||||
UPDATE domains SET last_check_status = $2 WHERE id = $1;
|
UPDATE domains SET last_check_status = $2 WHERE id = $1 AND project_id = $3;
|
||||||
|
|
||||||
-- name: CountDriftDomains :one
|
-- name: CountDriftDomains :one
|
||||||
SELECT count(*) FROM domains WHERE last_check_status = 'drift';
|
SELECT count(*) FROM domains WHERE last_check_status = 'drift';
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ func TestDomainStatus_RoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("expected default status 'unknown', got %q", status)
|
t.Fatalf("expected default status 'unknown', got %q", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.SetDomainStatus(ctx, d.ID, "ok"); err != nil {
|
if err := s.SetDomainStatus(ctx, d.ID, p.ID, "ok"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
status, err = s.GetDomainStatus(ctx, d.ID)
|
status, err = s.GetDomainStatus(ctx, d.ID)
|
||||||
@@ -265,3 +265,56 @@ func TestDomainStatus_RoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("expected ListDomains to reflect updated status: %+v", domains)
|
t.Fatalf("expected ListDomains to reflect updated status: %+v", domains)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSetDomainStatus_ScopedByProject_ForeignProjectIsNoOp covers the IDOR
|
||||||
|
// fix: SetDomainStatus is called with a valid domain ID but a projectID that
|
||||||
|
// does NOT own it (e.g. an authenticated caller's own pid, paired with
|
||||||
|
// another tenant's did in the URL). The WHERE id = $1 AND project_id = $3
|
||||||
|
// clause must match zero rows — no error, but the foreign domain's status
|
||||||
|
// must remain untouched, never "error"/"drift"/whatever was passed in.
|
||||||
|
func TestSetDomainStatus_ScopedByProject_ForeignProjectIsNoOp(t *testing.T) {
|
||||||
|
s, ctx := newStore(t)
|
||||||
|
_, owner, err := s.RegisterUser(ctx, "domain-status-owner@example.com", "argon2-hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, attacker, err := s.RegisterUser(ctx, "domain-status-attacker@example.com", "argon2-hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
acc, err := s.CreateAccount(ctx, owner.ID, "selectel", "enc-blob", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d, err := s.CreateDomain(ctx, owner.ID, acc.ID, "example.com", "zone-1", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attacker's own (valid) project ID, paired with owner's domain ID —
|
||||||
|
// mirrors the exact request shape an authenticated attacker could send.
|
||||||
|
if err := s.SetDomainStatus(ctx, d.ID, attacker.ID, "error"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.GetDomainStatus(ctx, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != "unknown" {
|
||||||
|
t.Fatalf("expected foreign-project SetDomainStatus to be a no-op, but status changed to %q", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The legitimate owner can still update it — proves the no-op above was
|
||||||
|
// due to project scoping, not some unrelated write failure.
|
||||||
|
if err := s.SetDomainStatus(ctx, d.ID, owner.ID, "error"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
status, err = s.GetDomainStatus(ctx, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != "error" {
|
||||||
|
t.Fatalf("expected owner's SetDomainStatus to apply, got %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -253,9 +253,13 @@ func (s *Store) GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetDomainStatus records the outcome of the most recent check/apply run for
|
// SetDomainStatus records the outcome of the most recent check/apply run for
|
||||||
// a domain (e.g. "ok", "drift", "error").
|
// a domain (e.g. "ok", "drift", "error"). Scoped by projectID — a domain ID
|
||||||
func (s *Store) SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error {
|
// belonging to another tenant's project is left untouched (matches zero
|
||||||
return s.q.SetDomainStatus(ctx, db.SetDomainStatusParams{ID: domainID, LastCheckStatus: status})
|
// rows) rather than being overwritten, closing an IDOR-on-write where a
|
||||||
|
// caller's own valid pid + a foreign did could otherwise flip a stranger's
|
||||||
|
// domain status.
|
||||||
|
func (s *Store) SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error {
|
||||||
|
return s.q.SetDomainStatus(ctx, db.SetDomainStatusParams{ID: domainID, LastCheckStatus: status, ProjectID: projectID})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountDriftDomains returns the current number of domains system-wide whose
|
// CountDriftDomains returns the current number of domains system-wide whose
|
||||||
|
|||||||
@@ -99,12 +99,13 @@ describe("api client", () => {
|
|||||||
await expect(api.listDomains(PROJECT_ID)).rejects.toThrow(UnauthorizedError)
|
await expect(api.listDomains(PROJECT_ID)).rejects.toThrow(UnauthorizedError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("applies with prune flag using projectId, id, body order", async () => {
|
it("applies with selected record keys using projectId, id, body order", async () => {
|
||||||
const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
||||||
await api.applyDomain(PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true })
|
await api.applyDomain(PROJECT_ID, "d1", { updates: ["A a."], prunes: ["A b."] })
|
||||||
const [url, opts] = spy.mock.calls[0]
|
const [url, opts] = spy.mock.calls[0]
|
||||||
expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1/apply`)
|
expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1/apply`)
|
||||||
expect(String((opts as RequestInit).body)).toContain("applyPrunes")
|
expect(String((opts as RequestInit).body)).toContain("prunes")
|
||||||
|
expect(JSON.parse(String((opts as RequestInit).body))).toEqual({ updates: ["A a."], prunes: ["A b."] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it("checkDomain(projectId, id) hits project-scoped check path", async () => {
|
it("checkDomain(projectId, id) hits project-scoped check path", async () => {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface CreateChannelInput { type: string; config: object; secret: stri
|
|||||||
export interface CheckRun { id?: string; createdAt: string; result: object }
|
export interface CheckRun { id?: string; createdAt: string; result: object }
|
||||||
|
|
||||||
export interface RecordView {
|
export interface RecordView {
|
||||||
|
key: string // stable "TYPE name." identifier — used to select this record for Apply
|
||||||
kind: string // add | update | delete | in_sync
|
kind: string // add | update | delete | in_sync
|
||||||
type: string
|
type: string
|
||||||
name: string
|
name: string
|
||||||
@@ -51,4 +52,4 @@ export interface ChangesetResponse {
|
|||||||
readOnly: RecordView[]
|
readOnly: RecordView[]
|
||||||
inSyncCount: number
|
inSyncCount: number
|
||||||
}
|
}
|
||||||
export interface ApplyRequest { applyUpdates: boolean; applyPrunes: boolean }
|
export interface ApplyRequest { updates: string[]; prunes: string[] }
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
import { render, screen } from "@testing-library/react"
|
import { render, screen } from "@testing-library/react"
|
||||||
|
import userEvent from "@testing-library/user-event"
|
||||||
import { DiffView } from "./DiffView"
|
import { DiffView } from "./DiffView"
|
||||||
import type { ChangesetResponse } from "@/api/types"
|
import type { ChangesetResponse } from "@/api/types"
|
||||||
|
|
||||||
const cs: ChangesetResponse = {
|
const cs: ChangesetResponse = {
|
||||||
updates: [{ kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }],
|
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: [{ kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }],
|
prunes: [{ key: "A old.example.com.", kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }],
|
||||||
readOnly: [{ kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }],
|
readOnly: [{ key: "NS example.com.", kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }],
|
||||||
inSyncCount: 3,
|
inSyncCount: 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function noop() { /* unused in most tests */ }
|
||||||
|
|
||||||
|
function renderDiff(overrides: Partial<Parameters<typeof DiffView>[0]> = {}) {
|
||||||
|
return render(
|
||||||
|
<DiffView
|
||||||
|
changeset={cs}
|
||||||
|
selectedUpdates={new Set(["A www.example.com."])}
|
||||||
|
selectedPrunes={new Set()}
|
||||||
|
onToggleUpdate={noop}
|
||||||
|
onTogglePrune={noop}
|
||||||
|
onToggleAllUpdates={noop}
|
||||||
|
onToggleAllPrunes={noop}
|
||||||
|
{...overrides}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
test("renders all sections with counts", () => {
|
test("renders all sections with counts", () => {
|
||||||
render(<DiffView changeset={cs} />)
|
renderDiff()
|
||||||
expect(screen.getByText(/www\.example\.com\./)).toBeInTheDocument()
|
expect(screen.getByText(/www\.example\.com\./)).toBeInTheDocument()
|
||||||
expect(screen.getByText(/old\.example\.com\./)).toBeInTheDocument()
|
expect(screen.getByText(/old\.example\.com\./)).toBeInTheDocument()
|
||||||
// Anchored (vs. the brief's bare /example\.com\./) — "www.example.com." and
|
// Anchored (vs. the brief's bare /example\.com\./) — "www.example.com." and
|
||||||
@@ -23,7 +41,7 @@ test("renders all sections with counts", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("marks read-only records", () => {
|
test("marks read-only records", () => {
|
||||||
render(<DiffView changeset={cs} />)
|
renderDiff()
|
||||||
expect(screen.getByText(/NS/)).toBeInTheDocument()
|
expect(screen.getByText(/NS/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -35,6 +53,7 @@ test("renders a very long unbreakable value (DKIM key) without crashing", () =>
|
|||||||
const csWithDkim: ChangesetResponse = {
|
const csWithDkim: ChangesetResponse = {
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
|
key: "TXT default._domainkey.example.com.",
|
||||||
kind: "update",
|
kind: "update",
|
||||||
type: "TXT",
|
type: "TXT",
|
||||||
name: "default._domainkey.example.com.",
|
name: "default._domainkey.example.com.",
|
||||||
@@ -47,7 +66,7 @@ test("renders a very long unbreakable value (DKIM key) without crashing", () =>
|
|||||||
readOnly: [],
|
readOnly: [],
|
||||||
inSyncCount: 0,
|
inSyncCount: 0,
|
||||||
}
|
}
|
||||||
render(<DiffView changeset={csWithDkim} />)
|
renderDiff({ changeset: csWithDkim, selectedUpdates: new Set() })
|
||||||
expect(screen.getByText(new RegExp(longValue))).toBeInTheDocument()
|
expect(screen.getByText(new RegExp(longValue))).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -60,7 +79,77 @@ test("does not crash when changeset fields are null", () => {
|
|||||||
readOnly: null,
|
readOnly: null,
|
||||||
inSyncCount: 5,
|
inSyncCount: 5,
|
||||||
} as unknown as ChangesetResponse
|
} as unknown as ChangesetResponse
|
||||||
render(<DiffView changeset={nullish} />)
|
renderDiff({ changeset: nullish, selectedUpdates: new Set() })
|
||||||
expect(screen.getByText(/5/)).toBeInTheDocument()
|
expect(screen.getByText(/5/)).toBeInTheDocument()
|
||||||
expect(screen.getByText(/in sync/)).toBeInTheDocument()
|
expect(screen.getByText(/in sync/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("renders a checkbox for update and prune rows but not for read-only rows", () => {
|
||||||
|
renderDiff()
|
||||||
|
// 2 select-all (update + prune headers) + 2 row checkboxes (one update, one prune).
|
||||||
|
// Read-only section contributes none: no select-all, no row checkbox.
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox")
|
||||||
|
expect(checkboxes).toHaveLength(4)
|
||||||
|
|
||||||
|
const updateRowCheckbox = screen.getByRole("checkbox", { name: /www\.example\.com\./ })
|
||||||
|
expect(updateRowCheckbox).toBeInTheDocument()
|
||||||
|
const pruneRowCheckbox = screen.getByRole("checkbox", { name: /old\.example\.com\./ })
|
||||||
|
expect(pruneRowCheckbox).toBeInTheDocument()
|
||||||
|
|
||||||
|
expect(screen.queryByRole("checkbox", { name: /example\.com\..*NS|NS.*example\.com\./ })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clicking an update row checkbox calls onToggleUpdate with the record key", async () => {
|
||||||
|
const onToggleUpdate = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderDiff({ onToggleUpdate })
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: /www\.example\.com\./ }))
|
||||||
|
expect(onToggleUpdate).toHaveBeenCalledWith("A www.example.com.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clicking a prune row checkbox calls onTogglePrune with the record key", async () => {
|
||||||
|
const onTogglePrune = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderDiff({ onTogglePrune })
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: /old\.example\.com\./ }))
|
||||||
|
expect(onTogglePrune).toHaveBeenCalledWith("A old.example.com.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("select-all header checkbox is checked when all rows in the section are selected", () => {
|
||||||
|
renderDiff({ selectedUpdates: new Set(["A www.example.com."]) })
|
||||||
|
const selectAll = screen.getByRole("checkbox", { name: /выбрать все.*updates/i })
|
||||||
|
expect(selectAll).toHaveAttribute("aria-checked", "true")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("select-all header checkbox calls onToggleAllUpdates(true) when clicked while none selected", async () => {
|
||||||
|
const onToggleAllUpdates = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderDiff({ selectedUpdates: new Set(), onToggleAllUpdates })
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: /выбрать все.*updates/i }))
|
||||||
|
expect(onToggleAllUpdates).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
prunes: [],
|
||||||
|
readOnly: [],
|
||||||
|
inSyncCount: 0,
|
||||||
|
}
|
||||||
|
renderDiff({
|
||||||
|
changeset: csWithMultipleUpdates,
|
||||||
|
// Partial selection: one of three keys — neither all nor none — is what
|
||||||
|
// must drive the header checkbox into the indeterminate ("mixed") state.
|
||||||
|
selectedUpdates: new Set(["A www.example.com."]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectAll = screen.getByRole("checkbox", { name: /выбрать все.*updates/i })
|
||||||
|
expect(selectAll).toHaveAttribute("aria-checked", "mixed")
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
|
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { ChangesetResponse, RecordView } from "@/api/types"
|
import type { ChangesetResponse, RecordView } from "@/api/types"
|
||||||
|
|
||||||
@@ -40,9 +41,23 @@ function Values({ values }: { values?: string[] }) {
|
|||||||
return <>{values.join(", ")}</>
|
return <>{values.join(", ")}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
function RecordRow({
|
||||||
|
record,
|
||||||
|
tone,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
record: RecordView
|
||||||
|
tone: Tone
|
||||||
|
checked?: boolean
|
||||||
|
onToggle?: (key: string) => void
|
||||||
|
}) {
|
||||||
const meta = TONE_META[tone]
|
const meta = TONE_META[tone]
|
||||||
const showArrow = tone !== "delete"
|
const showArrow = tone !== "delete"
|
||||||
|
// Read-only records aren't selectable — onToggle is only passed for
|
||||||
|
// update/delete sections. Presence of onToggle is the selectability flag,
|
||||||
|
// not the tone, so this stays correct if a tone's selectability ever changes.
|
||||||
|
const selectable = onToggle !== undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -52,9 +67,18 @@ function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
|||||||
)}
|
)}
|
||||||
style={{ borderLeftColor: meta.dot }}
|
style={{ borderLeftColor: meta.dot }}
|
||||||
>
|
>
|
||||||
{/* Top line: type badge, name, read-only flag — always single-line,
|
{/* Top line: (optional) checkbox, type badge, name, read-only flag —
|
||||||
never affected by how long the record values are. */}
|
always single-line, never affected by how long the record values are. */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{selectable && (
|
||||||
|
<Checkbox
|
||||||
|
checked={checked ?? false}
|
||||||
|
onCheckedChange={() => onToggle!(record.key)}
|
||||||
|
aria-label={`${tone === "delete" ? "Удалить" : "Применить"} ${record.type} ${record.name}`}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="font-dns w-11 shrink-0 justify-center border-border text-[10px] tracking-wide text-muted-foreground"
|
className="font-dns w-11 shrink-0 justify-center border-border text-[10px] tracking-wide text-muted-foreground"
|
||||||
@@ -81,8 +105,14 @@ function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
|||||||
unbreakable value like a DKIM key wraps within the row's own
|
unbreakable value like a DKIM key wraps within the row's own
|
||||||
width instead of stretching it — a flex item's content can
|
width instead of stretching it — a flex item's content can
|
||||||
otherwise refuse to shrink below its intrinsic width. Indented
|
otherwise refuse to shrink below its intrinsic width. Indented
|
||||||
to align under the name (badge width + gap). */}
|
to align under the name (badge width + gap, plus checkbox width +
|
||||||
<div className="font-dns hidden pl-14 text-xs leading-relaxed break-all text-muted-foreground sm:block">
|
gap when this row is selectable). */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"font-dns hidden text-xs leading-relaxed break-all text-muted-foreground sm:block",
|
||||||
|
selectable ? "pl-[5.25rem]" : "pl-14",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Values values={record.actual} />
|
<Values values={record.actual} />
|
||||||
{showArrow && (
|
{showArrow && (
|
||||||
<>
|
<>
|
||||||
@@ -104,16 +134,36 @@ function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
|||||||
function Section({
|
function Section({
|
||||||
tone,
|
tone,
|
||||||
records,
|
records,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
onToggleAll,
|
||||||
}: {
|
}: {
|
||||||
tone: Tone
|
tone: Tone
|
||||||
records: RecordView[]
|
records: RecordView[]
|
||||||
|
selected?: Set<string>
|
||||||
|
onToggle?: (key: string) => void
|
||||||
|
onToggleAll?: (checked: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const meta = TONE_META[tone]
|
const meta = TONE_META[tone]
|
||||||
const Icon = meta.icon
|
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
|
||||||
|
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
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={meta.label} className="flex flex-col gap-2">
|
<section aria-label={meta.label} className="flex flex-col gap-2">
|
||||||
<header className="flex items-center gap-2 px-0.5">
|
<header className="flex items-center gap-2 px-0.5">
|
||||||
|
{selectable && records.length > 0 && (
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={indeterminate}
|
||||||
|
onCheckedChange={(v) => onToggleAll!(v === true)}
|
||||||
|
aria-label={`Выбрать все — ${meta.label}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Icon className="size-3.5" strokeWidth={1.75} style={{ color: meta.dot }} />
|
<Icon className="size-3.5" strokeWidth={1.75} style={{ color: meta.dot }} />
|
||||||
<h2 className="text-xs font-semibold tracking-wide text-foreground uppercase">
|
<h2 className="text-xs font-semibold tracking-wide text-foreground uppercase">
|
||||||
{meta.label}
|
{meta.label}
|
||||||
@@ -134,8 +184,14 @@ function Section({
|
|||||||
meta.ring,
|
meta.ring,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{records.map((record, i) => (
|
{records.map((record) => (
|
||||||
<RecordRow key={`${record.type}-${record.name}-${i}`} record={record} tone={tone} />
|
<RecordRow
|
||||||
|
key={record.key}
|
||||||
|
record={record}
|
||||||
|
tone={tone}
|
||||||
|
checked={selectable ? selected!.has(record.key) : undefined}
|
||||||
|
onToggle={selectable ? onToggle : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -145,17 +201,41 @@ function Section({
|
|||||||
|
|
||||||
export function DiffView({
|
export function DiffView({
|
||||||
changeset,
|
changeset,
|
||||||
|
selectedUpdates,
|
||||||
|
selectedPrunes,
|
||||||
|
onToggleUpdate,
|
||||||
|
onTogglePrune,
|
||||||
|
onToggleAllUpdates,
|
||||||
|
onToggleAllPrunes,
|
||||||
footerExtra,
|
footerExtra,
|
||||||
}: {
|
}: {
|
||||||
changeset: ChangesetResponse
|
changeset: ChangesetResponse
|
||||||
|
selectedUpdates: Set<string>
|
||||||
|
selectedPrunes: Set<string>
|
||||||
|
onToggleUpdate: (key: string) => void
|
||||||
|
onTogglePrune: (key: string) => void
|
||||||
|
onToggleAllUpdates: (checked: boolean) => void
|
||||||
|
onToggleAllPrunes: (checked: boolean) => void
|
||||||
footerExtra?: ReactNode
|
footerExtra?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
// Defensive: a field may arrive as null (e.g. a nil slice from an older
|
// Defensive: a field may arrive as null (e.g. a nil slice from an older
|
||||||
// backend) — normalise to [] so Section never calls .length/.map on null.
|
// backend) — normalise to [] so Section never calls .length/.map on null.
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Section tone="update" records={changeset.updates ?? []} />
|
<Section
|
||||||
<Section tone="delete" records={changeset.prunes ?? []} />
|
tone="update"
|
||||||
|
records={changeset.updates ?? []}
|
||||||
|
selected={selectedUpdates}
|
||||||
|
onToggle={onToggleUpdate}
|
||||||
|
onToggleAll={onToggleAllUpdates}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
tone="delete"
|
||||||
|
records={changeset.prunes ?? []}
|
||||||
|
selected={selectedPrunes}
|
||||||
|
onToggle={onTogglePrune}
|
||||||
|
onToggleAll={onToggleAllPrunes}
|
||||||
|
/>
|
||||||
<Section tone="readonly" records={changeset.readOnly ?? []} />
|
<Section tone="readonly" records={changeset.readOnly ?? []} />
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-indeterminate:border-primary data-indeterminate:bg-primary/40 data-indeterminate:text-primary-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ beforeEach(() => {
|
|||||||
vi.spyOn(api, "listDomains").mockResolvedValue([domainWithTemplate])
|
vi.spyOn(api, "listDomains").mockResolvedValue([domainWithTemplate])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("apply sends applyPrunes=false by default, true only after opting in", async () => {
|
test("default selection: updates checked, prunes unchecked; apply sends only selected keys", async () => {
|
||||||
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||||
updates: [{ 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 }],
|
||||||
prunes: [{ kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
|
prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
|
||||||
readOnly: [], inSyncCount: 0,
|
readOnly: [], inSyncCount: 0,
|
||||||
})
|
})
|
||||||
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
||||||
@@ -52,22 +52,51 @@ test("apply sends applyPrunes=false by default, true only after opting in", asyn
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
const applyBtn = await screen.findByRole("button", { name: /apply/i })
|
const updateRowCheckbox = await screen.findByRole("checkbox", { name: /a\.$/ })
|
||||||
|
const pruneRowCheckbox = screen.getByRole("checkbox", { name: /b\.$/ })
|
||||||
|
expect(updateRowCheckbox).toHaveAttribute("aria-checked", "true")
|
||||||
|
expect(pruneRowCheckbox).toHaveAttribute("aria-checked", "false")
|
||||||
|
expect(screen.queryByText(/будет удалено записей/i)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
const applyBtn = screen.getByRole("button", { name: /apply/i })
|
||||||
await user.click(applyBtn)
|
await user.click(applyBtn)
|
||||||
await waitFor(() => expect(applySpy).toHaveBeenCalled())
|
await waitFor(() => expect(applySpy).toHaveBeenCalled())
|
||||||
expect(applySpy.mock.calls[0]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: false }])
|
expect(applySpy.mock.calls[0]).toEqual([PROJECT_ID, "d1", { updates: ["A a."], prunes: [] }])
|
||||||
|
|
||||||
|
// отметить prune → появляется предупреждение с количеством, и Apply шлёт его ключ тоже
|
||||||
|
await user.click(pruneRowCheckbox)
|
||||||
|
const warning = screen.getByRole("alert")
|
||||||
|
expect(warning).toHaveTextContent(/будет удалено записей:\s*1/i)
|
||||||
|
|
||||||
// включить prune и применить снова
|
|
||||||
const pruneToggle = screen.getByRole("checkbox", { name: /prune|удал/i })
|
|
||||||
await user.click(pruneToggle)
|
|
||||||
await user.click(screen.getByRole("button", { name: /apply/i }))
|
await user.click(screen.getByRole("button", { name: /apply/i }))
|
||||||
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
|
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
|
||||||
expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }])
|
expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { updates: ["A a."], prunes: ["A b."] }])
|
||||||
|
|
||||||
// домен с шаблоном: записи зоны не нужны для диффа — запрос не должен уходить к провайдеру
|
// домен с шаблоном: записи зоны не нужны для диффа — запрос не должен уходить к провайдеру
|
||||||
expect(zoneRecordsSpy).not.toHaveBeenCalled()
|
expect(zoneRecordsSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 }],
|
||||||
|
prunes: [],
|
||||||
|
readOnly: [], inSyncCount: 0,
|
||||||
|
})
|
||||||
|
vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
const applyBtn = await screen.findByRole("button", { name: /apply/i })
|
||||||
|
expect(applyBtn).not.toBeDisabled()
|
||||||
|
expect(screen.getByText(/готово к применению/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
const updateRowCheckbox = screen.getByRole("checkbox", { name: /a\.$/ })
|
||||||
|
await user.click(updateRowCheckbox)
|
||||||
|
|
||||||
|
expect(applyBtn).toBeDisabled()
|
||||||
|
expect(screen.getByText(/изменений для применения нет/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
test("пока список доменов грузится — показан общий лоадер, а не баннер об отсутствии шаблона", async () => {
|
test("пока список доменов грузится — показан общий лоадер, а не баннер об отсутствии шаблона", async () => {
|
||||||
let resolveListDomains: (domains: Domain[]) => void
|
let resolveListDomains: (domains: Domain[]) => void
|
||||||
vi.spyOn(api, "listDomains").mockReturnValue(
|
vi.spyOn(api, "listDomains").mockReturnValue(
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useId, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react"
|
import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react"
|
||||||
import { DiffView } from "@/components/DiffView"
|
import { DiffView } from "@/components/DiffView"
|
||||||
import { DomainHistory } from "@/components/DomainHistory"
|
import { DomainHistory } from "@/components/DomainHistory"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -37,17 +35,48 @@ export function DomainDiffPage() {
|
|||||||
// (успешный ответ), что шаблона нет.
|
// (успешный ответ), что шаблона нет.
|
||||||
const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate)
|
const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate)
|
||||||
const createTemplateFromZone = useCreateTemplateFromZone()
|
const createTemplateFromZone = useCreateTemplateFromZone()
|
||||||
const [applyPrunes, setApplyPrunes] = useState(false)
|
const [selectedUpdates, setSelectedUpdates] = useState<Set<string>>(new Set())
|
||||||
const pruneCheckboxId = useId()
|
const [selectedPrunes, setSelectedPrunes] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const changeset = check.data
|
const changeset = check.data
|
||||||
const hasPrunes = (changeset?.prunes?.length ?? 0) > 0
|
|
||||||
const hasUpdates = (changeset?.updates?.length ?? 0) > 0
|
// Re-derive the selection whenever the changeset changes (initial load,
|
||||||
const pruneWarning = applyPrunes && hasPrunes
|
// recheck, or apply's own invalidation): updates default to fully selected
|
||||||
|
// (safe — they only bring the zone in line with the template), prunes
|
||||||
|
// default to empty (deletion is opt-in and irreversible).
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedUpdates(new Set((changeset?.updates ?? []).map((r) => r.key)))
|
||||||
|
setSelectedPrunes(new Set())
|
||||||
|
}, [changeset])
|
||||||
|
|
||||||
const recordList = zoneRecords.data ?? []
|
const recordList = zoneRecords.data ?? []
|
||||||
|
const hasSelection = selectedUpdates.size + selectedPrunes.size > 0
|
||||||
|
|
||||||
|
function toggleUpdate(key: string) {
|
||||||
|
setSelectedUpdates((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function togglePrune(key: string) {
|
||||||
|
setSelectedPrunes((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function toggleAllUpdates(checked: boolean) {
|
||||||
|
setSelectedUpdates(checked ? new Set((changeset?.updates ?? []).map((r) => r.key)) : new Set())
|
||||||
|
}
|
||||||
|
function toggleAllPrunes(checked: boolean) {
|
||||||
|
setSelectedPrunes(checked ? new Set((changeset?.prunes ?? []).map((r) => r.key)) : new Set())
|
||||||
|
}
|
||||||
|
|
||||||
function onApply() {
|
function onApply() {
|
||||||
apply.mutate({ applyUpdates: true, applyPrunes })
|
apply.mutate({ updates: [...selectedUpdates], prunes: [...selectedPrunes] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCreateTemplateFromZone() {
|
function onCreateTemplateFromZone() {
|
||||||
@@ -197,36 +226,18 @@ export function DomainDiffPage() {
|
|||||||
|
|
||||||
{hasTemplate && changeset && (
|
{hasTemplate && changeset && (
|
||||||
<>
|
<>
|
||||||
<DiffView changeset={changeset} />
|
<DiffView
|
||||||
|
changeset={changeset}
|
||||||
|
selectedUpdates={selectedUpdates}
|
||||||
|
selectedPrunes={selectedPrunes}
|
||||||
|
onToggleUpdate={toggleUpdate}
|
||||||
|
onTogglePrune={togglePrune}
|
||||||
|
onToggleAllUpdates={toggleAllUpdates}
|
||||||
|
onToggleAllPrunes={toggleAllPrunes}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
|
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
|
||||||
<Label
|
{selectedPrunes.size > 0 && (
|
||||||
htmlFor={pruneCheckboxId}
|
|
||||||
className="flex items-start gap-2.5 text-sm font-normal"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={pruneCheckboxId}
|
|
||||||
aria-label="prune — удалить лишние записи"
|
|
||||||
checked={applyPrunes}
|
|
||||||
onCheckedChange={(v) => setApplyPrunes(v === true)}
|
|
||||||
className="mt-0.5"
|
|
||||||
style={
|
|
||||||
applyPrunes
|
|
||||||
? ({ borderColor: "var(--diff-delete)", background: "var(--diff-delete)" } as React.CSSProperties)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="flex flex-col gap-0.5">
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
Prune — удалить записи, которых нет в шаблоне
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
По умолчанию выключено. Apply меняет только записи из шаблона.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
{pruneWarning && (
|
|
||||||
<div
|
<div
|
||||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs"
|
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs"
|
||||||
style={{
|
style={{
|
||||||
@@ -237,8 +248,8 @@ export function DomainDiffPage() {
|
|||||||
>
|
>
|
||||||
<TriangleAlert className="mt-px size-3.5 shrink-0" strokeWidth={2} />
|
<TriangleAlert className="mt-px size-3.5 shrink-0" strokeWidth={2} />
|
||||||
<span>
|
<span>
|
||||||
Будет безвозвратно удалено записей:{" "}
|
Будет удалено записей:{" "}
|
||||||
<span className="font-dns font-semibold">{changeset.prunes.length}</span>. Действие необратимо.
|
<span className="font-dns font-semibold">{selectedPrunes.size}</span>. Действие необратимо.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -252,12 +263,10 @@ export function DomainDiffPage() {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{hasUpdates || (applyPrunes && hasPrunes)
|
{hasSelection ? "Готово к применению" : "Изменений для применения нет"}
|
||||||
? "Готово к применению"
|
|
||||||
: "Изменений для применения нет"}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button onClick={onApply} disabled={apply.isPending}>
|
<Button onClick={onApply} disabled={apply.isPending || !hasSelection}>
|
||||||
{apply.isPending ? (
|
{apply.isPending ? (
|
||||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user