Files
dns-autoresolver/internal/store/store_test.go
T

509 lines
17 KiB
Go

package store
import (
"context"
"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)
}
}