feat(store): persist per-domain custom record marks

This commit is contained in:
2026-08-19 17:57:06 +07:00
parent e675c17123
commit 618d5c5bb6
6 changed files with 208 additions and 0 deletions
+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
}
+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"`
@@ -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;
+86
View File
@@ -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)
}
}
+28
View File
@@ -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.