Merge feat/custom-records: custom-записи в диффе и очистка исчезнувших зон

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