Files
dns-autoresolver/internal/store/store_test.go
T
vasyanskandClaude Opus 5 caebc06076 feat(web): filter domains by template and status
The domains list had no way to narrow itself down, so an operator with
dozens of zones had to scan the whole table to find the drifted ones.

Add two client-side filters above the table (the full list already
arrives in one request, so server-side filtering would only add contract
surface). The status filter compares against the same derived value the
row's badge renders — a domain with no template shows "без шаблона" and
must not match "drift" just because it kept a stale last_check_status
from before its template was detached, so that derivation lives in one
function used by both.

Sorting is now decided in the store: ListDomains orders by zone_name
instead of created_at, so every consumer of the list gets the same order
and the filters never reorder anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
2026-08-19 19:24:58 +07:00

548 lines
18 KiB
Go

package store
import (
"context"
"reflect"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/store/db"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
// defaultProject is the seed default tenant project (see migrations/0001_init.sql).
var defaultProject = uuid.MustParse("00000000-0000-0000-0000-000000000002")
func newStore(t *testing.T) (*Store, context.Context) {
dsn := startPostgres(t)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
return New(pool), context.Background()
}
func TestAccountCRUD(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", Comment: "prod",
})
if err != nil {
t.Fatal(err)
}
got, err := s.Queries().GetAccount(ctx, db.GetAccountParams{ID: acc.ID, ProjectID: defaultProject})
if err != nil || got.Provider != "selectel" || got.SecretEnc != "enc-blob" {
t.Fatalf("get mismatch: %+v err=%v", got, err)
}
list, err := s.Queries().ListAccounts(ctx, defaultProject)
if err != nil || len(list) != 1 {
t.Fatalf("list mismatch: %+v err=%v", list, err)
}
if err := s.Queries().DeleteAccount(ctx, db.DeleteAccountParams{ID: acc.ID, ProjectID: defaultProject}); err != nil {
t.Fatal(err)
}
if _, err := s.Queries().GetAccount(ctx, db.GetAccountParams{ID: acc.ID, ProjectID: defaultProject}); err == nil {
t.Fatal("expected error after delete, got nil")
}
}
func TestTemplateJSONBRoundTrip(t *testing.T) {
s, ctx := newStore(t)
doc := dto.TemplateDoc{Records: []dto.RecordDTO{
{Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"1.2.3.4"}},
{Type: "SRV", Name: "_autodiscover._tcp.example.com.", TTL: 3600, Values: []string{"0 0 443 mail.example.com."}},
}}
tpl, err := s.Queries().CreateTemplate(ctx, db.CreateTemplateParams{
ID: uuid.New(), ProjectID: defaultProject, Name: "base", Doc: &doc,
})
if err != nil {
t.Fatal(err)
}
got, err := s.Queries().GetTemplate(ctx, db.GetTemplateParams{ID: tpl.ID, ProjectID: defaultProject})
if err != nil {
t.Fatal(err)
}
if got.Doc == nil || len(got.Doc.Records) != 2 || got.Doc.Records[1].Type != "SRV" {
t.Fatalf("jsonb round-trip failed: %+v", got.Doc)
}
doc2 := dto.TemplateDoc{Records: []dto.RecordDTO{
{Type: "A", Name: "www.example.com.", TTL: 60, Values: []string{"5.6.7.8"}},
}}
updated, err := s.Queries().UpdateTemplate(ctx, db.UpdateTemplateParams{
ID: tpl.ID, ProjectID: defaultProject, Name: "base-v2", Doc: &doc2,
})
if err != nil {
t.Fatal(err)
}
if updated.Version != tpl.Version+1 || updated.Doc == nil || len(updated.Doc.Records) != 1 {
t.Fatalf("update mismatch: %+v", updated)
}
if err := s.Queries().DeleteTemplate(ctx, db.DeleteTemplateParams{ID: tpl.ID, ProjectID: defaultProject}); err != nil {
t.Fatal(err)
}
}
func TestImportDomains_CommitsAllOnSuccess(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)
}
zones := []provider.Zone{
{ID: "z1", Name: "a.example.com"},
{ID: "z2", Name: "b.example.com"},
}
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 {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("expected 2 persisted domains, got %d", len(list))
}
}
// TestImportDomains_RollsBackAllOnError verifies the transactional contract:
// if any zone in the batch fails to insert (here, an FK violation because
// the account doesn't exist), none of the batch is left committed.
func TestImportDomains_RollsBackAllOnError(t *testing.T) {
s, ctx := newStore(t)
bogusAccountID := uuid.New() // no matching provider_accounts row
zones := []provider.Zone{
{ID: "z1", Name: "a.example.com"},
{ID: "z2", Name: "b.example.com"},
}
if _, _, err := s.ImportDomains(ctx, defaultProject, bogusAccountID, zones); err == nil {
t.Fatal("expected FK violation error, got nil")
}
list, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
if len(list) != 0 {
t.Fatalf("expected 0 domains after rollback, got %d", len(list))
}
}
// TestImportDomains_IdempotentOnRepeat verifies the fix for the import
// idempotency gap: re-importing the same zones must not create duplicate
// domains (enforced by the domains_project_zone_uniq constraint + ON
// CONFLICT DO NOTHING in the ImportDomain query) and must not error.
func TestImportDomains_IdempotentOnRepeat(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)
}
zones := []provider.Zone{
{ID: "z1", Name: "a.example.com"},
{ID: "z2", Name: "b.example.com"},
}
first, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
if err != nil {
t.Fatal(err)
}
if len(first) != 2 {
t.Fatalf("expected 2 domains on first import, got %d", len(first))
}
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 {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("expected still exactly 2 domains (no duplicates), got %d", len(list))
}
var count int
row := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM domains WHERE project_id = $1 AND zone_id = $2`, defaultProject, "z1")
if err := row.Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected COUNT=1 for zone z1 (UNIQUE constraint), got %d", count)
}
}
// TestSetDomainTemplate_ClosesImportCheckLoop verifies the fix for the
// second review gap: an imported domain (template_id=NULL) can be bound to
// a template via SetDomainTemplate, after which LoadDomain succeeds and
// returns that template — closing the import -> bind -> check cycle.
func TestSetDomainTemplate_ClosesImportCheckLoop(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)
}
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
if err != nil {
t.Fatal(err)
}
dom := doms[0]
// Before binding, the domain is not checkable.
if _, err := s.LoadDomain(ctx, defaultProject, dom.ID); err == nil {
t.Fatal("expected LoadDomain to fail before a template is bound")
}
doc := dto.TemplateDoc{Records: []dto.RecordDTO{
{Type: "A", Name: "www.a.example.com.", TTL: 300, Values: []string{"1.2.3.4"}},
}}
tpl, err := s.CreateTemplate(ctx, defaultProject, "base", doc)
if err != nil {
t.Fatal(err)
}
updated, err := s.SetDomainTemplate(ctx, dom.ID, defaultProject, &tpl.ID)
if err != nil {
t.Fatal(err)
}
if updated.TemplateID == nil || *updated.TemplateID != tpl.ID {
t.Fatalf("expected domain.TemplateID=%s, got %+v", tpl.ID, updated.TemplateID)
}
ref, err := s.LoadDomain(ctx, defaultProject, dom.ID)
if err != nil {
t.Fatalf("expected LoadDomain to succeed after binding template, got error: %v", err)
}
if len(ref.Template.Records) != 1 || ref.Template.Records[0].Type != "A" {
t.Fatalf("unexpected template loaded: %+v", ref.Template)
}
}
// TestSetDomainTemplate_RejectsForeignProjectTemplate verifies that binding
// a template belonging to a different project is rejected rather than
// silently succeeding (which would let one tenant's domain use another
// tenant's DNS template).
func TestSetDomainTemplate_RejectsForeignProjectTemplate(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)
}
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
if err != nil {
t.Fatal(err)
}
dom := doms[0]
// A template that belongs to a different (foreign) project. The default
// user is the seed tenant from migrations/0001_init.sql.
defaultUser := uuid.MustParse("00000000-0000-0000-0000-000000000001")
foreignProject := uuid.New()
if _, err := s.pool.Exec(ctx, `INSERT INTO projects (id, user_id, name) VALUES ($1, $2, 'foreign')`, foreignProject, defaultUser); err != nil {
t.Fatal(err)
}
foreignTpl, err := s.CreateTemplate(ctx, foreignProject, "foreign", dto.TemplateDoc{})
if err != nil {
t.Fatal(err)
}
if _, err := s.SetDomainTemplate(ctx, dom.ID, defaultProject, &foreignTpl.ID); err == nil {
t.Fatal("expected error binding a template from a different project, got nil")
}
}
// seedDomain creates a provider account and imports a single domain for
// defaultProject, returning the resulting Domain — the shared setup used by
// the custom-records tests below.
func seedDomain(t *testing.T, s *Store, ctx context.Context) Domain {
t.Helper()
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
if err != nil {
t.Fatal(err)
}
return doms[0]
}
// TestCustomRecordsAddIsIdempotentAndScoped verifies that AddCustomRecord
// is idempotent (ON CONFLICT DO NOTHING — no PK-conflict error on repeat)
// and that it is scoped by projectID, closing the same IDOR-on-write gap
// SetDomainTemplate guards against.
func TestCustomRecordsAddIsIdempotentAndScoped(t *testing.T) {
s, ctx := newStore(t)
dom := seedDomain(t, s, ctx)
const key = "CNAME admin.example.com."
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
// Повторное добавление того же ключа не должно падать на PK-конфликте.
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatalf("second add must be a no-op, got %v", err)
}
keys, err := s.ListCustomKeys(ctx, dom.ID)
if err != nil {
t.Fatal(err)
}
if len(keys) != 1 || keys[0] != key {
t.Fatalf("expected exactly [%q], got %+v", key, keys)
}
// Чужой проект не может пометить этот домен.
if err := s.AddCustomRecord(ctx, dom.ID, uuid.New(), "A www.example.com."); err == nil {
t.Fatal("expected an error when marking a domain from another project")
}
}
// TestCustomRecordsDeleteAndCascade verifies DeleteCustomRecord removes the
// mark, and that deleting the domain cascades to remove any remaining marks
// (domain_custom_records.domain_id REFERENCES domains(id) ON DELETE CASCADE).
func TestCustomRecordsDeleteAndCascade(t *testing.T) {
s, ctx := newStore(t)
dom := seedDomain(t, s, ctx)
const key = "CNAME admin.example.com."
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
if err := s.DeleteCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
keys, err := s.ListCustomKeys(ctx, dom.ID)
if err != nil {
t.Fatal(err)
}
if len(keys) != 0 {
t.Fatalf("expected no keys after delete, got %+v", keys)
}
// Каскад: удаление домена уносит его пометки.
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
if err := s.DeleteDomain(ctx, dom.ID, dom.ProjectID); err != nil {
t.Fatal(err)
}
keys, err = s.ListCustomKeys(ctx, dom.ID)
if err != nil {
t.Fatal(err)
}
if len(keys) != 0 {
t.Fatalf("expected cascade to remove keys with the domain, got %+v", keys)
}
}
// seedAccount creates an additional provider account for an existing
// project — used to test that ImportDomains scopes its deletions to a
// single account, not the whole project.
func seedAccount(t *testing.T, s *Store, ctx context.Context, projectID uuid.UUID) Account {
t.Helper()
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: projectID, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
return accountFromDB(acc)
}
// TestImportDomainsRemovesVanishedZones verifies the sync behavior added to
// ImportDomains: a domain whose zone no longer appears in the provider's
// zone list is deleted on the next import, closing the orphan-domain gap
// (previously such a domain sat forever in status "error").
func TestImportDomainsRemovesVanishedZones(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
created, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
{ID: "z1", Name: "one.example.com."},
{ID: "z2", Name: "two.example.com."},
})
if err != nil {
t.Fatal(err)
}
if len(created) != 2 || len(removed) != 0 {
t.Fatalf("first import: expected 2 created / 0 removed, got %d/%d", len(created), len(removed))
}
// z2 disappeared at the provider — re-import must remove its domain.
created, removed, err = s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
{ID: "z1", Name: "one.example.com."},
})
if err != nil {
t.Fatal(err)
}
if len(created) != 0 {
t.Fatalf("expected nothing new to be created, got %+v", created)
}
if len(removed) != 1 || removed[0].ZoneID != "z2" {
t.Fatalf("expected the vanished zone's domain to be removed, got %+v", removed)
}
left, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
if len(left) != 1 || left[0].ZoneID != "z1" {
t.Fatalf("expected only z1 to survive, got %+v", left)
}
}
// TestImportDomainsEmptyZoneListRemovesNothing verifies the safety guard: an
// empty zone list from the provider is indistinguishable from a temporary
// loss of access, so it must never delete anything.
func TestImportDomainsEmptyZoneListRemovesNothing(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
if _, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
{ID: "z1", Name: "one.example.com."},
}); err != nil {
t.Fatal(err)
}
created, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, nil)
if err != nil {
t.Fatal(err)
}
if len(created) != 0 || len(removed) != 0 {
t.Fatalf("empty zone list must be a no-op, got %d created / %d removed", len(created), len(removed))
}
left, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
if len(left) != 1 {
t.Fatalf("expected the domain to survive an empty zone list, got %+v", left)
}
}
// TestImportDomainsOnlyTouchesItsOwnAccount verifies deletion is scoped to
// the account being imported: a project may hold several provider accounts,
// and one account's zone list says nothing about another account's domains.
func TestImportDomainsOnlyTouchesItsOwnAccount(t *testing.T) {
s, ctx := newStore(t)
accA, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
accB := seedAccount(t, s, ctx, defaultProject)
if _, _, err := s.ImportDomains(ctx, defaultProject, accB.ID, []provider.Zone{
{ID: "zb", Name: "b.example.com."},
}); err != nil {
t.Fatal(err)
}
if _, _, err := s.ImportDomains(ctx, defaultProject, accA.ID, []provider.Zone{
{ID: "za", Name: "a.example.com."},
}); err != nil {
t.Fatal(err)
}
// Re-importing accA with the same single zone must not touch accB's domains.
_, removed, err := s.ImportDomains(ctx, defaultProject, accA.ID, []provider.Zone{
{ID: "za", Name: "a.example.com."},
})
if err != nil {
t.Fatal(err)
}
if len(removed) != 0 {
t.Fatalf("import for one account must not remove another account's domains, got %+v", removed)
}
left, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
if len(left) != 2 {
t.Fatalf("expected both accounts' domains to survive, got %+v", left)
}
}
// TestListDomains_SortedByZoneName pins the list order: domains come back
// alphabetically by zone name, never in creation order. The domains page
// relies on this — its filters reorder nothing, so the store is the single
// place the order is decided.
func TestListDomains_SortedByZoneName(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)
}
// Inserted in deliberately non-alphabetical order (one import call: a
// per-zone call would prune the previously imported domains), so a query
// still sorting by created_at would return zulu first.
if _, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
{ID: "z1", Name: "zulu.example.com"},
{ID: "z2", Name: "mike.example.com"},
{ID: "z3", Name: "alpha.example.com"},
}); err != nil {
t.Fatal(err)
}
list, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
got := make([]string, 0, len(list))
for _, d := range list {
got = append(got, d.ZoneName)
}
want := []string{"alpha.example.com", "mike.example.com", "zulu.example.com"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected domains sorted by zone name %v, got %v", want, got)
}
}