package database import ( "context" "errors" "path/filepath" "testing" "time" "github.com/owen/vibedns/internal/models" ) // newTestDB opens a migrated database in a temporary directory. func newTestDB(t *testing.T) *DB { t.Helper() path := filepath.Join(t.TempDir(), "test.db") db, err := Open(path) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { db.Close() }) if _, err := db.Migrate(context.Background()); err != nil { t.Fatalf("migrate: %v", err) } return db } func TestMigrationsApplyAndAreIdempotent(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "migrate.db") db, err := Open(path) if err != nil { t.Fatalf("open: %v", err) } defer db.Close() n, err := db.Migrate(ctx) if err != nil { t.Fatalf("first migrate: %v", err) } if n == 0 { t.Fatal("expected migrations to be applied on a fresh database") } // A second run must be a no-op, which is what makes it safe to run on // every start. again, err := db.Migrate(ctx) if err != nil { t.Fatalf("second migrate: %v", err) } if again != 0 { t.Errorf("second migrate applied %d migrations, want 0", again) } v, err := db.SchemaVersion(ctx) if err != nil { t.Fatalf("schema version: %v", err) } if v < 1 { t.Errorf("schema version = %d, want at least 1", v) } statuses, err := db.MigrationStatuses(ctx) if err != nil { t.Fatalf("statuses: %v", err) } for _, s := range statuses { if !s.Applied { t.Errorf("migration %d_%s was not applied", s.Version, s.Name) } if s.Drifted { t.Errorf("migration %d_%s reports drift on a fresh database", s.Version, s.Name) } } } func TestSeedDataExists(t *testing.T) { db := newTestDB(t) ctx := context.Background() lists, err := db.DomainLists(ctx, models.KindBlacklist, "") if err != nil { t.Fatalf("list blacklists: %v", err) } if len(lists) == 0 { t.Error("expected the seed migration to create starter blacklists") } nets, err := db.Networks(ctx, "", true) if err != nil { t.Fatalf("list networks: %v", err) } if len(nets) == 0 { t.Error("expected the seed migration to create private-range networks") } // The seeded networks must be private ranges, never the whole Internet. for _, n := range nets { if n.CIDR == "0.0.0.0/0" || n.CIDR == "::/0" { t.Errorf("seed data contains an internet-wide network %q", n.CIDR) } } } func TestZoneCRUD(t *testing.T) { db := newTestDB(t) ctx := context.Background() zone := models.Zone{ Name: "example.com.", Kind: models.ZoneForward, Enabled: true, DefaultTTL: 3600, PrimaryNS: "ns1.example.com.", AdminEmail: "hostmaster@example.com", Serial: 1, Refresh: 7200, Retry: 3600, Expire: 1209600, Minimum: 3600, AutoSerial: true, } created, err := db.CreateZone(ctx, zone) if err != nil { t.Fatalf("create: %v", err) } if created.ID == 0 { t.Fatal("created zone has no ID") } // Duplicate names must be rejected. if _, err := db.CreateZone(ctx, zone); !errors.Is(err, ErrConflict) { t.Errorf("duplicate create error = %v, want ErrConflict", err) } loaded, err := db.Zone(ctx, created.ID) if err != nil { t.Fatalf("load: %v", err) } if loaded.Name != "example.com." { t.Errorf("name = %q, want example.com.", loaded.Name) } if _, err := db.Zone(ctx, 99999); !errors.Is(err, ErrNotFound) { t.Errorf("missing zone error = %v, want ErrNotFound", err) } loaded.Description = "updated" if _, err := db.UpdateZone(ctx, loaded); err != nil { t.Fatalf("update: %v", err) } if err := db.DeleteZone(ctx, created.ID); err != nil { t.Fatalf("delete: %v", err) } if err := db.DeleteZone(ctx, created.ID); !errors.Is(err, ErrNotFound) { t.Errorf("second delete error = %v, want ErrNotFound", err) } } func TestRecordsCascadeAndSerialBump(t *testing.T) { db := newTestDB(t) ctx := context.Background() zone, err := db.CreateZone(ctx, models.Zone{ Name: "example.com.", Kind: models.ZoneForward, Enabled: true, DefaultTTL: 3600, PrimaryNS: "ns1.example.com.", AdminEmail: "a@example.com", Serial: 1, AutoSerial: true, }) if err != nil { t.Fatalf("create zone: %v", err) } if _, err := db.CreateRecord(ctx, models.Record{ ZoneID: zone.ID, Name: "www", Type: "A", Data: "192.0.2.1", Enabled: true, }); err != nil { t.Fatalf("create record: %v", err) } // Adding a record must advance the serial, which is how secondaries learn // the zone changed. after, err := db.Zone(ctx, zone.ID) if err != nil { t.Fatalf("reload zone: %v", err) } if after.Serial <= zone.Serial { t.Errorf("serial = %d, want greater than %d after a record change", after.Serial, zone.Serial) } // Deleting the zone must take its records with it. if err := db.DeleteZone(ctx, zone.ID); err != nil { t.Fatalf("delete zone: %v", err) } recs, total, err := db.Records(ctx, RecordFilter{ZoneID: zone.ID}) if err != nil { t.Fatalf("list records: %v", err) } if total != 0 || len(recs) != 0 { t.Errorf("records remained after the zone was deleted: %d", total) } } func TestManualSerialIsPreserved(t *testing.T) { db := newTestDB(t) ctx := context.Background() zone, err := db.CreateZone(ctx, models.Zone{ Name: "manual.example.", Kind: models.ZoneForward, Enabled: true, DefaultTTL: 300, PrimaryNS: "ns1.manual.example.", AdminEmail: "a@manual.example", Serial: 2024010101, AutoSerial: false, }) if err != nil { t.Fatalf("create: %v", err) } if _, err := db.CreateRecord(ctx, models.Record{ ZoneID: zone.ID, Name: "@", Type: "A", Data: "192.0.2.1", Enabled: true, }); err != nil { t.Fatalf("create record: %v", err) } after, _ := db.Zone(ctx, zone.ID) if after.Serial != 2024010101 { t.Errorf("serial = %d, want the manual value to be left alone", after.Serial) } } func TestImportDomainsCountsDuplicates(t *testing.T) { db := newTestDB(t) ctx := context.Background() list, err := db.CreateDomainList(ctx, models.DomainList{ Kind: models.KindBlacklist, Name: "Import Test", Enabled: true, }) if err != nil { t.Fatalf("create list: %v", err) } rows := []ImportDomain{ {Domain: "a.example", MatchSubdomains: true}, {Domain: "b.example", MatchSubdomains: true}, {Domain: "c.example", MatchSubdomains: true}, } imported, dupes, err := db.ImportDomains(ctx, list.ID, rows) if err != nil { t.Fatalf("import: %v", err) } if imported != 3 || dupes != 0 { t.Errorf("first import = %d imported, %d duplicates; want 3, 0", imported, dupes) } // Re-importing the same rows plus one new one. rows = append(rows, ImportDomain{Domain: "d.example", MatchSubdomains: true}) imported, dupes, err = db.ImportDomains(ctx, list.ID, rows) if err != nil { t.Fatalf("second import: %v", err) } if imported != 1 || dupes != 3 { t.Errorf("second import = %d imported, %d duplicates; want 1, 3", imported, dupes) } loaded, err := db.DomainList(ctx, list.ID) if err != nil { t.Fatalf("reload list: %v", err) } if loaded.DomainCount != 4 { t.Errorf("domain count = %d, want 4", loaded.DomainCount) } } // TestImportLargeBatchIsOneTransaction exercises the path a real blocklist // takes. If this were one transaction per domain it would take minutes. func TestImportLargeBatch(t *testing.T) { db := newTestDB(t) ctx := context.Background() list, err := db.CreateDomainList(ctx, models.DomainList{ Kind: models.KindBlacklist, Name: "Large", Enabled: true, }) if err != nil { t.Fatalf("create list: %v", err) } const n = 20000 rows := make([]ImportDomain, 0, n) for i := 0; i < n; i++ { rows = append(rows, ImportDomain{ Domain: "host" + itoa(i) + ".example.com", MatchSubdomains: true, }) } imported, _, err := db.ImportDomains(ctx, list.ID, rows) if err != nil { t.Fatalf("bulk import: %v", err) } if imported != n { t.Errorf("imported = %d, want %d", imported, n) } // The snapshot query must return them all for the in-memory matcher. count := 0 if err := db.SnapshotDomains(ctx, func(SnapshotDomainEntry) { count++ }); err != nil { t.Fatalf("snapshot: %v", err) } if count != n { t.Errorf("snapshot returned %d domains, want %d", count, n) } } func TestSettingsRoundTrip(t *testing.T) { db := newTestDB(t) ctx := context.Background() if err := db.SetSetting(ctx, "test.key", "value"); err != nil { t.Fatalf("set: %v", err) } v, ok, err := db.Setting(ctx, "test.key") if err != nil || !ok || v != "value" { t.Errorf("get = %q, %v, %v; want \"value\", true, nil", v, ok, err) } // Writing again must update rather than fail on the primary key. if err := db.SetSetting(ctx, "test.key", "changed"); err != nil { t.Fatalf("overwrite: %v", err) } v, _, _ = db.Setting(ctx, "test.key") if v != "changed" { t.Errorf("after overwrite = %q, want \"changed\"", v) } if _, ok, _ := db.Setting(ctx, "missing.key"); ok { t.Error("a missing key should report ok=false") } } func TestQueryLogPruning(t *testing.T) { db := newTestDB(t) ctx := context.Background() var entries []models.QueryLogEntry for i := 0; i < 100; i++ { entries = append(entries, models.QueryLogEntry{ Timestamp: time.Now(), ClientIP: "192.0.2.1", QName: "example.com.", QType: "A", Rcode: "NOERROR", Source: models.SourceCache, Protocol: "udp", }) } if err := db.InsertQueryLogs(ctx, entries); err != nil { t.Fatalf("insert: %v", err) } n, err := db.QueryLogCount(ctx) if err != nil || n != 100 { t.Fatalf("count = %d, %v; want 100", n, err) } // Trim to the newest 40 rows. removed, err := db.PruneQueryLogs(ctx, 0, 40) if err != nil { t.Fatalf("prune: %v", err) } if removed != 60 { t.Errorf("pruned %d rows, want 60", removed) } n, _ = db.QueryLogCount(ctx) if n != 40 { t.Errorf("rows remaining = %d, want 40", n) } } func TestAuditLog(t *testing.T) { db := newTestDB(t) ctx := context.Background() err := db.InsertAudit(ctx, models.AuditEntry{ Actor: "admin", Source: "web", ClientIP: "192.0.2.1", Action: "zone.create", ObjectType: "zone", ObjectName: "example.com.", }) if err != nil { t.Fatalf("insert: %v", err) } entries, total, err := db.AuditLogs(ctx, AuditFilter{Limit: 10}) if err != nil { t.Fatalf("read: %v", err) } if total != 1 || len(entries) != 1 { t.Fatalf("got %d of %d, want 1 of 1", len(entries), total) } if entries[0].Action != "zone.create" { t.Errorf("action = %q", entries[0].Action) } } func TestAPITokenLookup(t *testing.T) { db := newTestDB(t) ctx := context.Background() tok, err := db.CreateAPIToken(ctx, "test", "a description", "abcd1234", "hash-value") if err != nil { t.Fatalf("create: %v", err) } if tok.ID == 0 { t.Fatal("no ID assigned") } candidates, err := db.APITokensByPrefix(ctx, "abcd1234") if err != nil { t.Fatalf("lookup: %v", err) } if len(candidates) != 1 || candidates[0].Hash != "hash-value" { t.Errorf("lookup returned %v", candidates) } // A disabled token must not be returned as a candidate at all. if err := db.SetAPITokenEnabled(ctx, tok.ID, false); err != nil { t.Fatalf("disable: %v", err) } candidates, _ = db.APITokensByPrefix(ctx, "abcd1234") if len(candidates) != 0 { t.Error("a disabled token was returned by the prefix lookup") } } func TestForeignKeysAreEnforced(t *testing.T) { db := newTestDB(t) ctx := context.Background() // A record pointing at a zone that does not exist must be rejected; // without PRAGMA foreign_keys this would silently succeed. _, err := db.CreateRecord(ctx, models.Record{ ZoneID: 99999, Name: "www", Type: "A", Data: "192.0.2.1", Enabled: true, }) if err == nil { t.Error("expected a foreign key violation for an orphaned record") } } func itoa(i int) string { if i == 0 { return "0" } var buf [12]byte pos := len(buf) for i > 0 { pos-- buf[pos] = byte('0' + i%10) i /= 10 } return string(buf[pos:]) }