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