From e675c1712376a2f8478d7ee96491cae04e55df6c Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 17:51:19 +0700 Subject: [PATCH 01/10] feat(diff): mark records deliberately kept outside the template --- internal/diff/diff.go | 46 +++++++++++++++++++++++++++-- internal/diff/diff_test.go | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/internal/diff/diff.go b/internal/diff/diff.go index e5dd15c..bce7321 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -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 +} diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go index baf90ac..7b77744 100644 --- a/internal/diff/diff_test.go +++ b/internal/diff/diff_test.go @@ -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) + } +} From 618d5c5bb66874a269250aa5d9623717b859ce9d Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 17:57:06 +0700 Subject: [PATCH 02/10] feat(store): persist per-domain custom record marks --- internal/store/db/customs.sql.go | 66 ++++++++++++++ internal/store/db/models.go | 7 ++ .../migrations/0005_domain_custom_records.sql | 11 +++ internal/store/queries/customs.sql | 10 +++ internal/store/store_test.go | 86 +++++++++++++++++++ internal/store/tenant.go | 28 ++++++ 6 files changed, 208 insertions(+) create mode 100644 internal/store/db/customs.sql.go create mode 100644 internal/store/migrations/0005_domain_custom_records.sql create mode 100644 internal/store/queries/customs.sql diff --git a/internal/store/db/customs.sql.go b/internal/store/db/customs.sql.go new file mode 100644 index 0000000..2554253 --- /dev/null +++ b/internal/store/db/customs.sql.go @@ -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 +} diff --git a/internal/store/db/models.go b/internal/store/db/models.go index 0c440e7..080e7c6 100644 --- a/internal/store/db/models.go +++ b/internal/store/db/models.go @@ -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"` diff --git a/internal/store/migrations/0005_domain_custom_records.sql b/internal/store/migrations/0005_domain_custom_records.sql new file mode 100644 index 0000000..c051042 --- /dev/null +++ b/internal/store/migrations/0005_domain_custom_records.sql @@ -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; diff --git a/internal/store/queries/customs.sql b/internal/store/queries/customs.sql new file mode 100644 index 0000000..e833892 --- /dev/null +++ b/internal/store/queries/customs.sql @@ -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; diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 9572617..0955278 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -277,3 +277,89 @@ 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) + } +} diff --git a/internal/store/tenant.go b/internal/store/tenant.go index e2084e1..612fd9f 100644 --- a/internal/store/tenant.go +++ b/internal/store/tenant.go @@ -245,6 +245,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. From 208e73980d741fff95f35a828a1275e8b794ae11 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 18:02:55 +0700 Subject: [PATCH 03/10] feat(service): apply custom marks to the computed diff --- internal/service/service.go | 6 +++ internal/service/service_test.go | 68 +++++++++++++++++++++++++++++--- internal/store/loader.go | 15 ++++--- 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/internal/service/service.go b/internal/service/service.go index 46646b1..78e975a 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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 } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 8fb8c8b..28e2983 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -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) + } +} diff --git a/internal/store/loader.go b/internal/store/loader.go index 4b6068e..0eeb7b9 100644 --- a/internal/store/loader.go +++ b/internal/store/loader.go @@ -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 } From da267b3cef5e867f31ba790bf0df5d4f416ac59d Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 18:10:46 +0700 Subject: [PATCH 04/10] feat(api): endpoints to mark and unmark custom records --- internal/api/api.go | 8 +++ internal/api/api_test.go | 2 +- internal/api/dto.go | 15 +++- internal/api/handlers.go | 52 ++++++++++++++ internal/api/tenant_test.go | 136 ++++++++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 2 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index c621e20..581f4f3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -55,6 +55,12 @@ type TenantStore interface { // "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 +138,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) }) }) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 94e1c99..2f59986 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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) } diff --git a/internal/api/dto.go b/internal/api/dto.go index 6d5fa9f..42015f1 100644 --- a/internal/api/dto.go +++ b/internal/api/dto.go @@ -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)) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index cc10af0..2dd9933 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -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). diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index 4f613b0..1db85a6 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -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" @@ -49,6 +52,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,6 +168,24 @@ func (m *mockTenantStore) SetDomainStatus(_ context.Context, domainID, projectID return m.setDomainStatusErr } +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(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) { m.importCalled = true if m.importDomainsErr != nil { @@ -620,6 +658,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) From 404f869d42cff5ac748512217398ffd051324b31 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 18:16:42 +0700 Subject: [PATCH 05/10] feat(web): api client and hooks for custom record marks --- web/src/api/client.ts | 10 +++++++++ web/src/api/types.ts | 2 ++ web/src/components/DiffView.test.tsx | 16 ++++++++------ web/src/hooks/useApi.ts | 30 +++++++++++++++++++++++++++ web/src/pages/DomainDiffPage.test.tsx | 14 +++++++------ 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f09822a..d731673 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -83,6 +83,16 @@ export const api = { req(projectPath(projectId, `/domains/${id}/check`)), applyDomain: (projectId: string, id: string, body: ApplyRequest) => req(projectPath(projectId, `/domains/${id}/apply`), { method: "POST", body: JSON.stringify(body) }), + addCustom: (projectId: string, id: string, key: string) => + req(projectPath(projectId, `/domains/${id}/customs`), { + method: "POST", + body: JSON.stringify({ key }), + }), + removeCustom: (projectId: string, id: string, key: string) => + req( + projectPath(projectId, `/domains/${id}/customs?key=${encodeURIComponent(key)}`), + { method: "DELETE" }, + ), domainHistory: (projectId: string, id: string) => req(projectPath(projectId, `/domains/${id}/history`)), diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 97c0ece..6cd1f1e 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -45,10 +45,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 } diff --git a/web/src/components/DiffView.test.tsx b/web/src/components/DiffView.test.tsx index 88b4a99..b60a67b 100644 --- a/web/src/components/DiffView.test.tsx +++ b/web/src/components/DiffView.test.tsx @@ -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, } diff --git a/web/src/hooks/useApi.ts b/web/src/hooks/useApi.ts index 3250ec1..e150ed7 100644 --- a/web/src/hooks/useApi.ts +++ b/web/src/hooks/useApi.ts @@ -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({ diff --git a/web/src/pages/DomainDiffPage.test.tsx b/web/src/pages/DomainDiffPage.test.tsx index 708cd3d..25d8516 100644 --- a/web/src/pages/DomainDiffPage.test.tsx +++ b/web/src/pages/DomainDiffPage.test.tsx @@ -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() From e00648baf7eb539307c617a4f57eeb15a8f49723 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 18:22:32 +0700 Subject: [PATCH 06/10] feat(web): customs section with mark and unmark actions --- web/src/components/DiffView.tsx | 60 ++++++++++++++++++++++++--- web/src/index.css | 3 ++ web/src/pages/DomainDiffPage.test.tsx | 60 ++++++++++++++++++++++++++- web/src/pages/DomainDiffPage.tsx | 12 ++++++ 4 files changed, 129 insertions(+), 6 deletions(-) diff --git a/web/src/components/DiffView.tsx b/web/src/components/DiffView.tsx index 463fa51..7671b8e 100644 --- a/web/src/components/DiffView.tsx +++ b/web/src/components/DiffView.tsx @@ -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 )} + + {action} {/* 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 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)} /> ))} @@ -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) => ( + + )) + } + /> +
( + + )) + } />
diff --git a/web/src/index.css b/web/src/index.css index 9f0905e..bcd7df4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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 { diff --git a/web/src/pages/DomainDiffPage.test.tsx b/web/src/pages/DomainDiffPage.test.tsx index 25d8516..930a61d 100644 --- a/web/src/pages/DomainDiffPage.test.tsx +++ b/web/src/pages/DomainDiffPage.test.tsx @@ -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" @@ -173,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() +}) diff --git a/web/src/pages/DomainDiffPage.tsx b/web/src/pages/DomainDiffPage.tsx index eac410f..c12fd8d 100644 --- a/web/src/pages/DomainDiffPage.tsx +++ b/web/src/pages/DomainDiffPage.tsx @@ -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)} />
@@ -275,6 +281,12 @@ export function DomainDiffPage() { Apply
+ + {(addCustom.isError || removeCustom.isError) && ( + + {(addCustom.error ?? removeCustom.error)?.message} + + )} )} From 4d05271f101b517bf989486fcc82dbff09b40a2b Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 18:32:10 +0700 Subject: [PATCH 07/10] feat: remove domains whose zones vanished at the provider on import --- internal/api/api.go | 5 +- internal/api/tenant_dto.go | 8 ++ internal/api/tenant_handlers.go | 23 +++-- internal/api/tenant_test.go | 59 +++++++++-- internal/store/db/domains.sql.go | 41 ++++++++ internal/store/queries/domains.sql | 5 + internal/store/store_test.go | 157 +++++++++++++++++++++++++++-- internal/store/tenant.go | 49 ++++++--- 8 files changed, 310 insertions(+), 37 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 581f4f3..4f12703 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -48,7 +48,10 @@ 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 diff --git a/internal/api/tenant_dto.go b/internal/api/tenant_dto.go index 1ec0b6c..4176d19 100644 --- a/internal/api/tenant_dto.go +++ b/internal/api/tenant_dto.go @@ -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 == "" { diff --git a/internal/api/tenant_handlers.go b/internal/api/tenant_handlers.go index 4a93a68..bc47e00 100644 --- a/internal/api/tenant_handlers.go +++ b/internal/api/tenant_handlers.go @@ -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 --- diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index 1db85a6..baedfc6 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -38,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 @@ -186,10 +191,16 @@ func (m *mockTenantStore) DeleteCustomRecord(_ context.Context, domainID, projec return m.deleteCustomRecordErr } -func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) { +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 { @@ -198,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{} @@ -431,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)) } } @@ -471,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) diff --git a/internal/store/db/domains.sql.go b/internal/store/db/domains.sql.go index 25e2a07..a9afa71 100644 --- a/internal/store/db/domains.sql.go +++ b/internal/store/db/domains.sql.go @@ -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 ` diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql index ba0829d..8dc7f5c 100644 --- a/internal/store/queries/domains.sql +++ b/internal/store/queries/domains.sql @@ -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 *; diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 0955278..89fd964 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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) } @@ -289,7 +295,7 @@ func seedDomain(t *testing.T, s *Store, ctx context.Context) Domain { 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) } @@ -363,3 +369,140 @@ func TestCustomRecordsDeleteAndCascade(t *testing.T) { 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) + } +} diff --git a/internal/store/tenant.go b/internal/store/tenant.go index 612fd9f..3030d0f 100644 --- a/internal/store/tenant.go +++ b/internal/store/tenant.go @@ -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 From d6d91f88262b543a26b66809ea8398d75e6f98de Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 18:37:56 +0700 Subject: [PATCH 08/10] feat(web): show created and removed counts after zone import --- web/src/api/client.ts | 4 ++-- web/src/api/types.ts | 1 + web/src/pages/DomainsPage.test.tsx | 19 ++++++++++++++++++- web/src/pages/DomainsPage.tsx | 5 +++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d731673..3e3b703 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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" @@ -70,7 +70,7 @@ export const api = { deleteDomain: (projectId: string, id: string) => req(projectPath(projectId, `/domains/${id}`), { method: "DELETE" }), importZones: (projectId: string, accountId: string) => - req(projectPath(projectId, `/accounts/${accountId}/import`), { method: "POST" }), + req(projectPath(projectId, `/accounts/${accountId}/import`), { method: "POST" }), setDomainTemplate: (projectId: string, id: string, templateId: string | null) => req(projectPath(projectId, `/domains/${id}`), { method: "PATCH", body: JSON.stringify({ templateId }) }), diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 6cd1f1e..ef38843 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 } diff --git a/web/src/pages/DomainsPage.test.tsx b/web/src/pages/DomainsPage.test.tsx index 68bcb74..80108ac 100644 --- a/web/src/pages/DomainsPage.test.tsx +++ b/web/src/pages/DomainsPage.test.tsx @@ -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,23 @@ 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 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() diff --git a/web/src/pages/DomainsPage.tsx b/web/src/pages/DomainsPage.tsx index 709074d..c15462b 100644 --- a/web/src/pages/DomainsPage.tsx +++ b/web/src/pages/DomainsPage.tsx @@ -102,6 +102,11 @@ export function DomainsPage() { {importZones.isError && ( {importZones.error.message} )} + {importZones.isSuccess && ( + + Создано {importZones.data.created.length}, удалено {importZones.data.removed.length} + + )} {setTemplate.isError && ( From 58a57ff4d0fc99348c81f67e1db4bdbaa654271f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 18:45:52 +0700 Subject: [PATCH 09/10] docs: custom records and import-as-sync invariants Document the two Task 1-9 features in CLAUDE.md invariants and README features. Also closes two review gaps: a Go test proving importResponse marshals created/removed as [] (never null) on a no-op import, and a frontend test-race fix in DomainsPage.test.tsx where the import button was clicked before its disabled state cleared. --- CLAUDE.md | 2 ++ README.md | 7 +++++++ internal/api/tenant_test.go | 29 +++++++++++++++++++++++++++++ web/src/pages/DomainsPage.test.tsx | 2 ++ 4 files changed, 40 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 56fa593..01877e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 4c8d9c0..9d80a40 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ **перед** обновлениями — иначе провайдер отвергает конфликт (например `CNAME` на имени, где ещё жива `A`-запись). При ошибке показывается реальный ответ провайдера, а не generic-текст. +- **Custom-записи**: запись можно пометить как намеренно оставленную вне + шаблона — она уходит из Prunes, не считается drift и не может быть + применена, пока пометка не снята; как только шаблон начинает описывать + этот ключ сам, пометка автоматически перестаёт действовать. +- **Импорт зон — синхронизация**: повторный импорт аккаунта провайдера не + только заводит домены новых зон, но и удаляет домены зон, исчезнувших у + провайдера; пустой ответ провайдера ничего не удаляет. - **Расписание проверок**: планировщик периодически гоняет read-only check+notify (без Apply), пишет историю проверок и статус drift. - **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки. diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index baedfc6..39b0dd7 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -531,6 +531,35 @@ func TestImportZones_BadAccountUUID(t *testing.T) { } } +// TestImportResponseEmptyMarshalsToArrays guards the same белый-экран class +// of bug as TestChangesetResponseEmptyMarshalsToArrays (api_test.go), but for +// the import endpoint: a sync that created and removed nothing (empty zone +// list from the provider) must still marshal created/removed as [], not +// null — the frontend's .length/.map calls on the response would otherwise +// crash right after a no-op import. +func TestImportResponseEmptyMarshalsToArrays(t *testing.T) { + a, ts := newTenantTestAPI() + accID := uuid.New() + ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}} + a.Reg = &mockRegistry{zones: nil} + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/"+accID.String()+"/import", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, `"created":[]`) { + t.Fatalf("expected \"created\":[] in %s", body) + } + if !strings.Contains(body, `"removed":[]`) { + t.Fatalf("expected \"removed\":[] in %s", body) + } +} + func TestCreateDomain_BadProjectUUID(t *testing.T) { a, _ := newTenantTestAPI() router := NewRouter(a) diff --git a/web/src/pages/DomainsPage.test.tsx b/web/src/pages/DomainsPage.test.tsx index 80108ac..96c6947 100644 --- a/web/src/pages/DomainsPage.test.tsx +++ b/web/src/pages/DomainsPage.test.tsx @@ -88,6 +88,8 @@ test("после импорта показывает, сколько зон со 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() From 98be357da8e8c63d0fa1999ee19cab0c1c41b327 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 19:04:13 +0700 Subject: [PATCH 10/10] fix(web): handle empty-body 2xx responses; fix(store): sync DeleteDomainsNotInZones query with generated columns req() in the frontend api client called res.json() on every non-204 2xx response, which throws SyntaxError on the empty body returned by POST .../customs (201) and similar endpoints. Read the response body once as text and parse it only when non-empty, so any bodyless 2xx resolves instead of rejecting. Added web/src/api/client.test.ts coverage that exercises the real req() against a stubbed fetch for both the 201 (addCustom) and 204 (removeCustom) empty-body cases. Also brought internal/store/queries/domains.sql's DeleteDomainsNotInZones query back in sync with the hand-maintained internal/store/db/domains.sql.go (RETURNING * -> explicit column list) since sqlc isn't installed in this environment and the two files are kept aligned by hand. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU --- internal/store/queries/domains.sql | 2 +- web/src/api/client.test.ts | 26 ++++++++++++++++++++++++++ web/src/api/client.ts | 12 +++++++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql index 8dc7f5c..f406bff 100644 --- a/internal/store/queries/domains.sql +++ b/internal/store/queries/domains.sql @@ -41,4 +41,4 @@ 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 *; +RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status; diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 2eedff8..0f19401 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -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() + }) + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3e3b703..6e442c4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -20,14 +20,20 @@ async function req(path: string, init?: RequestInit): Promise { 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 {