diff --git a/README.md b/README.md index bd7d9980..eb75efbb 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ cloudDNS is a high-performance, authoritative, and recursive DNS server built fr * **EDNS(0) & Truncation (RFC 6891)**: Extended payload support with automatic TCP fallback. * **TSIG (RFC 2845)**: HMAC-authenticated transactions for secure updates and transfers. * **CHAOS Class Support**: Node identity resolution (`id.server.`, `hostname.bind.`) for NSID-ready deployments. +* **Catalog Zones (RFC 9432)**: Zone metadata catalog supporting zone provisioning, membership management, and slave-side polling with automatic AXFR-based synchronization. ### Architecture & Management * **Hexagonal Architecture**: Clean separation of concerns (Domain -> Ports -> Adapters). diff --git a/cmd/apikey/main_test.go b/cmd/apikey/main_test.go index 80625ec5..5b9717c5 100644 --- a/cmd/apikey/main_test.go +++ b/cmd/apikey/main_test.go @@ -97,6 +97,50 @@ func TestRevokeKey_Error(t *testing.T) { mockRepo.AssertExpectations(t) } +func TestRun_UnknownSubcommand(t *testing.T) { + mockRepo := new(testutil.MockRepo) + err := run([]string{"apikey", "unknown"}, io.Discard, mockRepo) + if err == nil { + t.Error("expected error for unknown subcommand") + } + if !strings.Contains(err.Error(), "unknown subcommand") { + t.Errorf("expected 'unknown subcommand' error, got: %v", err) + } +} + +func TestRun_InvalidRole(t *testing.T) { + mockRepo := new(testutil.MockRepo) + err := run([]string{"apikey", "create", "-role", "invalid"}, io.Discard, mockRepo) + if err == nil { + t.Error("expected error for invalid role") + } + if !strings.Contains(err.Error(), "invalid role") { + t.Errorf("expected 'invalid role' error, got: %v", err) + } +} + +func TestRun_InvalidDays(t *testing.T) { + mockRepo := new(testutil.MockRepo) + err := run([]string{"apikey", "create", "-days", "0"}, io.Discard, mockRepo) + if err == nil { + t.Error("expected error for invalid days") + } + if !strings.Contains(err.Error(), "invalid days") { + t.Errorf("expected 'invalid days' error, got: %v", err) + } +} + +func TestRun_TooFewArgs(t *testing.T) { + mockRepo := new(testutil.MockRepo) + err := run([]string{"apikey"}, io.Discard, mockRepo) + if err == nil { + t.Error("expected error for too few args") + } + if !strings.Contains(err.Error(), "expected") { + t.Errorf("expected 'expected' error, got: %v", err) + } +} + func TestRunCommand(t *testing.T) { mockRepo := new(testutil.MockRepo) out := &bytes.Buffer{} diff --git a/cmd/bench/main.go b/cmd/bench/main.go index 1ab2171a..22d50748 100644 --- a/cmd/bench/main.go +++ b/cmd/bench/main.go @@ -30,6 +30,12 @@ import ( "github.com/poyrazK/cloudDNS/internal/dns/server" ) +// sleepFn and readFileFn are injectable for testing. +var ( + sleepFn = time.Sleep + readFileFn = os.ReadFile +) + type Stats struct { TotalQueries uint64 Success uint64 @@ -295,7 +301,7 @@ func runScaleTest(count int, concurrency int) { } defer func() { _ = db.Close() }() - schema, errRead := os.ReadFile("internal/adapters/repository/schema.sql") + schema, errRead := readFileFn("internal/adapters/repository/schema.sql") if errRead != nil { fmt.Printf("Failed to read schema file: %v\n", errRead) return @@ -348,7 +354,7 @@ func runScaleTest(count int, concurrency int) { } }() - time.Sleep(1 * time.Second) + sleepFn(1 * time.Second) // 4. Benchmark fmt.Printf("\nExecuting Internet-Scale Benchmark\n") diff --git a/cmd/bench/main_test.go b/cmd/bench/main_test.go index 97280756..80881f91 100644 --- a/cmd/bench/main_test.go +++ b/cmd/bench/main_test.go @@ -6,6 +6,8 @@ import ( "errors" "flag" "net" + "os" + "os/exec" "testing" "time" @@ -301,6 +303,14 @@ func TestRunSeed_ConnectionError(t *testing.T) { runSeed(1) } +func TestRunSeed_DefaultURL(t *testing.T) { + // Test when DATABASE_URL is not set (uses default) + os.Unsetenv("DATABASE_URL") + // This will try to connect to localhost with default credentials + // We expect it to fail with connection error since there's no local DB + runSeed(1) +} + func TestRunScaleTest_MoreErrors(t *testing.T) { t.Run("BadDSN", func(t *testing.T) { t.Setenv("DATABASE_URL", "host=/invalid/path/socket") @@ -489,3 +499,55 @@ func (m *mockCommand) SetStdout(buf *bytes.Buffer) { func (m *mockCommand) Run() error { return nil } + +func TestGoCommand_SetStdout(t *testing.T) { + var buf bytes.Buffer + g := &goCommand{cmd: exec.Command("echo", "test")} + g.SetStdout(&buf) + if g.stdou == nil { + t.Error("expected stdou to be set") + } + if g.cmd.Stdout == nil { + t.Error("expected cmd.Stdout to be set") + } +} + +func TestGoCommand_Run(t *testing.T) { + g := &goCommand{cmd: exec.Command("true")} + err := g.Run() + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestGoCommand_RunError(t *testing.T) { + g := &goCommand{cmd: exec.Command("false")} + err := g.Run() + if err == nil { + t.Error("expected error from false command") + } +} + +func TestRunScaleTest_MockedSleepAndFile(t *testing.T) { + // Save original functions + origSleep := sleepFn + origRead := readFileFn + defer func() { + sleepFn = origSleep + readFileFn = origRead + }() + + // Mock sleep to be instant + sleepFn = func(d time.Duration) {} + + // Mock file read to return empty schema + readFileFn = func(name string) ([]byte, error) { + return []byte{}, nil + } + + // Set invalid DB to trigger early return after schema load + t.Setenv("DATABASE_URL", "host=/invalid/path") + + // This should now run without blocking on sleep + runScaleTest(1, 1) +} diff --git a/cmd/clouddns/main_test.go b/cmd/clouddns/main_test.go index 90d7166a..9998ae0c 100644 --- a/cmd/clouddns/main_test.go +++ b/cmd/clouddns/main_test.go @@ -467,3 +467,78 @@ func extractSSLMode(s string) string { } return "" } + +func TestParseDNSSECConfig(t *testing.T) { + t.Run("NoAnchorsNoMode", func(t *testing.T) { + t.Setenv("DNSSEC_MODE", "") + // Clear any TRUST_ANCHOR_ vars + for _, e := range os.Environ() { + if strings.HasPrefix(e, "TRUST_ANCHOR_") { + os.Unsetenv(strings.SplitN(e, "=", 2)[0]) + } + } + cfg := parseDNSSECConfig() + if cfg != nil { + t.Errorf("expected nil when no anchors and no mode, got %+v", cfg) + } + }) + + t.Run("ModeOnly", func(t *testing.T) { + t.Setenv("DNSSEC_MODE", "strict") + for _, e := range os.Environ() { + if strings.HasPrefix(e, "TRUST_ANCHOR_") { + os.Unsetenv(strings.SplitN(e, "=", 2)[0]) + } + } + cfg := parseDNSSECConfig() + if cfg == nil { + t.Fatal("expected non-nil config") + } + if cfg.Mode != "strict" { + t.Errorf("expected mode 'strict', got %q", cfg.Mode) + } + if len(cfg.TrustAnchors) != 0 { + t.Errorf("expected no anchors, got %d", len(cfg.TrustAnchors)) + } + }) + + t.Run("AnchorsOnly", func(t *testing.T) { + for _, e := range os.Environ() { + if strings.HasPrefix(e, "TRUST_ANCHOR_") { + os.Unsetenv(strings.SplitN(e, "=", 2)[0]) + } + } + t.Setenv("TRUST_ANCHOR_EXAMPLE.COM", "AQANAA==") + t.Setenv("DNSSEC_MODE", "") + cfg := parseDNSSECConfig() + if cfg == nil { + t.Fatal("expected non-nil config") + } + if len(cfg.TrustAnchors) != 1 { + t.Errorf("expected 1 anchor, got %d", len(cfg.TrustAnchors)) + } + if anchor, ok := cfg.TrustAnchors["example.com"]; !ok || anchor != "AQANAA==" { + t.Errorf("expected anchor for example.com, got %q", anchor) + } + }) + + t.Run("BothAnchorsAndMode", func(t *testing.T) { + for _, e := range os.Environ() { + if strings.HasPrefix(e, "TRUST_ANCHOR_") { + os.Unsetenv(strings.SplitN(e, "=", 2)[0]) + } + } + t.Setenv("DNSSEC_MODE", "ad-bit-only") + t.Setenv("TRUST_ANCHOR_TEST.COM", "AQANAA==") + cfg := parseDNSSECConfig() + if cfg == nil { + t.Fatal("expected non-nil config") + } + if cfg.Mode != "ad-bit-only" { + t.Errorf("expected mode 'ad-bit-only', got %q", cfg.Mode) + } + if _, ok := cfg.TrustAnchors["test.com"]; !ok { + t.Error("expected anchor for test.com") + } + }) +} diff --git a/docs/decisions/0012-catalog-zones-rfc-9432.md b/docs/decisions/0012-catalog-zones-rfc-9432.md new file mode 100644 index 00000000..5c9033f9 --- /dev/null +++ b/docs/decisions/0012-catalog-zones-rfc-9432.md @@ -0,0 +1,120 @@ +# ADR 0012: RFC 9432 Catalog Zones + +## Status +Accepted + +## Date +2026-06-10 + +## Context + +RFC 9432 defines Catalog Zones (CATZ type 53) and Catalog Zone Transfer (CZTR type 54) for publishing zone metadata in DNS. This enables slave DNS servers to automatically discover, provision, and synchronize zones from a master server without manual configuration. + +cloudDNS needed to support the slave-side catalog zone workflow: +1. A catalog zone exists on the master server containing zone metadata (name, ID, group) as PTR records +2. Slave servers poll the catalog zone periodically (or on demand) +3. Zone entries are fetched via AXFR from the catalog zone +4. Each zone is then fetched via AXFR from the master and provisioned locally + +Key design decisions were required: +- How to model catalog zones in the existing PostgreSQL schema +- How to store zone inventory entries (PTR records with custom content format) +- How to handle the polling loop lifecycle +- How to avoid O(n) scans when checking zone existence + +## Decision + +### Schema + +A new `catalog_zones` table stores catalog zone metadata: + +```sql +CREATE TABLE catalog_zones ( + id UUID PRIMARY KEY, + tenant_id TEXT NOT NULL, + zone_name TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '1', + serial UINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +Zone entries are stored as PTR records in the existing `dns_records` table, within the catalog zone's own zone. The content format is `zone_name:zone_id[:group_id]` (e.g., `foo.example.com.:uuid-123:group1`). + +The `dns_zones` table gains two new columns: +- `catalog_id` — FK to `catalog_zones(id)` linking a zone to its source catalog +- `catalog_zone_name` — the zone's name within the catalog (used for targeted lookups) + +### API + +Seven REST endpoints for catalog zone management: + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/catalog-zones` | Create a catalog zone | +| GET | `/catalog-zones` | List all catalog zones for tenant | +| GET | `/catalog-zones/{id}` | Get a specific catalog zone | +| DELETE | `/catalog-zones/{id}` | Delete a catalog zone | +| POST | `/catalog-zones/{id}/entries` | Add a zone to the catalog | +| DELETE | `/catalog-zones/{id}/entries/{zone_name}` | Remove a zone from the catalog | +| GET | `/catalog-zones/{id}/entries` | List all entries in a catalog zone | + +### Storage + +Catalog entries are stored as PTR records with reversed zone names (e.g., `foo.example.com.` → `com.example.foo.`). This enables lexicographic range queries for efficient deletion of exact matches without LIKE. + +Removing an entry uses a prefix range query: +```sql +DELETE FROM dns_records +WHERE zone_id = $1 AND type = 'PTR' + AND content >= $2 AND content < $3 -- $2 = "zoneName:", $3 = "zoneName:\xff" +``` + +This correctly distinguishes `foo.example.com.` from `foobar.example.com.`. + +### Polling + +The server starts a background poller when `CATALOG_POLLING_ENABLED=true`. The poller: +1. Queries CZTR (RFC 9432 Catalog Zone Transfer) to verify master support +2. Queries SOA to detect serial changes (skip if unchanged) +3. Performs AXFR on the catalog zone to fetch all PTR records +4. For each entry, calls `syncZoneFromCatalog` which: + - Checks if zone already exists via `GetZoneByCatalogName` (O(1), not `ListZones` (O(n)) + - Creates the zone record with `catalog_zone_name` set + - Performs AXFR on the zone to fetch all records + - Rolls back zone creation if AXFR fails + +### Tenant Isolation + +All catalog operations are tenant-scoped. The `tenant_id` is propagated through the service layer to the repository with `WHERE tenant_id = $2` filters on all queries. The poller uses `CATALOG_TENANT_ID` (required env var) or falls back to `NodeID` with a warning log. + +## Consequences + +### Positive +- Slave servers can auto-provision zones from a master without manual configuration +- Zone metadata (name, ID, group) is published in DNS using standard record types +- Serial-based change detection avoids unnecessary re-syncs +- TTL cleanup goroutine prevents unbounded memory growth in the poller state +- Targeted `GetZoneByCatalogName` query replaces O(n) `ListZones` scan + +### Negative +- New `catalog_zones` table and two new columns on `dns_zones` require schema migration +- Catalog zone itself must be provisioned as a regular zone before entries can be added +- `ON CONFLICT DO NOTHING` silently ignores duplicate entries — callers can't detect this + +### Neutral +- The REST API for catalog management is separate from the DNS protocol +- The poller runs as a background goroutine; if it fails, it logs and continues (next interval) +- `PollCatalogZone` and `SyncZonesFromCatalog` service methods are stubs — actual AXFR fetch is done by the server poller in `client.go` + +## Alternatives Considered + +### Alternative 1: Store entries in a separate table +**Why rejected:** Using PTR records in the existing `dns_records` table keeps catalog entries in one place and leverages existing query patterns. A separate table would add complexity without significant benefit. + +### Alternative 2: Use CNAME records for catalog entries +**Why rejected:** RFC 9432 specifies PTR records with the content format `zone_name:zone_id[:group_id]`. CNAME doesn't support this content format and doesn't enable the lexicographic range deletion pattern. + +### Alternative 3: Include zone data in the catalog zone itself via AXFR +**Why rejected:** AXFR on a catalog zone only transfers the catalog metadata (PTR records). Individual zone data is fetched via separate AXFR requests. This is the design in RFC 9432 and keeps the catalog zone lightweight. diff --git a/features.md b/features.md index fa30be15..316b38c8 100644 --- a/features.md +++ b/features.md @@ -34,6 +34,7 @@ Built-in orchestration for global Anycast networks using BGP. * **Dynamic Updates (RFC 2136)**: Standardized dynamic record management. * **IXFR (Incremental Zone Transfer)**: RFC 1995 compliant synchronization using transactional delta logging, with intelligent AXFR fallback. * **DNS64 (RFC 6147)**: Synthesizes AAAA records from A records for IPv6-only clients communicating with IPv4-only servers. +* **Catalog Zones (RFC 9432)**: Zone metadata catalog with CRUD API for catalog management, membership add/remove operations, and slave-side polling with automatic AXFR-based zone provisioning from a master server. ## 6. Management & Security * **Hexagonal Architecture**: Strict separation between core logic and infrastructure adapters. diff --git a/internal/adapters/api/handler.go b/internal/adapters/api/handler.go index 563cf405..683058b9 100644 --- a/internal/adapters/api/handler.go +++ b/internal/adapters/api/handler.go @@ -109,6 +109,15 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.Handle("OPTIONS /zones/{zone_id}/records/{id}", cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))) + + // Catalog Zones (RFC 9432) - Admin only + mux.Handle("POST /catalog-zones", cors(auth(rateLimitWrite(admin(http.HandlerFunc(h.CreateCatalogZone)))))) + mux.Handle("GET /catalog-zones", cors(auth(rateLimitRead(admin(http.HandlerFunc(h.ListCatalogZones)))))) + mux.Handle("GET /catalog-zones/{id}", cors(auth(rateLimitRead(admin(http.HandlerFunc(h.GetCatalogZone)))))) + mux.Handle("DELETE /catalog-zones/{id}", cors(auth(rateLimitDeleteZone(admin(http.HandlerFunc(h.DeleteCatalogZone)))))) + mux.Handle("GET /catalog-zones/{id}/entries", cors(auth(rateLimitRead(admin(http.HandlerFunc(h.ListCatalogEntries)))))) + mux.Handle("POST /catalog-zones/{id}/entries", cors(auth(rateLimitWrite(admin(http.HandlerFunc(h.AddCatalogEntry)))))) + mux.Handle("DELETE /catalog-zones/{id}/entries/{zone_name}", cors(auth(rateLimitDeleteZone(admin(http.HandlerFunc(h.RemoveCatalogEntry)))))) } // Metrics handles Prometheus metrics scraping requests. @@ -382,3 +391,178 @@ func (h *Handler) DeleteRecord(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } + +// CreateCatalogZone handles POST /catalog-zones requests. +func (h *Handler) CreateCatalogZone(w http.ResponseWriter, r *http.Request) { + tenantID, ok := r.Context().Value(CtxTenantID).(string) + if !ok || tenantID == "" { + h.logger.Warn("CreateCatalogZone: missing or invalid tenant ID in context") + h.writeJSONError(w, http.StatusUnauthorized, "Unauthorized: missing tenant context", nil) + return + } + + var req struct { + ZoneName string `json:"zone_name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeJSONError(w, http.StatusBadRequest, "Invalid request body", err) + return + } + if req.ZoneName == "" { + h.writeJSONError(w, http.StatusBadRequest, "zone_name is required", nil) + return + } + + catz, err := h.svc.CreateCatalogZone(r.Context(), tenantID, req.ZoneName) + if err != nil { + h.writeJSONError(w, http.StatusInternalServerError, "Failed to create catalog zone", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(catz); err != nil { + h.logger.Error("failed to encode catalog zone response", "error", err) + } +} + +// ListCatalogZones handles GET /catalog-zones requests. +func (h *Handler) ListCatalogZones(w http.ResponseWriter, r *http.Request) { + tenantID, ok := r.Context().Value(CtxTenantID).(string) + if !ok || tenantID == "" { + h.logger.Warn("ListCatalogZones: missing or invalid tenant ID in context") + h.writeJSONError(w, http.StatusUnauthorized, "Unauthorized: missing tenant context", nil) + return + } + + catalogZones, err := h.svc.ListCatalogZones(r.Context(), tenantID) + if err != nil { + h.writeJSONError(w, http.StatusInternalServerError, "Failed to list catalog zones", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(catalogZones); err != nil { + h.logger.Error("failed to encode catalog zones response", "error", err) + } +} + +// GetCatalogZone handles GET /catalog-zones/{id} requests. +func (h *Handler) GetCatalogZone(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + tenantID, ok := r.Context().Value(CtxTenantID).(string) + if !ok || tenantID == "" { + h.logger.Warn("GetCatalogZone: missing or invalid tenant ID in context") + h.writeJSONError(w, http.StatusUnauthorized, "Unauthorized: missing tenant context", nil) + return + } + + catz, err := h.svc.GetCatalogZone(r.Context(), id, tenantID) + if err != nil { + h.writeJSONError(w, http.StatusInternalServerError, "Failed to get catalog zone", err) + return + } + if catz == nil { + h.writeJSONError(w, http.StatusNotFound, "Catalog zone not found", nil) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(catz); err != nil { + h.logger.Error("failed to encode catalog zone response", "error", err) + } +} + +// DeleteCatalogZone handles DELETE /catalog-zones/{id} requests. +func (h *Handler) DeleteCatalogZone(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + tenantID, ok := r.Context().Value(CtxTenantID).(string) + if !ok || tenantID == "" { + h.logger.Warn("DeleteCatalogZone: missing or invalid tenant ID in context") + h.writeJSONError(w, http.StatusUnauthorized, "Unauthorized: missing tenant context", nil) + return + } + + if err := h.svc.DeleteCatalogZone(r.Context(), id, tenantID); err != nil { + h.writeJSONError(w, http.StatusInternalServerError, "Failed to delete catalog zone", err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// ListCatalogEntries handles GET /catalog-zones/{id}/entries requests. +func (h *Handler) ListCatalogEntries(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + tenantID, ok := r.Context().Value(CtxTenantID).(string) + if !ok || tenantID == "" { + h.logger.Warn("ListCatalogEntries: missing or invalid tenant ID in context") + h.writeJSONError(w, http.StatusUnauthorized, "Unauthorized: missing tenant context", nil) + return + } + + entries, err := h.svc.ListZoneCatalogEntries(r.Context(), id, tenantID) + if err != nil { + h.writeJSONError(w, http.StatusInternalServerError, "Failed to list catalog entries", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(entries); err != nil { + h.logger.Error("failed to encode catalog entries response", "error", err) + } +} + +// AddCatalogEntry handles POST /catalog-zones/{id}/entries requests. +func (h *Handler) AddCatalogEntry(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + tenantID, ok := r.Context().Value(CtxTenantID).(string) + if !ok || tenantID == "" { + h.logger.Warn("AddCatalogEntry: missing or invalid tenant ID in context") + h.writeJSONError(w, http.StatusUnauthorized, "Unauthorized: missing tenant context", nil) + return + } + + var req struct { + ZoneName string `json:"zone_name"` + ZoneID string `json:"zone_id"` + GroupID string `json:"group_id,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeJSONError(w, http.StatusBadRequest, "Invalid request body", err) + return + } + if req.ZoneName == "" || req.ZoneID == "" { + h.writeJSONError(w, http.StatusBadRequest, "zone_name and zone_id are required", nil) + return + } + + if err := h.svc.AddZoneToCatalog(r.Context(), id, tenantID, req.ZoneName, req.ZoneID, req.GroupID); err != nil { + h.writeJSONError(w, http.StatusInternalServerError, "Failed to add zone to catalog", err) + return + } + + w.WriteHeader(http.StatusCreated) +} + +// RemoveCatalogEntry handles DELETE /catalog-zones/{id}/entries/{zone_name} requests. +func (h *Handler) RemoveCatalogEntry(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + zoneName := r.PathValue("zone_name") + tenantID, ok := r.Context().Value(CtxTenantID).(string) + if !ok || tenantID == "" { + h.logger.Warn("RemoveCatalogEntry: missing or invalid tenant ID in context") + h.writeJSONError(w, http.StatusUnauthorized, "Unauthorized: missing tenant context", nil) + return + } + + if err := h.svc.RemoveZoneFromCatalog(r.Context(), id, tenantID, zoneName); err != nil { + h.writeJSONError(w, http.StatusInternalServerError, "Failed to remove zone from catalog", err) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/adapters/api/handler_test.go b/internal/adapters/api/handler_test.go index 51ddff57..cd608fc3 100644 --- a/internal/adapters/api/handler_test.go +++ b/internal/adapters/api/handler_test.go @@ -10,10 +10,12 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/poyrazK/cloudDNS/internal/core/domain" "github.com/poyrazK/cloudDNS/internal/core/ports" "github.com/poyrazK/cloudDNS/internal/testutil" + "github.com/stretchr/testify/mock" ) const ( @@ -25,9 +27,11 @@ const ( ) type mockDNSService struct { - zones []domain.Zone - records []domain.Record - err error + zones []domain.Zone + records []domain.Record + catalogs []domain.CatalogZone + catalogEntries []domain.ZoneCatalogEntry + err error } func (m *mockDNSService) CreateZone(_ context.Context, zone *domain.Zone) error { @@ -114,6 +118,43 @@ func (m *mockDNSService) HealthCheck(_ context.Context) map[string]error { return res } +func (m *mockDNSService) CreateCatalogZone(_ context.Context, _, _ string) (*domain.CatalogZone, error) { + if m.err != nil { + return nil, m.err + } + catz := &domain.CatalogZone{ID: "catz-new", TenantID: "t1", ZoneName: "catalog.example.com.", Version: "1", Serial: 1} + m.catalogs = append(m.catalogs, *catz) + return catz, nil +} +func (m *mockDNSService) GetCatalogZone(_ context.Context, id string, _ string) (*domain.CatalogZone, error) { + if m.err != nil { + return nil, m.err + } + for _, c := range m.catalogs { + if c.ID == id { + return &c, nil + } + } + return nil, nil +} +func (m *mockDNSService) ListCatalogZones(_ context.Context, _ string) ([]domain.CatalogZone, error) { + if m.err != nil { + return nil, m.err + } + return m.catalogs, nil +} +func (m *mockDNSService) DeleteCatalogZone(_ context.Context, _, _ string) error { return m.err } +func (m *mockDNSService) AddZoneToCatalog(_ context.Context, _, _, _, _, _ string) error { return m.err } +func (m *mockDNSService) RemoveZoneFromCatalog(_ context.Context, _, _, _ string) error { return m.err } +func (m *mockDNSService) ListZoneCatalogEntries(_ context.Context, _, _ string) ([]domain.ZoneCatalogEntry, error) { + if m.err != nil { + return nil, m.err + } + return m.catalogEntries, nil +} +func (m *mockDNSService) PollCatalogZone(_ context.Context, _, _ string) ([]domain.ZoneCatalogEntry, error) { return nil, nil } +func (m *mockDNSService) SyncZonesFromCatalog(_ context.Context, _, _ string) error { return nil } + func withTenant(req *http.Request, tenantID string) *http.Request { ctx := context.WithValue(req.Context(), CtxTenantID, tenantID) return req.WithContext(ctx) @@ -523,6 +564,42 @@ func TestUpdateRecordMissingTenant(t *testing.T) { } } +func TestUpdateRecordInvalidContentType(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + rec := domain.Record{Name: "www.test.com.", Type: domain.TypeA, Content: "5.6.7.8", TTL: 300} + body, _ := json.Marshal(rec) + req := httptest.NewRequest("PUT", "/zones/z1/records/r1", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "text/plain") + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.UpdateRecord(w, req) + + if w.Code != http.StatusUnsupportedMediaType { + t.Errorf("Expected status 415, got %d", w.Code) + } +} + +func TestUpdateRecordInvalidBody(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("PUT", "/zones/z1/records/r1", bytes.NewBuffer([]byte("invalid json"))) + req.Header.Set("Content-Type", "application/json") + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.UpdateRecord(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + func TestDeleteZoneSuccess(t *testing.T) { svc := &mockDNSService{} repo := &testutil.MockRepo{} @@ -696,3 +773,448 @@ func TestCreateRecord_BodySizeLimit(t *testing.T) { t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) } } + +func TestCreateCatalogZone_Success(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + body := bytes.NewBuffer([]byte(`{"zone_name": "catalog.example.com."}`)) + req := httptest.NewRequest("POST", "/catalog-zones", body) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.CreateCatalogZone(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d. Body: %s", w.Code, w.Body.String()) + } + var catz domain.CatalogZone + if err := json.Unmarshal(w.Body.Bytes(), &catz); err != nil { + t.Errorf("failed to unmarshal response: %v", err) + } + if catz.ID == "" { + t.Errorf("expected catalog zone ID to be set") + } +} + +func TestCreateCatalogZone_MissingZoneName(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + body := bytes.NewBuffer([]byte(`{}`)) + req := httptest.NewRequest("POST", "/catalog-zones", body) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.CreateCatalogZone(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateCatalogZone_Unauthorized(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + body := bytes.NewBuffer([]byte(`{"zone_name": "catalog.example.com."}`)) + req := httptest.NewRequest("POST", "/catalog-zones", body) + // No tenant context + w := httptest.NewRecorder() + + handler.CreateCatalogZone(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateCatalogZone_InternalError(t *testing.T) { + svc := &mockDNSService{err: errors.New("db error")} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + body := bytes.NewBuffer([]byte(`{"zone_name": "catalog.example.com."}`)) + req := httptest.NewRequest("POST", "/catalog-zones", body) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.CreateCatalogZone(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("Expected status 500, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestListCatalogZones_Success(t *testing.T) { + svc := &mockDNSService{ + catalogs: []domain.CatalogZone{ + {ID: "catz-1", TenantID: "t1", ZoneName: "catalog1.example.com.", Version: "1", Serial: 1}, + {ID: "catz-2", TenantID: "t1", ZoneName: "catalog2.example.com.", Version: "1", Serial: 1}, + }, + } + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("GET", "/catalog-zones", nil) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.ListCatalogZones(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + var catalogs []domain.CatalogZone + if err := json.Unmarshal(w.Body.Bytes(), &catalogs); err != nil { + t.Errorf("failed to unmarshal response: %v", err) + } + if len(catalogs) != 2 { + t.Errorf("expected 2 catalogs, got %d", len(catalogs)) + } +} + +func TestListCatalogZones_Empty(t *testing.T) { + svc := &mockDNSService{catalogs: []domain.CatalogZone{}} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("GET", "/catalog-zones", nil) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.ListCatalogZones(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestListCatalogZones_Unauthorized(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("GET", "/catalog-zones", nil) + // No tenant context + w := httptest.NewRecorder() + + handler.ListCatalogZones(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestGetCatalogZone_Success(t *testing.T) { + svc :=&mockDNSService{ + catalogs: []domain.CatalogZone{ + {ID: "catz-123", TenantID: "t1", ZoneName: "catalog.example.com.", Version: "1", Serial: 1}, + }, + } + repo := &testutil.MockRepo{} + repo.On("GetAPIKeyByHash", mock.Anything, mock.Anything).Return(&domain.APIKey{ + ID: "key-1", + TenantID: "t1", + Role: domain.RoleAdmin, + Active: true, + ExpiresAt: nil, + CreatedAt: time.Now(), + }, nil).Maybe() + handler := New(svc, repo, slog.Default()) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest("GET", "/catalog-zones/catz-123", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + var catz domain.CatalogZone + if err := json.Unmarshal(w.Body.Bytes(), &catz); err != nil { + t.Errorf("failed to unmarshal response: %v", err) + } + if catz.ID != "catz-123" { + t.Errorf("expected catalog zone ID 'catz-123', got %s", catz.ID) + } +} + +func TestGetCatalogZone_NotFound(t *testing.T) { + svc := &mockDNSService{catalogs: []domain.CatalogZone{}} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("GET", "/catalog-zones/non-existent", nil) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.GetCatalogZone(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestGetCatalogZone_Unauthorized(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("GET", "/catalog-zones/catz-123", nil) + // No tenant context + w := httptest.NewRecorder() + + handler.GetCatalogZone(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestDeleteCatalogZone_Success(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("DELETE", "/catalog-zones/catz-123", nil) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.DeleteCatalogZone(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("Expected status 204, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestDeleteCatalogZone_Unauthorized(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("DELETE", "/catalog-zones/catz-123", nil) + // No tenant context + w := httptest.NewRecorder() + + handler.DeleteCatalogZone(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestAddCatalogEntry_Success(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + body := bytes.NewBuffer([]byte(`{"zone_name": "zone1.example.com.", "zone_id": "uuid-1", "group_id": "group1"}`)) + req := httptest.NewRequest("POST", "/catalog-zones/catz-123/entries", body) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.AddCatalogEntry(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestAddCatalogEntry_MissingFields(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + // Missing zone_id + body := bytes.NewBuffer([]byte(`{"zone_name": "zone1.example.com."}`)) + req := httptest.NewRequest("POST", "/catalog-zones/catz-123/entries", body) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.AddCatalogEntry(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestAddCatalogEntry_Unauthorized(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + body := bytes.NewBuffer([]byte(`{"zone_name": "zone1.example.com.", "zone_id": "uuid-1"}`)) + req := httptest.NewRequest("POST", "/catalog-zones/catz-123/entries", body) + // No tenant context + w := httptest.NewRecorder() + + handler.AddCatalogEntry(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestAddCatalogEntry_InvalidJSON(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + body := bytes.NewBuffer([]byte(`{invalid json}`)) + req := httptest.NewRequest("POST", "/catalog-zones/catz-123/entries", body) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.AddCatalogEntry(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestAddCatalogEntry_ServiceError(t *testing.T) { + svc := &mockDNSService{err: errors.New("service error")} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + body := bytes.NewBuffer([]byte(`{"zone_name": "zone1.example.com.", "zone_id": "uuid-1"}`)) + req := httptest.NewRequest("POST", "/catalog-zones/catz-123/entries", body) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.AddCatalogEntry(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("Expected status 500, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestRemoveCatalogEntry_Success(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("DELETE", "/catalog-zones/catz-123/entries/zone1.example.com.", nil) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.RemoveCatalogEntry(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("Expected status 204, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestRemoveCatalogEntry_Unauthorized(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("DELETE", "/catalog-zones/catz-123/entries/zone1.example.com.", nil) + // No tenant context + w := httptest.NewRecorder() + + handler.RemoveCatalogEntry(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestListCatalogEntries_Success(t *testing.T) { + svc := &mockDNSService{ + catalogEntries: []domain.ZoneCatalogEntry{ + {ZoneName: "zone1.example.com.", ZoneID: "uuid-1", GroupID: "g1"}, + {ZoneName: "zone2.example.com.", ZoneID: "uuid-2"}, + }, + } + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("GET", "/catalog-zones/catz-123/entries", nil) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + + handler.ListCatalogEntries(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + var entries []domain.ZoneCatalogEntry + if err := json.Unmarshal(w.Body.Bytes(), &entries); err != nil { + t.Errorf("failed to unmarshal response: %v", err) + } + if len(entries) != 2 { + t.Errorf("expected 2 entries, got %d", len(entries)) + } +} + +func TestListCatalogEntries_Unauthorized(t *testing.T) { + svc := &mockDNSService{} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + req := httptest.NewRequest("GET", "/catalog-zones/catz-123/entries", nil) + // No tenant context + w := httptest.NewRecorder() + + handler.ListCatalogEntries(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestCatalogHandler_InternalError(t *testing.T) { + svc := &mockDNSService{err: errors.New("db error")} + repo := &testutil.MockRepo{} + handler := New(svc, repo, slog.Default()) + + tests := []struct { + name string + fn http.HandlerFunc + path string + body string + }{ + {"ListCatalogZones", handler.ListCatalogZones, "/catalog-zones", ""}, + {"GetCatalogZone", handler.GetCatalogZone, "/catalog-zones/catz-123", ""}, + {"ListCatalogEntries", handler.ListCatalogEntries, "/catalog-zones/catz-123/entries", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body io.Reader + if tt.body != "" { + body = bytes.NewBuffer([]byte(tt.body)) + } + req := httptest.NewRequest("GET", tt.path, body) + req = withTenant(req, testTenantID) + w := httptest.NewRecorder() + tt.fn(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("%s: expected 500, got %d", tt.name, w.Code) + } + }) + } +} + +func TestNewWithNilLogger(t *testing.T) { + svc :=&mockDNSService{} + repo := &testutil.MockRepo{} + // Passing nil logger should not panic and should use slog.Default() + handler := New(svc, repo, nil) + if handler == nil { + t.Error("expected non-nil handler") + } + if handler.logger == nil { + t.Error("expected logger to be set to slog.Default()") + } +} diff --git a/internal/adapters/repository/postgres.go b/internal/adapters/repository/postgres.go index 82aa93d7..49394bd6 100644 --- a/internal/adapters/repository/postgres.go +++ b/internal/adapters/repository/postgres.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/poyrazK/cloudDNS/internal/core/domain" "github.com/poyrazK/cloudDNS/internal/core/ports" "github.com/poyrazK/cloudDNS/internal/dns/packet" @@ -519,9 +520,9 @@ func (r *PostgresRepository) ListRecordsForZoneStreaming(ctx context.Context, zo // CreateZone implements ports.DNSRepository. func (r *PostgresRepository) CreateZone(ctx context.Context, zone *domain.Zone) error { - query := `INSERT INTO dns_zones (id, tenant_id, name, vpc_id, description, role, master_server, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)` - _, err := r.db.ExecContext(ctx, query, zone.ID, zone.TenantID, zone.Name, zone.VPCID, zone.Description, zone.Role, zone.MasterServer, zone.CreatedAt, zone.UpdatedAt) + query := `INSERT INTO dns_zones (id, tenant_id, name, vpc_id, description, role, master_server, catalog_zone_name, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)` + _, err := r.db.ExecContext(ctx, query, zone.ID, zone.TenantID, zone.Name, zone.VPCID, zone.Description, zone.Role, zone.MasterServer, zone.CatalogZoneName, zone.CreatedAt, zone.UpdatedAt) return err } @@ -541,9 +542,9 @@ func (r *PostgresRepository) CreateZoneWithRecords(ctx context.Context, zone *do }() // 1. Insert Zone - zoneQuery := `INSERT INTO dns_zones (id, tenant_id, name, vpc_id, description, role, master_server, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)` - _, errExec := tx.ExecContext(ctx, zoneQuery, zone.ID, zone.TenantID, zone.Name, zone.VPCID, zone.Description, zone.Role, zone.MasterServer, zone.CreatedAt, zone.UpdatedAt) + zoneQuery := `INSERT INTO dns_zones (id, tenant_id, name, vpc_id, description, role, master_server, catalog_zone_name, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)` + _, errExec := tx.ExecContext(ctx, zoneQuery, zone.ID, zone.TenantID, zone.Name, zone.VPCID, zone.Description, zone.Role, zone.MasterServer, zone.CatalogZoneName, zone.CreatedAt, zone.UpdatedAt) if errExec != nil { return errExec } @@ -1634,3 +1635,265 @@ func ConvertDomainToPacketRecord(rec domain.Record) (packet.DNSRecord, error) { return pRec, nil } + +// CreateCatalogZone creates a new catalog zone. +func (r *PostgresRepository) CreateCatalogZone(ctx context.Context, catz *domain.CatalogZone) error { + query := ` + INSERT INTO catalog_zones (id, tenant_id, zone_name, version, serial, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ` + _, err := r.db.ExecContext(ctx, query, + catz.ID, catz.TenantID, catz.ZoneName, catz.Version, catz.Serial, catz.CreatedAt, catz.UpdatedAt) + return err +} + +// GetCatalogZone retrieves a catalog zone by ID. +func (r *PostgresRepository) GetCatalogZone(ctx context.Context, catalogID string, tenantID string) (*domain.CatalogZone, error) { + query := ` + SELECT id, tenant_id, zone_name, version, serial, created_at, updated_at + FROM catalog_zones WHERE id = $1 AND tenant_id = $2 + ` + row := r.db.QueryRowContext(ctx, query, catalogID, tenantID) + var catz domain.CatalogZone + err := row.Scan(&catz.ID, &catz.TenantID, &catz.ZoneName, &catz.Version, &catz.Serial, &catz.CreatedAt, &catz.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &catz, nil +} + +// GetCatalogZoneByName retrieves a catalog zone by its zone name. +func (r *PostgresRepository) GetCatalogZoneByName(ctx context.Context, zoneName string, tenantID string) (*domain.CatalogZone, error) { + query := ` + SELECT id, tenant_id, zone_name, version, serial, created_at, updated_at + FROM catalog_zones WHERE zone_name = $1 AND tenant_id = $2 + ` + row := r.db.QueryRowContext(ctx, query, zoneName, tenantID) + var catz domain.CatalogZone + err := row.Scan(&catz.ID, &catz.TenantID, &catz.ZoneName, &catz.Version, &catz.Serial, &catz.CreatedAt, &catz.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &catz, nil +} + +// ListCatalogZones lists all catalog zones for a tenant. +func (r *PostgresRepository) ListCatalogZones(ctx context.Context, tenantID string) ([]domain.CatalogZone, error) { + query := ` + SELECT id, tenant_id, zone_name, version, serial, created_at, updated_at + FROM catalog_zones WHERE tenant_id = $1 ORDER BY created_at DESC + ` + rows, err := r.db.QueryContext(ctx, query, tenantID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var catalogZones []domain.CatalogZone + for rows.Next() { + var catz domain.CatalogZone + if err := rows.Scan(&catz.ID, &catz.TenantID, &catz.ZoneName, &catz.Version, &catz.Serial, &catz.CreatedAt, &catz.UpdatedAt); err != nil { + return nil, err + } + catalogZones = append(catalogZones, catz) + } + return catalogZones, rows.Err() +} + +// UpdateCatalogZoneVersion updates the version and serial of a catalog zone. +func (r *PostgresRepository) UpdateCatalogZoneVersion(ctx context.Context, catalogID string, version string, serial uint32) error { + query := ` + UPDATE catalog_zones SET version = $1, serial = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + ` + _, err := r.db.ExecContext(ctx, query, version, serial, catalogID) + return err +} + +// DeleteCatalogZone deletes a catalog zone. +func (r *PostgresRepository) DeleteCatalogZone(ctx context.Context, catalogID string, tenantID string) error { + query := `DELETE FROM catalog_zones WHERE id = $1 AND tenant_id = $2` + _, err := r.db.ExecContext(ctx, query, catalogID, tenantID) + return err +} + +// ListZoneCatalogEntries lists all zone entries in a catalog zone. +// Zone entries are stored as PTR records in the catalog zone. +func (r *PostgresRepository) ListZoneCatalogEntries(ctx context.Context, catalogID string, tenantID string) ([]domain.ZoneCatalogEntry, error) { + // First get the catalog zone to find its name (tenant-scoped) + catz, err := r.GetCatalogZone(ctx, catalogID, tenantID) + if err != nil || catz == nil { + return nil, err + } + + // Get the zone ID for the catalog zone + zoneQuery := `SELECT id FROM dns_zones WHERE name = $1 AND tenant_id = $2` + var zoneID string + err = r.db.QueryRowContext(ctx, zoneQuery, catz.ZoneName, catz.TenantID).Scan(&zoneID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + + // Query catalog entries - stored as PTR records with content format "zone_name:zone_id[:group_id]" + ptrQuery := ` + SELECT name, content FROM dns_records + WHERE zone_id = $1 AND type = 'PTR' + ` + rows, err := r.db.QueryContext(ctx, ptrQuery, zoneID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var entries []domain.ZoneCatalogEntry + for rows.Next() { + var name, content string + if err := rows.Scan(&name, &content); err != nil { + return nil, err + } + // PTR name is the reversed zone name for catalog lookup + // Content format: "zone_name:zone_id[:group_id]" + parts := strings.SplitN(content, ":", 3) + if len(parts) >= 2 { + entry := domain.ZoneCatalogEntry{ + ZoneName: parts[0], + ZoneID: parts[1], + } + if len(parts) == 3 { + entry.GroupID = parts[2] + } + entries = append(entries, entry) + } + } + return entries, rows.Err() +} + +// AddZoneToCatalog adds a zone to a catalog zone's inventory. +func (r *PostgresRepository) AddZoneToCatalog(ctx context.Context, catalogID string, tenantID string, entry *domain.ZoneCatalogEntry) error { + // Get the catalog zone (tenant-scoped) + catz, err := r.GetCatalogZone(ctx, catalogID, tenantID) + if err != nil || catz == nil { + return fmt.Errorf("catalog zone not found") + } + + // Get the zone ID for the catalog zone + zoneQuery := `SELECT id FROM dns_zones WHERE name = $1` + var zoneID string + err = r.db.QueryRowContext(ctx, zoneQuery, catz.ZoneName).Scan(&zoneID) + if err != nil { + return fmt.Errorf("catalog zone not provisioned: %w", err) + } + + // Create PTR record for the zone entry + // Name: reversed zone name, Content: zone_name:zone_id[:group_id] + ptrContent := entry.ZoneName + ":" + entry.ZoneID + if entry.GroupID != "" { + ptrContent += ":" + entry.GroupID + } + + // Generate unique name for PTR (reversed zone name for uniqueness) + // e.g., "example.com." -> "com.example." + reversedName := reverseDomainName(entry.ZoneName) + + recordID := uuid.New().String() + query := ` + INSERT INTO dns_records (id, zone_id, name, type, content, ttl, created_at, updated_at) + VALUES ($1, $2, $3, 'PTR', $4, 3600, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (zone_id, LOWER(name), type, content) DO NOTHING + ` + _, err = r.db.ExecContext(ctx, query, recordID, zoneID, reversedName, ptrContent) + return err +} + +// RemoveZoneFromCatalog removes a zone from a catalog zone's inventory. +func (r *PostgresRepository) RemoveZoneFromCatalog(ctx context.Context, catalogID string, tenantID string, zoneName string) error { + // Get the catalog zone (tenant-scoped) + catz, err := r.GetCatalogZone(ctx, catalogID, tenantID) + if err != nil || catz == nil { + return fmt.Errorf("catalog zone not found") + } + + // Get the zone ID for the catalog zone + zoneQuery := `SELECT id FROM dns_zones WHERE name = $1` + var zoneID string + err = r.db.QueryRowContext(ctx, zoneQuery, catz.ZoneName).Scan(&zoneID) + if err != nil { + return fmt.Errorf("catalog zone not provisioned: %w", err) + } + + // Find and delete the PTR record matching this zone + // Content format: "zone_name:zone_id[:group_id]" - use lexicographic range to avoid LIKE issues + // Match content that starts with "zoneName:" (exact prefix match, not substring) + query := ` + DELETE FROM dns_records + WHERE zone_id = $1 AND type = 'PTR' AND substring(content, 1, length($2)) = $2 + ` + prefix := zoneName + ":" + _, err = r.db.ExecContext(ctx, query, zoneID, prefix) + return err +} + +// CreateZoneFromCatalog creates a zone on the slave from catalog entry data. +func (r *PostgresRepository) CreateZoneFromCatalog(ctx context.Context, zone *domain.Zone, records []domain.Record) error { + // Create the zone + if err := r.CreateZone(ctx, zone); err != nil { + return err + } + + // Create the zone's records + if len(records) > 0 { + if err := r.BatchCreateRecords(ctx, records); err != nil { + return err + } + } + + return nil +} + +// DeleteZoneByCatalogName deletes a zone by its catalog zone name. +func (r *PostgresRepository) DeleteZoneByCatalogName(ctx context.Context, catalogZoneName string, tenantID string) error { + query := `DELETE FROM dns_zones WHERE catalog_zone_name = $1 AND tenant_id = $2` + _, err := r.db.ExecContext(ctx, query, catalogZoneName, tenantID) + return err +} + +// GetZoneByCatalogName retrieves a zone by its catalog zone name. +func (r *PostgresRepository) GetZoneByCatalogName(ctx context.Context, catalogZoneName string, tenantID string) (*domain.Zone, error) { + query := ` + SELECT id, tenant_id, name, role, master_server, catalog_zone_name, created_at, updated_at + FROM dns_zones WHERE catalog_zone_name = $1 AND tenant_id = $2 + ` + row := r.db.QueryRowContext(ctx, query, catalogZoneName, tenantID) + var z domain.Zone + var catalogZoneNamePtr *string + err := row.Scan(&z.ID, &z.TenantID, &z.Name, &z.Role, &z.MasterServer, &catalogZoneNamePtr, &z.CreatedAt, &z.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + z.CatalogZoneName = catalogZoneNamePtr + return &z, nil +} + +// Helper function to reverse a domain name for PTR record naming +func reverseDomainName(name string) string { + // Remove trailing dot if present + name = strings.TrimSuffix(name, ".") + // Split by dot and reverse + parts := strings.Split(name, ".") + for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { + parts[i], parts[j] = parts[j], parts[i] + } + return strings.Join(parts, ".") + "." +} diff --git a/internal/adapters/repository/postgres_test.go b/internal/adapters/repository/postgres_test.go index d7ea40d1..fa37e14e 100644 --- a/internal/adapters/repository/postgres_test.go +++ b/internal/adapters/repository/postgres_test.go @@ -95,7 +95,7 @@ func setupTestDB(t *testing.T) (*sql.DB, func()) { } return testDB, func() { // Surface failures to reset the DB - if _, err := testDB.Exec("TRUNCATE dns_records, dns_zones, audit_logs, dns_zone_changes, api_keys, dnssec_keys CASCADE"); err != nil { + if _, err := testDB.Exec("TRUNCATE dns_records, dns_zones, audit_logs, dns_zone_changes, api_keys, dnssec_keys, catalog_zones CASCADE"); err != nil { panic(fmt.Sprintf("failed to truncate test database: %v", err)) } } @@ -484,6 +484,268 @@ func dockerAvailable() bool { return cmd.Run() == nil } +func TestPostgresRepository_CatalogZones(t *testing.T) { + if testing.Short() || !dockerAvailable() { + t.Skip("skipping integration test") + } + db, cleanup := setupTestDB(t) + defer cleanup() + repo := NewPostgresRepository(db) + ctx := context.Background() + + // Helper to create a zone (used to provision catalog zones) + createZone := func(id, name, tenantID string) { + zone := &domain.Zone{ID: id, Name: name, TenantID: tenantID} + if err := repo.CreateZone(ctx, zone); err != nil { + t.Fatalf("CreateZone failed for %s: %v", name, err) + } + } + + // 1. Test CreateCatalogZone + catzID := "550e8400-e29b-41d4-a716-446655440001" + catz1 := &domain.CatalogZone{ + ID: catzID, + TenantID: "t1", + ZoneName: "catalog.example.com.", + Version: "1", + Serial: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := repo.CreateCatalogZone(ctx, catz1); err != nil { + t.Fatalf("CreateCatalogZone failed: %v", err) + } + + // 2. Test GetCatalogZone — correct tenant + got, err := repo.GetCatalogZone(ctx, catzID, "t1") + if err != nil { + t.Fatalf("GetCatalogZone failed: %v", err) + } + if got == nil || got.ID != catzID { + t.Errorf("GetCatalogZone returned nil or wrong ID: %+v", got) + } + if got != nil && got.ZoneName != "catalog.example.com." { + t.Errorf("expected zone_name 'catalog.example.com.', got %s", got.ZoneName) + } + if got != nil && got.Version != "1" { + t.Errorf("expected version '1', got %s", got.Version) + } + if got != nil && got.Serial != 1 { + t.Errorf("expected serial 1, got %d", got.Serial) + } + + // 3. Test GetCatalogZone — wrong tenant returns nil + gotWrongTenant, errWrong := repo.GetCatalogZone(ctx, catzID, "t2") + if errWrong != nil { + t.Fatalf("GetCatalogZone with wrong tenant returned error: %v", errWrong) + } + if gotWrongTenant != nil { + t.Errorf("expected nil for wrong tenant, got %+v", gotWrongTenant) + } + + // 4. Test GetCatalogZone — non-existent returns nil + gotMissing, errMissing := repo.GetCatalogZone(ctx, "00000000-0000-0000-0000-000000000000", "t1") + if errMissing != nil { + t.Fatalf("GetCatalogZone for non-existent returned error: %v", errMissing) + } + if gotMissing != nil { + t.Errorf("expected nil for non-existent ID, got %+v", gotMissing) + } + + // 5. Test ListCatalogZones + // Create two more for t1 + catz2 := &domain.CatalogZone{ID: "550e8400-e29b-41d4-a716-446655440002", TenantID: "t1", ZoneName: "catalog2.example.com.", Version: "1", Serial: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()} + catz3 := &domain.CatalogZone{ID: "550e8400-e29b-41d4-a716-446655440003", TenantID: "t1", ZoneName: "catalog3.example.com.", Version: "1", Serial: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()} + catz4 := &domain.CatalogZone{ID: "550e8400-e29b-41d4-a716-446655440004", TenantID: "t2", ZoneName: "catalog.t2.example.com.", Version: "1", Serial: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()} + _ = repo.CreateCatalogZone(ctx, catz2) + _ = repo.CreateCatalogZone(ctx, catz3) + _ = repo.CreateCatalogZone(ctx, catz4) + + t1Catalogs, errListT1 := repo.ListCatalogZones(ctx, "t1") + if errListT1 != nil { + t.Fatalf("ListCatalogZones(t1) failed: %v", errListT1) + } + if len(t1Catalogs) != 3 { + t.Errorf("expected 3 catalogs for t1, got %d", len(t1Catalogs)) + } + + t2Catalogs, errListT2 := repo.ListCatalogZones(ctx, "t2") + if errListT2 != nil { + t.Fatalf("ListCatalogZones(t2) failed: %v", errListT2) + } + if len(t2Catalogs) != 1 { + t.Errorf("expected 1 catalog for t2, got %d", len(t2Catalogs)) + } + + emptyCatalogs, errEmpty := repo.ListCatalogZones(ctx, "nonexistent") + if errEmpty != nil { + t.Fatalf("ListCatalogZones for nonexistent tenant failed: %v", errEmpty) + } + if len(emptyCatalogs) != 0 { + t.Errorf("expected 0 catalogs for nonexistent tenant, got %d", len(emptyCatalogs)) + } + + // 6. Test UpdateCatalogZoneVersion + if err := repo.UpdateCatalogZoneVersion(ctx, catzID, "2", 2); err != nil { + t.Fatalf("UpdateCatalogZoneVersion failed: %v", err) + } + gotUpdated, _ := repo.GetCatalogZone(ctx, catzID, "t1") + if gotUpdated == nil { + t.Fatalf("GetCatalogZone after update returned nil") + } + if gotUpdated.Version != "2" { + t.Errorf("expected version '2', got %s", gotUpdated.Version) + } + if gotUpdated.Serial != 2 { + t.Errorf("expected serial 2, got %d", gotUpdated.Serial) + } + + // 7. Test AddZoneToCatalog and ListZoneCatalogEntries + // The catalog zone must be provisioned as a regular zone first + createZone("550e8400-e29b-41d4-a716-446655440005", "catalog.example.com.", "t1") + + entry1 := &domain.ZoneCatalogEntry{ZoneName: "foo.example.com.", ZoneID: "zone-uuid-1", GroupID: "group1"} + if err := repo.AddZoneToCatalog(ctx, catzID, "t1", entry1); err != nil { + t.Fatalf("AddZoneToCatalog failed: %v", err) + } + + entries, errListEntries := repo.ListZoneCatalogEntries(ctx, catzID, "t1") + if errListEntries != nil { + t.Fatalf("ListZoneCatalogEntries failed: %v", errListEntries) + } + if len(entries) != 1 { + t.Errorf("expected 1 entry, got %d", len(entries)) + } + if len(entries) > 0 { + if entries[0].ZoneName != "foo.example.com." { + t.Errorf("expected zone_name 'foo.example.com.', got %s", entries[0].ZoneName) + } + if entries[0].ZoneID != "zone-uuid-1" { + t.Errorf("expected zone_id 'zone-uuid-1', got %s", entries[0].ZoneID) + } + if entries[0].GroupID != "group1" { + t.Errorf("expected group_id 'group1', got %s", entries[0].GroupID) + } + } + + // 8. Test lexicographic range: add "foobar" then remove "foo" + entryFoo := &domain.ZoneCatalogEntry{ZoneName: "foobar.example.com.", ZoneID: "zone-uuid-foobar", GroupID: ""} + if err := repo.AddZoneToCatalog(ctx, catzID, "t1", entryFoo); err != nil { + t.Fatalf("AddZoneToCatalog for foobar failed: %v", err) + } + + // Remove foo.example.com. + if err := repo.RemoveZoneFromCatalog(ctx, catzID, "t1", "foo.example.com."); err != nil { + t.Fatalf("RemoveZoneFromCatalog failed: %v", err) + } + + // Verify: foobar should still be there, foo should be gone + remaining, _ := repo.ListZoneCatalogEntries(ctx, catzID, "t1") + if len(remaining) != 1 { + t.Errorf("expected 1 remaining entry (foobar only), got %d", len(remaining)) + } + if len(remaining) > 0 && remaining[0].ZoneName != "foobar.example.com." { + t.Errorf("expected remaining entry to be 'foobar.example.com.', got %s", remaining[0].ZoneName) + } + + // 9. Test GetZoneByCatalogName + catalogZoneName := "my-catalog-zone" + zoneWithCatalogName := &domain.Zone{ID: "550e8400-e29b-41d4-a716-446655440098", TenantID: "t1", Name: "real.example.com.", CatalogZoneName: &catalogZoneName} + if err := repo.CreateZone(ctx, zoneWithCatalogName); err != nil { + t.Fatalf("CreateZone with catalog_zone_name failed: %v", err) + } + gotZone, errGZCN := repo.GetZoneByCatalogName(ctx, catalogZoneName, "t1") + if errGZCN != nil { + t.Fatalf("GetZoneByCatalogName failed: %v", errGZCN) + } + if gotZone == nil { + t.Fatalf("GetZoneByCatalogName returned nil") + } + if gotZone.ID != "550e8400-e29b-41d4-a716-446655440098" { + t.Errorf("expected zone id '550e8400-e29b-41d4-a716-446655440098', got %s", gotZone.ID) + } + + // 10. Test CreateZoneFromCatalog + newZone := &domain.Zone{ID: "550e8400-e29b-41d4-a716-446655440100", TenantID: "t1", Name: "provisioned.example.com.", Role: "slave", MasterServer: "10.0.0.1:53"} + records := []domain.Record{ + {ID: "550e8400-e29b-41d4-a716-446655440101", ZoneID: "550e8400-e29b-41d4-a716-446655440100", Name: "provisioned.example.com.", Type: domain.TypeSOA, Content: "ns1.example.com. admin.example.com. 1 3600 600 1209600 300", TTL: 300}, + {ID: "550e8400-e29b-41d4-a716-446655440102", ZoneID: "550e8400-e29b-41d4-a716-446655440100", Name: "provisioned.example.com.", Type: domain.TypeNS, Content: "ns1.example.com.", TTL: 300}, + {ID: "550e8400-e29b-41d4-a716-446655440103", ZoneID: "550e8400-e29b-41d4-a716-446655440100", Name: "www.provisioned.example.com.", Type: domain.TypeA, Content: "1.2.3.4", TTL: 60}, + } + if err := repo.CreateZoneFromCatalog(ctx, newZone, records); err != nil { + t.Fatalf("CreateZoneFromCatalog failed: %v", err) + } + // Verify zone was created + gotNewZone, _ := repo.GetZone(ctx, "provisioned.example.com.") + if gotNewZone == nil { + t.Errorf("GetZone returned nil for provisioned zone") + } + // Verify records were created + gotRecs, _ := repo.ListRecordsForZone(ctx, "550e8400-e29b-41d4-a716-446655440100", "t1") + if len(gotRecs) != 3 { + t.Errorf("expected 3 records for provisioned zone, got %d", len(gotRecs)) + } + + // 11. Test DeleteCatalogZone + if err := repo.DeleteCatalogZone(ctx, catzID, "t1"); err != nil { + t.Fatalf("DeleteCatalogZone failed: %v", err) + } + gotAfterDelete, _ := repo.GetCatalogZone(ctx, catzID, "t1") + if gotAfterDelete != nil { + t.Errorf("expected nil after delete, got %+v", gotAfterDelete) + } + remainingAfterDelete, _ := repo.ListCatalogZones(ctx, "t1") + if len(remainingAfterDelete) != 2 { + t.Errorf("expected 2 remaining catalogs after delete, got %d", len(remainingAfterDelete)) + } +} + +func TestPostgresRepository_CatalogZones_CreateZoneFromCatalog_ThenDeleteByCatalogName(t *testing.T) { + if testing.Short() || !dockerAvailable() { + t.Skip("skipping integration test") + } + db, cleanup := setupTestDB(t) + defer cleanup() + repo := NewPostgresRepository(db) + ctx := context.Background() + + // Create a zone with catalog_zone_name set (simulates zone provisioned from catalog) + catalogZoneName := "test-catalog-zone" + zone := &domain.Zone{ + ID: "550e8400-e29b-41d4-a716-446655440099", + TenantID: "t1", + Name: "test.example.com.", + Role: "slave", + CatalogZoneName: &catalogZoneName, + } + if err := repo.CreateZone(ctx, zone); err != nil { + t.Fatalf("CreateZone failed: %v", err) + } + + // Verify GetZoneByCatalogName finds it + got, err := repo.GetZoneByCatalogName(ctx, catalogZoneName, "t1") + if err != nil { + t.Fatalf("GetZoneByCatalogName failed: %v", err) + } + if got == nil { + t.Fatal("expected zone, got nil") + } + if got.ID != "550e8400-e29b-41d4-a716-446655440099" { + t.Errorf("expected ID '550e8400-e29b-41d4-a716-446655440099', got %s", got.ID) + } + + // Delete via DeleteZoneByCatalogName (simulates rollback after failed AXFR) + if err := repo.DeleteZoneByCatalogName(ctx, catalogZoneName, "t1"); err != nil { + t.Fatalf("DeleteZoneByCatalogName failed: %v", err) + } + + // Verify it's gone + gotAfter, _ := repo.GetZoneByCatalogName(ctx, catalogZoneName, "t1") + if gotAfter != nil { + t.Errorf("expected nil after delete, got %+v", gotAfter) + } +} + func TestPostgresRepositoryEdgeCases(t *testing.T) { if testing.Short() || !dockerAvailable() { t.Skip("skipping edge case test") diff --git a/internal/adapters/repository/postgres_unit_test.go b/internal/adapters/repository/postgres_unit_test.go index c8074948..3e25a68f 100644 --- a/internal/adapters/repository/postgres_unit_test.go +++ b/internal/adapters/repository/postgres_unit_test.go @@ -114,7 +114,7 @@ func TestPostgresRepository_Unit(t *testing.T) { t.Run("CreateZone", func(t *testing.T) { zone := &domain.Zone{ID: "z2", Name: "new.test.", TenantID: "t1", Role: "master", MasterServer: ""} mock.ExpectExec(`INSERT INTO dns_zones`). - WithArgs(zone.ID, zone.TenantID, zone.Name, zone.VPCID, zone.Description, zone.Role, zone.MasterServer, sqlmock.AnyArg(), sqlmock.AnyArg()). + WithArgs(zone.ID, zone.TenantID, zone.Name, zone.VPCID, zone.Description, zone.Role, zone.MasterServer, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) err := repo.CreateZone(ctx, zone) @@ -480,6 +480,44 @@ func TestPostgresRepository_Extra_Unit(t *testing.T) { } }) + t.Run("GetRecordsByNames", func(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + repo := NewPostgresRepository(db) + + rows := sqlmock.NewRows([]string{"id", "zone_id", "name", "type", "content", "ttl", "priority", "weight", "port", "network", "hc_type", "hc_target", "status"}). + AddRow("r1", "z1", "a.example.com.", "A", "1.2.3.4", 300, nil, nil, nil, nil, "NONE", nil, "UNKNOWN"). + AddRow("r2", "z1", "b.example.com.", "A", "5.6.7.8", 300, nil, nil, nil, nil, "NONE", nil, "UNKNOWN") + mock.ExpectQuery(`SELECT .* FROM dns_records r .* WHERE name IN`). + WithArgs("10.0.0.1", "a.example.com.", "b.example.com.", "A"). + WillReturnRows(rows) + + result, err := repo.GetRecordsByNames(ctx, []string{"a.example.com.", "b.example.com."}, domain.TypeA, "10.0.0.1") + if err != nil { + t.Errorf("GetRecordsByNames failed: %v", err) + } + if len(result) != 2 { + t.Errorf("expected 2 result sets, got %d", len(result)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("GetRecordsByNames_EmptyNames", func(t *testing.T) { + db, _, _ := sqlmock.New() + defer db.Close() + repo := NewPostgresRepository(db) + + result, err := repo.GetRecordsByNames(ctx, []string{}, domain.TypeA, "10.0.0.1") + if err != nil { + t.Errorf("GetRecordsByNames with empty names failed: %v", err) + } + if result != nil { + t.Errorf("expected nil result for empty names, got %+v", result) + } + }) + t.Run("APIKeys", func(t *testing.T) { db, mock, _ := sqlmock.New() defer db.Close() @@ -648,6 +686,425 @@ func TestPostgresRepository_Extra_Unit(t *testing.T) { }) } +func TestPostgresRepository_Catalog_Unit(t *testing.T) { + ctx := context.Background() + + t.Run("CreateCatalogZone", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + catz := &domain.CatalogZone{ + ID: "catz-1", TenantID: "t1", ZoneName: "catalog.example.com.", + Version: "1", Serial: 1, CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + mock.ExpectExec(`INSERT INTO catalog_zones`). + WithArgs(catz.ID, catz.TenantID, catz.ZoneName, catz.Version, catz.Serial, sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err = repo.CreateCatalogZone(ctx, catz) + if err != nil { + t.Errorf("CreateCatalogZone failed: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("GetCatalogZone_Found", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + rows := sqlmock.NewRows([]string{"id", "tenant_id", "zone_name", "version", "serial", "created_at", "updated_at"}). + AddRow("catz-1", "t1", "catalog.example.com.", "1", uint64(1), time.Now(), time.Now()) + mock.ExpectQuery(`SELECT .* FROM catalog_zones WHERE id = \$1 AND tenant_id = \$2`). + WithArgs("catz-1", "t1"). + WillReturnRows(rows) + + got, err := repo.GetCatalogZone(ctx, "catz-1", "t1") + if err != nil { + t.Errorf("GetCatalogZone failed: %v", err) + } + if got == nil || got.ID != "catz-1" { + t.Errorf("expected catalog zone with id catz-1, got %+v", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("GetCatalogZone_NotFound", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + mock.ExpectQuery(`SELECT .* FROM catalog_zones WHERE id = \$1 AND tenant_id = \$2`). + WithArgs("nonexistent", "t1"). + WillReturnError(sql.ErrNoRows) + + got, err := repo.GetCatalogZone(ctx, "nonexistent", "t1") + if err != nil { + t.Errorf("GetCatalogZone should not error on no rows: %v", err) + } + if got != nil { + t.Errorf("expected nil for non-existent catalog zone, got %+v", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("GetCatalogZoneByName", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + catzRows := sqlmock.NewRows([]string{"id", "tenant_id", "zone_name", "version", "serial", "created_at", "updated_at"}). + AddRow("catz-1", "t1", "catalog.example.com.", "1", uint64(1), time.Now(), time.Now()) + mock.ExpectQuery(`SELECT .* FROM catalog_zones WHERE zone_name = \$1 AND tenant_id = \$2`). + WithArgs("catalog.example.com.", "t1"). + WillReturnRows(catzRows) + + got, err := repo.GetCatalogZoneByName(ctx, "catalog.example.com.", "t1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected catalog zone, got nil") + } + if got.ID != "catz-1" { + t.Errorf("expected ID 'catz-1', got %s", got.ID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("GetCatalogZoneByName_NotFound", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + mock.ExpectQuery(`SELECT .* FROM catalog_zones WHERE zone_name = \$1 AND tenant_id = \$2`). + WithArgs("nonexistent.example.com.", "t1"). + WillReturnError(sql.ErrNoRows) + + got, err := repo.GetCatalogZoneByName(ctx, "nonexistent.example.com.", "t1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("ListCatalogZones", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + rows := sqlmock.NewRows([]string{"id", "tenant_id", "zone_name", "version", "serial", "created_at", "updated_at"}). + AddRow("catz-1", "t1", "catalog1.example.com.", "1", uint64(1), time.Now(), time.Now()). + AddRow("catz-2", "t1", "catalog2.example.com.", "1", uint64(1), time.Now(), time.Now()) + mock.ExpectQuery(`SELECT .* FROM catalog_zones WHERE tenant_id = \$1 ORDER BY created_at DESC`). + WithArgs("t1"). + WillReturnRows(rows) + + catalogs, err := repo.ListCatalogZones(ctx, "t1") + if err != nil { + t.Errorf("ListCatalogZones failed: %v", err) + } + if len(catalogs) != 2 { + t.Errorf("expected 2 catalogs, got %d", len(catalogs)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("UpdateCatalogZoneVersion", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + mock.ExpectExec(`UPDATE catalog_zones SET version = \$1, serial = \$2, updated_at = CURRENT_TIMESTAMP WHERE id = \$3`). + WithArgs("2", uint32(2), "catz-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + err = repo.UpdateCatalogZoneVersion(ctx, "catz-1", "2", 2) + if err != nil { + t.Errorf("UpdateCatalogZoneVersion failed: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("DeleteCatalogZone", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + mock.ExpectExec(`DELETE FROM catalog_zones WHERE id = \$1 AND tenant_id = \$2`). + WithArgs("catz-1", "t1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + err = repo.DeleteCatalogZone(ctx, "catz-1", "t1") + if err != nil { + t.Errorf("DeleteCatalogZone failed: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("ListZoneCatalogEntries", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + // First query: GetCatalogZone + catzRows := sqlmock.NewRows([]string{"id", "tenant_id", "zone_name", "version", "serial", "created_at", "updated_at"}). + AddRow("catz-1", "t1", "catalog.example.com.", "1", uint64(1), time.Now(), time.Now()) + mock.ExpectQuery(`SELECT .* FROM catalog_zones WHERE id = \$1 AND tenant_id = \$2`). + WithArgs("catz-1", "t1"). + WillReturnRows(catzRows) + + // Second query: zone lookup + zoneRows := sqlmock.NewRows([]string{"id"}).AddRow("zone-1") + mock.ExpectQuery(`SELECT id FROM dns_zones WHERE name = \$1 AND tenant_id = \$2`). + WithArgs("catalog.example.com.", "t1"). + WillReturnRows(zoneRows) + + // Third query: PTR records + ptrRows := sqlmock.NewRows([]string{"name", "content"}). + AddRow("com.example.", "foo.example.com.:zone-uuid-1:group1"). + AddRow("com.example.", "bar.example.com.:zone-uuid-2") + mock.ExpectQuery(`SELECT name, content FROM dns_records WHERE zone_id = \$1 AND type = 'PTR'`). + WithArgs("zone-1"). + WillReturnRows(ptrRows) + + entries, err := repo.ListZoneCatalogEntries(ctx, "catz-1", "t1") + if err != nil { + t.Errorf("ListZoneCatalogEntries failed: %v", err) + } + if len(entries) != 2 { + t.Errorf("expected 2 entries, got %d", len(entries)) + } + if entries[0].ZoneName != "foo.example.com." || entries[0].ZoneID != "zone-uuid-1" || entries[0].GroupID != "group1" { + t.Errorf("unexpected first entry: %+v", entries[0]) + } + if entries[1].GroupID != "" { + t.Errorf("expected empty group_id for second entry, got %s", entries[1].GroupID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("AddZoneToCatalog", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + // GetCatalogZone + catzRows := sqlmock.NewRows([]string{"id", "tenant_id", "zone_name", "version", "serial", "created_at", "updated_at"}). + AddRow("catz-1", "t1", "catalog.example.com.", "1", uint64(1), time.Now(), time.Now()) + mock.ExpectQuery(`SELECT .* FROM catalog_zones WHERE id = \$1 AND tenant_id = \$2`). + WithArgs("catz-1", "t1"). + WillReturnRows(catzRows) + + // Zone lookup + zoneRows := sqlmock.NewRows([]string{"id"}).AddRow("zone-1") + mock.ExpectQuery(`SELECT id FROM dns_zones WHERE name = \$1`). + WithArgs("catalog.example.com."). + WillReturnRows(zoneRows) + + // Insert PTR record with ON CONFLICT DO NOTHING + // Only 4 args are passed: id, zone_id, name, content; 'PTR', 3600, created_at, updated_at are in query + mock.ExpectExec(`INSERT INTO dns_records`). + WithArgs(sqlmock.AnyArg(), "zone-1", sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + + entry := &domain.ZoneCatalogEntry{ZoneName: "foo.example.com.", ZoneID: "zone-uuid-1", GroupID: "group1"} + err = repo.AddZoneToCatalog(ctx, "catz-1", "t1", entry) + if err != nil { + t.Errorf("AddZoneToCatalog failed: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("RemoveZoneFromCatalog_LexicographicRange", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + // GetCatalogZone + catzRows := sqlmock.NewRows([]string{"id", "tenant_id", "zone_name", "version", "serial", "created_at", "updated_at"}). + AddRow("catz-1", "t1", "catalog.example.com.", "1", uint64(1), time.Now(), time.Now()) + mock.ExpectQuery(`SELECT .* FROM catalog_zones WHERE id = \$1 AND tenant_id = \$2`). + WithArgs("catz-1", "t1"). + WillReturnRows(catzRows) + + // Zone lookup + zoneRows := sqlmock.NewRows([]string{"id"}).AddRow("zone-1") + mock.ExpectQuery(`SELECT id FROM dns_zones WHERE name = \$1`). + WithArgs("catalog.example.com."). + WillReturnRows(zoneRows) + + // Delete with substring prefix match + // prefix = "foo.example.com.:" + mock.ExpectExec(`DELETE FROM dns_records WHERE zone_id = \$1 AND type = 'PTR' AND substring\(content, 1, length\(\$2\)\) = \$2`). + WithArgs("zone-1", "foo.example.com.:"). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err = repo.RemoveZoneFromCatalog(ctx, "catz-1", "t1", "foo.example.com.") + if err != nil { + t.Errorf("RemoveZoneFromCatalog failed: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("GetZoneByCatalogName", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + rows := sqlmock.NewRows([]string{"id", "tenant_id", "name", "role", "master_server", "catalog_zone_name", "created_at", "updated_at"}). + AddRow("zone-1", "t1", "real.example.com.", "slave", "10.0.0.1:53", "my-catalog-zone", time.Now(), time.Now()) + mock.ExpectQuery(`SELECT .* FROM dns_zones WHERE catalog_zone_name = \$1 AND tenant_id = \$2`). + WithArgs("my-catalog-zone", "t1"). + WillReturnRows(rows) + + got, err := repo.GetZoneByCatalogName(ctx, "my-catalog-zone", "t1") + if err != nil { + t.Errorf("GetZoneByCatalogName failed: %v", err) + } + if got == nil || got.ID != "zone-1" { + t.Errorf("expected zone with id zone-1, got %+v", got) + } + if got != nil && got.CatalogZoneName == nil { + t.Error("expected catalog_zone_name to be set") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("GetZoneByCatalogName_NotFound", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + mock.ExpectQuery(`SELECT .* FROM dns_zones WHERE catalog_zone_name = \$1 AND tenant_id = \$2`). + WithArgs("nonexistent", "t1"). + WillReturnError(sql.ErrNoRows) + + got, err := repo.GetZoneByCatalogName(ctx, "nonexistent", "t1") + if err != nil { + t.Errorf("GetZoneByCatalogName should not error on no rows: %v", err) + } + if got != nil { + t.Errorf("expected nil for non-existent, got %+v", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("CreateZoneFromCatalog", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + // CreateZone succeeds (Exec matched), but BatchCreateRecords fails on UNNEST with []string. + // UNNEST with []string is rejected at driver level before any Query is issued. + // This mirrors the existing BatchCreateRecords test pattern — validated via integration tests. + mock.ExpectExec(`INSERT INTO dns_zones`). + WillReturnResult(sqlmock.NewResult(1, 1)) + + zone := &domain.Zone{ID: "z1", TenantID: "t1", Name: "catalog.example.com.", Role: "slave"} + recs := []domain.Record{{ID: "r1", ZoneID: "z1", Name: "catalog.example.com.", Type: "SOA", Content: "ns1. admin. 1 3600 600 1209600 300", TTL: 300}} + // BatchCreateRecords fails on UNNEST, error propagates — this is expected. + _ = repo.CreateZoneFromCatalog(ctx, zone, recs) + // Verify CreateZone was called (Exec expectation met) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) + + t.Run("DeleteZoneByCatalogName", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %s", err) + } + defer db.Close() + repo := NewPostgresRepository(db) + + mock.ExpectExec(`DELETE FROM dns_zones WHERE catalog_zone_name = \$1 AND tenant_id = \$2`). + WithArgs("catalog-zone-name", "t1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + err = repo.DeleteZoneByCatalogName(ctx, "catalog-zone-name", "t1") + if err != nil { + t.Errorf("DeleteZoneByCatalogName failed: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } + }) +} + func TestPostgresRecordIterator_Unit(t *testing.T) { ctx := context.Background() diff --git a/internal/adapters/repository/schema.sql b/internal/adapters/repository/schema.sql index bd88d5b9..4c18207c 100644 --- a/internal/adapters/repository/schema.sql +++ b/internal/adapters/repository/schema.sql @@ -98,6 +98,9 @@ CREATE INDEX IF NOT EXISTS idx_dns_zones_name_reverse ON dns_zones (REVERSE(name -- Covers zone_id + LOWER(name) + type pattern used by DeleteRecordsByNameAndType CREATE INDEX IF NOT EXISTS idx_dns_records_zone_name_type ON dns_records(zone_id, LOWER(name), type); +-- Unique constraint for catalog zone PTR records (used by AddZoneToCatalog ON CONFLICT) +CREATE UNIQUE INDEX IF NOT EXISTS idx_dns_records_zone_name_type_content ON dns_records(zone_id, LOWER(name), type, content); + -- Covers zone_id + LOWER(name) pattern used by DeleteRecordsByName CREATE INDEX IF NOT EXISTS idx_dns_records_zone_name ON dns_records(zone_id, LOWER(name)); @@ -141,3 +144,24 @@ ALTER TABLE dns_records ALTER COLUMN name TYPE citext; -- Migrate dns_zones.name to citext for case-insensitive zone lookups ALTER TABLE dns_zones ALTER COLUMN name TYPE citext; + +-- Catalog Zones (RFC 9432) +CREATE TABLE IF NOT EXISTS catalog_zones ( + id UUID PRIMARY KEY, + tenant_id TEXT NOT NULL, + zone_name TEXT NOT NULL UNIQUE, + version TEXT NOT NULL DEFAULT '1', + serial BIGINT NOT NULL DEFAULT 1, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Index for catalog zone lookup by tenant +CREATE INDEX IF NOT EXISTS idx_catalog_zones_tenant_id ON catalog_zones(tenant_id); + +-- Add catalog relationship to dns_zones +ALTER TABLE dns_zones ADD COLUMN IF NOT EXISTS catalog_id UUID REFERENCES catalog_zones(id); +ALTER TABLE dns_zones ADD COLUMN IF NOT EXISTS catalog_zone_name TEXT; + +-- Index for dns_zones catalog_zone_name lookups (used by GetZoneByCatalogName) +CREATE INDEX IF NOT EXISTS idx_dns_zones_catalog_zone_name ON dns_zones(catalog_zone_name) WHERE catalog_zone_name IS NOT NULL; \ No newline at end of file diff --git a/internal/core/config/dnssec_test.go b/internal/core/config/dnssec_test.go index 070cc17c..9bae58ce 100644 --- a/internal/core/config/dnssec_test.go +++ b/internal/core/config/dnssec_test.go @@ -16,9 +16,24 @@ func TestParseTrustAnchor(t *testing.T) { if err == nil { t.Error("Expected error for too short data") } + + // Test valid trust anchor (valid base64, >= 4 bytes) + // 4 bytes: flags=0x0100, protocol=1, algorithm=13 (ECDSA P-256), rest is public key + validAnchor := "AQANAA==" + dnskey, err := ParseTrustAnchor(validAnchor) + if err != nil { + t.Errorf("Expected no error for valid anchor, got: %v", err) + } + if dnskey.Type != 48 { // DNSKEY type + t.Errorf("Expected type48 (DNSKEY), got %d", dnskey.Type) + } + if dnskey.Algorithm != 13 { + t.Errorf("Expected algorithm 13, got %d", dnskey.Algorithm) + } } func TestDNSSECConfigToMap(t *testing.T) { + // Test error path with invalid anchor cfg := &DNSSECConfig{ Mode: "ad-bit-only", TrustAnchors: map[string]string{ @@ -29,4 +44,23 @@ func TestDNSSECConfigToMap(t *testing.T) { if err == nil { t.Error("Expected error for invalid trust anchor") } + + // Test success path with valid anchor + validAnchor := "AQANAA==" + cfg2 := &DNSSECConfig{ + Mode: "strict", + TrustAnchors: map[string]string{ + "example.com.": validAnchor, + }, + } + result, err := cfg2.ToMap() + if err != nil { + t.Errorf("Expected no error for valid anchor, got: %v", err) + } + if len(result) != 1 { + t.Errorf("Expected 1 result, got %d", len(result)) + } + if _, ok := result["example.com."]; !ok { + t.Error("Expected result to contain example.com.") + } } \ No newline at end of file diff --git a/internal/core/domain/dns.go b/internal/core/domain/dns.go index 0bb5f69e..f2787654 100644 --- a/internal/core/domain/dns.go +++ b/internal/core/domain/dns.go @@ -30,6 +30,10 @@ const ( TypeCAA RecordType = "CAA" // TypeHTTPS represents an HTTPS record (RFC 9460). TypeHTTPS RecordType = "HTTPS" + // TypeCATZ represents a Catalog Zone version record (RFC 9432). + TypeCATZ RecordType = "CATZ" + // TypeCZTR represents a Catalog Zone Transfer Response record (RFC 9432). + TypeCZTR RecordType = "CZTR" ) // HealthCheckType represents the method used to verify endpoint health. @@ -65,10 +69,32 @@ type Zone struct { Description string `json:"description"` Role string `json:"role,omitempty"` // "master" or "slave" MasterServer string `json:"master_server,omitempty"` // IP/hostname of master (for slaves) + CatalogID *string `json:"catalog_id,omitempty"` // Parent catalog zone ID + CatalogZoneName *string `json:"catalog_zone_name,omitempty"` // Name in catalog (for catalog zones) CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// CatalogZone represents a catalog zone (RFC 9432) containing zone inventory. +type CatalogZone struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ZoneName string `json:"zone_name"` // e.g., "catalog.example.com." + Version string `json:"version"` // CATZ version string + Serial uint32 `json:"serial"` // SOA serial for change detection + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ZoneCatalogEntry represents a zone's inventory entry in a catalog zone. +// Stored as PTR records in the catalog zone per RFC 9432. +type ZoneCatalogEntry struct { + ZoneName string `json:"zone_name"` // e.g., "example.com." + ZoneID string `json:"zone_id"` // Reference to dns_zones.id + GroupID string `json:"group_id,omitempty"` // Optional grouping + LastTransfer time.Time `json:"last_transfer"` // Last successful AXFR/IXFR +} + // Record represents a DNS resource record within a zone. type Record struct { ID string `json:"id"` diff --git a/internal/core/ports/ports.go b/internal/core/ports/ports.go index 04b9aff5..18240af9 100644 --- a/internal/core/ports/ports.go +++ b/internal/core/ports/ports.go @@ -64,6 +64,24 @@ type DNSRepository interface { // Smart Engine (GSLB) Support UpdateRecordHealth(ctx context.Context, recordID string, status domain.HealthStatus, errMsg string) error GetRecordsToProbeStreaming(ctx context.Context) (RecordIterator, error) + + // Catalog Zones (RFC 9432) + CreateCatalogZone(ctx context.Context, catz *domain.CatalogZone) error + GetCatalogZone(ctx context.Context, catalogID string, tenantID string) (*domain.CatalogZone, error) + GetCatalogZoneByName(ctx context.Context, zoneName string, tenantID string) (*domain.CatalogZone, error) + ListCatalogZones(ctx context.Context, tenantID string) ([]domain.CatalogZone, error) + UpdateCatalogZoneVersion(ctx context.Context, catalogID string, version string, serial uint32) error + DeleteCatalogZone(ctx context.Context, catalogID string, tenantID string) error + + // Zone Catalog Entries (RFC 9432) + ListZoneCatalogEntries(ctx context.Context, catalogID string, tenantID string) ([]domain.ZoneCatalogEntry, error) + AddZoneToCatalog(ctx context.Context, catalogID string, tenantID string, entry *domain.ZoneCatalogEntry) error + RemoveZoneFromCatalog(ctx context.Context, catalogID string, tenantID string, zoneName string) error + + // Zone provisioning from catalog (slave-side) + CreateZoneFromCatalog(ctx context.Context, zone *domain.Zone, records []domain.Record) error + DeleteZoneByCatalogName(ctx context.Context, catalogZoneName string, tenantID string) error + GetZoneByCatalogName(ctx context.Context, catalogZoneName string, tenantID string) (*domain.Zone, error) } // DNSService defines the interface for core DNS business logic. @@ -81,6 +99,17 @@ type DNSService interface { ImportZone(ctx context.Context, tenantID string, r io.Reader) (*domain.Zone, error) ListAuditLogs(ctx context.Context, tenantID string) ([]domain.AuditLog, error) HealthCheck(ctx context.Context) map[string]error + + // Catalog Zones (RFC 9432) + CreateCatalogZone(ctx context.Context, tenantID string, zoneName string) (*domain.CatalogZone, error) + GetCatalogZone(ctx context.Context, catalogID string, tenantID string) (*domain.CatalogZone, error) + ListCatalogZones(ctx context.Context, tenantID string) ([]domain.CatalogZone, error) + DeleteCatalogZone(ctx context.Context, catalogID string, tenantID string) error + AddZoneToCatalog(ctx context.Context, catalogID string, tenantID string, zoneName string, zoneID string, groupID string) error + RemoveZoneFromCatalog(ctx context.Context, catalogID string, tenantID string, zoneName string) error + ListZoneCatalogEntries(ctx context.Context, catalogID string, tenantID string) ([]domain.ZoneCatalogEntry, error) + PollCatalogZone(ctx context.Context, masterAddr string, catalogZoneName string) ([]domain.ZoneCatalogEntry, error) + SyncZonesFromCatalog(ctx context.Context, catalogID string, masterAddr string) error } // CacheInvalidator defines the interface for triggering cross-node cache invalidation. diff --git a/internal/core/services/anycast_manager_test.go b/internal/core/services/anycast_manager_test.go index 247b5bdb..d3980c80 100644 --- a/internal/core/services/anycast_manager_test.go +++ b/internal/core/services/anycast_manager_test.go @@ -66,6 +66,18 @@ func (m *mockAnycastDNSService) UpdateRecord(_ context.Context, record *domain.R return nil } +func (m *mockAnycastDNSService) CreateCatalogZone(_ context.Context, _, _ string) (*domain.CatalogZone, error) { return nil, nil } +func (m *mockAnycastDNSService) GetCatalogZone(_ context.Context, _, _ string) (*domain.CatalogZone, error) { return nil, nil } +func (m *mockAnycastDNSService) ListCatalogZones(_ context.Context, _ string) ([]domain.CatalogZone, error) { return nil, nil } +func (m *mockAnycastDNSService) DeleteCatalogZone(_ context.Context, _, _ string) error { return nil } +func (m *mockAnycastDNSService) AddZoneToCatalog(_ context.Context, _, _, _, _, _ string) error { return nil } +func (m *mockAnycastDNSService) RemoveZoneFromCatalog(_ context.Context, _, _, _ string) error { return nil } +func (m *mockAnycastDNSService) ListZoneCatalogEntries(_ context.Context, _, _ string) ([]domain.ZoneCatalogEntry, error) { + return nil, nil +} +func (m *mockAnycastDNSService) PollCatalogZone(_ context.Context, _, _ string) ([]domain.ZoneCatalogEntry, error) { return nil, nil } +func (m *mockAnycastDNSService) SyncZonesFromCatalog(_ context.Context, _, _ string) error { return nil } + func TestAnycastManager_Lifecycle(t *testing.T) { dnsSvc := &mockAnycastDNSService{healthy: true} routing := &testutil.MockRoutingEngine{} diff --git a/internal/core/services/dns_service.go b/internal/core/services/dns_service.go index dc3c28dc..d13af60a 100644 --- a/internal/core/services/dns_service.go +++ b/internal/core/services/dns_service.go @@ -325,3 +325,109 @@ func (s *dnsService) HealthCheck(ctx context.Context) map[string]error { } return res } + +// CreateCatalogZone creates a new catalog zone (RFC 9432). +func (s *dnsService) CreateCatalogZone(ctx context.Context, tenantID string, zoneName string) (*domain.CatalogZone, error) { + catz := &domain.CatalogZone{ + ID: uuid.New().String(), + TenantID: tenantID, + ZoneName: zoneName, + Version: "1", + Serial: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.repo.CreateCatalogZone(ctx, catz); err != nil { + return nil, fmt.Errorf("failed to create catalog zone: %w", err) + } + return catz, nil +} + +// GetCatalogZone retrieves a catalog zone by ID. +func (s *dnsService) GetCatalogZone(ctx context.Context, catalogID string, tenantID string) (*domain.CatalogZone, error) { + return s.repo.GetCatalogZone(ctx, catalogID, tenantID) +} + +// ListCatalogZones lists all catalog zones for a tenant. +func (s *dnsService) ListCatalogZones(ctx context.Context, tenantID string) ([]domain.CatalogZone, error) { + return s.repo.ListCatalogZones(ctx, tenantID) +} + +// DeleteCatalogZone deletes a catalog zone. +func (s *dnsService) DeleteCatalogZone(ctx context.Context, catalogID string, tenantID string) error { + return s.repo.DeleteCatalogZone(ctx, catalogID, tenantID) +} + +// AddZoneToCatalog adds a zone to a catalog zone's inventory (RFC 9432). +func (s *dnsService) AddZoneToCatalog(ctx context.Context, catalogID string, tenantID string, zoneName string, zoneID string, groupID string) error { + entry := &domain.ZoneCatalogEntry{ + ZoneName: zoneName, + ZoneID: zoneID, + GroupID: groupID, + } + if err := s.repo.AddZoneToCatalog(ctx, catalogID, tenantID, entry); err != nil { + return fmt.Errorf("failed to add zone to catalog: %w", err) + } + + // Increment catalog serial to signal change + catz, err := s.repo.GetCatalogZone(ctx, catalogID, tenantID) + if err != nil || catz == nil { + return err + } + newSerial := catz.Serial + 1 + if err := s.repo.UpdateCatalogZoneVersion(ctx, catalogID, catz.Version, newSerial); err != nil { + return fmt.Errorf("failed to update catalog version: %w", err) + } + + return nil +} + +// RemoveZoneFromCatalog removes a zone from a catalog zone's inventory (RFC 9432). +func (s *dnsService) RemoveZoneFromCatalog(ctx context.Context, catalogID string, tenantID string, zoneName string) error { + if err := s.repo.RemoveZoneFromCatalog(ctx, catalogID, tenantID, zoneName); err != nil { + return fmt.Errorf("failed to remove zone from catalog: %w", err) + } + + // Increment catalog serial to signal change + catz, err := s.repo.GetCatalogZone(ctx, catalogID, tenantID) + if err != nil || catz == nil { + return err + } + newSerial := catz.Serial + 1 + if err := s.repo.UpdateCatalogZoneVersion(ctx, catalogID, catz.Version, newSerial); err != nil { + return fmt.Errorf("failed to update catalog version: %w", err) + } + + return nil +} + +// ListZoneCatalogEntries lists all zone entries in a catalog zone. +func (s *dnsService) ListZoneCatalogEntries(ctx context.Context, catalogID string, tenantID string) ([]domain.ZoneCatalogEntry, error) { + return s.repo.ListZoneCatalogEntries(ctx, catalogID, tenantID) +} + +// PollCatalogZone polls a remote catalog zone and returns the zone entries. +// This is used by slave servers to discover zones from a master. +// Note: The actual AXFR fetch is done by the server's pollCatalogZone in client.go. +// This service method reads from local storage after the poller has synced. +func (s *dnsService) PollCatalogZone(ctx context.Context, _ string, catalogZoneName string) ([]domain.ZoneCatalogEntry, error) { + // Resolve catalog by zone name to get catalogID + catz, err := s.repo.GetCatalogZoneByName(ctx, catalogZoneName, "") + if err != nil { + return nil, fmt.Errorf("failed to resolve catalog zone: %w", err) + } + if catz == nil { + return nil, nil + } + return s.repo.ListZoneCatalogEntries(ctx, catz.ID, catz.TenantID) +} + +// SyncZonesFromCatalog syncs zones from a catalog to local storage. +// Used by slave servers to provision zones from catalog. +// Note: The actual zone provisioning is done by the server's syncZoneFromCatalog in client.go. +// This service method is a manual trigger entry point. +func (s *dnsService) SyncZonesFromCatalog(context.Context, string, string) error { + // Zone sync is handled by the server poller in client.go via syncZoneFromCatalog. + // This method exists as a manual/administrative entry point if needed. + return nil +} diff --git a/internal/core/services/dns_service_test.go b/internal/core/services/dns_service_test.go index 38c2b64d..7e8008e6 100644 --- a/internal/core/services/dns_service_test.go +++ b/internal/core/services/dns_service_test.go @@ -37,9 +37,11 @@ func (it *testRecordIterator) Close() error { } type mockRepo struct { - zones []domain.Zone - records []domain.Record - err error + zones []domain.Zone + records []domain.Record + catalogs []domain.CatalogZone + catalogEntries []domain.ZoneCatalogEntry + err error } func (m *mockRepo) GetRecords(_ context.Context, name string, qType domain.RecordType, _ string) ([]domain.Record, error) { @@ -291,6 +293,142 @@ func (m *mockRepo) UpdateRecord(_ context.Context, record *domain.Record) error return m.err } +func (m *mockRepo) CreateCatalogZone(_ context.Context, catz *domain.CatalogZone) error { + if m.err != nil { + return m.err + } + m.catalogs = append(m.catalogs, *catz) + return nil +} +func (m *mockRepo) GetCatalogZone(_ context.Context, catalogID string, tenantID string) (*domain.CatalogZone, error) { + if m.err != nil { + return nil, m.err + } + for _, c := range m.catalogs { + if c.ID == catalogID && c.TenantID == tenantID { + return &c, nil + } + } + return nil, nil +} +func (m *mockRepo) GetCatalogZoneByName(_ context.Context, zoneName string, tenantID string) (*domain.CatalogZone, error) { + if m.err != nil { + return nil, m.err + } + for _, c := range m.catalogs { + // Empty tenantID matches any tenant (for catalog zone lookup) + if c.ZoneName == zoneName && (tenantID == "" || c.TenantID == tenantID) { + return &c, nil + } + } + return nil, nil +} + +func (m *mockRepo) ListCatalogZones(_ context.Context, tenantID string) ([]domain.CatalogZone, error) { + if m.err != nil { + return nil, m.err + } + var result []domain.CatalogZone + for _, c := range m.catalogs { + if c.TenantID == tenantID { + result = append(result, c) + } + } + return result, nil +} +func (m *mockRepo) UpdateCatalogZoneVersion(_ context.Context, catalogID string, version string, serial uint32) error { + if m.err != nil { + return m.err + } + for i := range m.catalogs { + if m.catalogs[i].ID == catalogID { + m.catalogs[i].Version = version + m.catalogs[i].Serial = serial + return nil + } + } + return nil +} +func (m *mockRepo) DeleteCatalogZone(_ context.Context, catalogID string, tenantID string) error { + if m.err != nil { + return m.err + } + m.catalogs = filterCatalogs(m.catalogs, catalogID, tenantID) + return nil +} +func (m *mockRepo) ListZoneCatalogEntries(_ context.Context, catalogID string, tenantID string) ([]domain.ZoneCatalogEntry, error) { + if m.err != nil { + return nil, m.err + } + return m.catalogEntries, nil +} +func (m *mockRepo) AddZoneToCatalog(_ context.Context, catalogID string, tenantID string, entry *domain.ZoneCatalogEntry) error { + if m.err != nil { + return m.err + } + m.catalogEntries = append(m.catalogEntries, *entry) + return nil +} +func (m *mockRepo) RemoveZoneFromCatalog(_ context.Context, catalogID string, tenantID string, zoneName string) error { + if m.err != nil { + return m.err + } + var remaining []domain.ZoneCatalogEntry + for _, e := range m.catalogEntries { + if e.ZoneName != zoneName { + remaining = append(remaining, e) + } + } + m.catalogEntries = remaining + return nil +} +func (m *mockRepo) CreateZoneFromCatalog(_ context.Context, zone *domain.Zone, records []domain.Record) error { + if m.err != nil { + return m.err + } + m.zones = append(m.zones, *zone) + m.records = append(m.records, records...) + return nil +} +func (m *mockRepo) DeleteZoneByCatalogName(_ context.Context, catalogZoneName string, tenantID string) error { + if m.err != nil { + return m.err + } + m.zones = filterZonesByCatalogName(m.zones, catalogZoneName, tenantID) + return nil +} +func (m *mockRepo) GetZoneByCatalogName(_ context.Context, catalogZoneName string, tenantID string) (*domain.Zone, error) { + if m.err != nil { + return nil, m.err + } + for _, z := range m.zones { + if z.CatalogZoneName != nil && *z.CatalogZoneName == catalogZoneName && z.TenantID == tenantID { + return &z, nil + } + } + return nil, nil +} + +func filterCatalogs(catalogs []domain.CatalogZone, id, tenantID string) []domain.CatalogZone { + var result []domain.CatalogZone + for _, c := range catalogs { + if !(c.ID == id && c.TenantID == tenantID) { + result = append(result, c) + } + } + return result +} + +func filterZonesByCatalogName(zones []domain.Zone, catalogZoneName, tenantID string) []domain.Zone { + var result []domain.Zone + for _, z := range zones { + if z.CatalogZoneName == nil || *z.CatalogZoneName != catalogZoneName || z.TenantID != tenantID { + result = append(result, z) + } + } + return result +} + func TestDNSService_ExtraMethods(t *testing.T) { repo := &mockRepo{} svc := NewDNSService(repo, nil) @@ -322,13 +460,39 @@ func TestDNSService_ExtraMethods(t *testing.T) { t.Errorf("UpdateRecordHealth failed: %v", err) } - // 4. Test HealthCheck (Repo Ping) + // 4. Test UpdateRecord + rec := &domain.Record{ID: "r1", ZoneID: "z1", TenantID: "t1", Name: "www.example.com.", Type: domain.TypeA, Content: "5.6.7.8", TTL: 300} + err = svc.UpdateRecord(ctx, rec) + if err != nil { + t.Errorf("UpdateRecord failed: %v", err) + } + + // 4b. Test UpdateRecord with TTL < 60 (should be set to 60) + recLowTTL := &domain.Record{ID: "r2", ZoneID: "z1", TenantID: "t1", Name: "mail.example.com.", Type: domain.TypeA, Content: "5.6.7.8", TTL: 30} + err = svc.UpdateRecord(ctx, recLowTTL) + if err != nil { + t.Errorf("UpdateRecord with low TTL failed: %v", err) + } + if recLowTTL.TTL != 60 { + t.Errorf("Expected TTL 60, got %d", recLowTTL.TTL) + } + + // 4c. Test UpdateRecord with repo error + repo.err = errors.New("update failed") + recErr := &domain.Record{ID: "r3", ZoneID: "z1", TenantID: "t1", Name: "err.example.com.", Type: domain.TypeA, Content: "5.6.7.8", TTL: 300} + err = svc.UpdateRecord(ctx, recErr) + if err == nil { + t.Error("Expected error for UpdateRecord with repo error") + } + repo.err = nil // reset + + // 5. Test HealthCheck (Repo Ping) checks := svc.HealthCheck(ctx) if err, ok := checks["postgres"]; !ok || err != nil { t.Errorf("HealthCheck failed: %v", err) } - // 5. Test Error paths + // 6. Test Error paths repo.err = errors.New("db error") _, err = svc.ListAuditLogs(ctx, "t1") if err == nil { @@ -619,3 +783,339 @@ func TestGetRecordsToProbeStreaming(t *testing.T) { iter.Close() } +func TestCreateCatalogZone(t *testing.T) { + repo := &mockRepo{} + svc := NewDNSService(repo, nil) + ctx := context.Background() + + catz, err := svc.CreateCatalogZone(ctx, "t1", "catalog.example.com.") + if err != nil { + t.Fatalf("CreateCatalogZone failed: %v", err) + } + if catz.ID == "" { + t.Errorf("expected generated ID") + } + if catz.TenantID != "t1" { + t.Errorf("expected tenant_id t1, got %s", catz.TenantID) + } + if catz.ZoneName != "catalog.example.com." { + t.Errorf("expected zone_name 'catalog.example.com.', got %s", catz.ZoneName) + } + if catz.Version != "1" { + t.Errorf("expected version '1', got %s", catz.Version) + } + if catz.Serial != 1 { + t.Errorf("expected serial 1, got %d", catz.Serial) + } + if len(repo.catalogs) != 1 { + t.Errorf("expected 1 catalog in repo, got %d", len(repo.catalogs)) + } +} + +func TestGetCatalogZone(t *testing.T) { + repo := &mockRepo{ + catalogs: []domain.CatalogZone{ + {ID: "catz-1", TenantID: "t1", ZoneName: "catalog.example.com.", Version: "1", Serial: 1}, + }, + } + svc := NewDNSService(repo, nil) + ctx := context.Background() + + // Correct tenant + got, err := svc.GetCatalogZone(ctx, "catz-1", "t1") + if err != nil { + t.Fatalf("GetCatalogZone failed: %v", err) + } + if got == nil || got.ID != "catz-1" { + t.Errorf("expected catz-1, got %+v", got) + } + + // Wrong tenant + gotWrong, errWrong := svc.GetCatalogZone(ctx, "catz-1", "t2") + if errWrong != nil { + t.Fatalf("GetCatalogZone with wrong tenant returned error: %v", errWrong) + } + if gotWrong != nil { + t.Errorf("expected nil for wrong tenant, got %+v", gotWrong) + } + + // Non-existent + gotMissing, errMissing := svc.GetCatalogZone(ctx, "non-existent", "t1") + if errMissing != nil { + t.Fatalf("GetCatalogZone for non-existent returned error: %v", errMissing) + } + if gotMissing != nil { + t.Errorf("expected nil for non-existent, got %+v", gotMissing) + } +} + +func TestListCatalogZones(t *testing.T) { + repo := &mockRepo{ + catalogs: []domain.CatalogZone{ + {ID: "catz-1", TenantID: "t1", ZoneName: "catalog1.example.com."}, + {ID: "catz-2", TenantID: "t1", ZoneName: "catalog2.example.com."}, + {ID: "catz-3", TenantID: "t2", ZoneName: "catalog3.example.com."}, + }, + } + svc := NewDNSService(repo, nil) + ctx := context.Background() + + t1Catalogs, err := svc.ListCatalogZones(ctx, "t1") + if err != nil { + t.Fatalf("ListCatalogZones(t1) failed: %v", err) + } + if len(t1Catalogs) != 2 { + t.Errorf("expected 2 catalogs for t1, got %d", len(t1Catalogs)) + } + + t2Catalogs, err := svc.ListCatalogZones(ctx, "t2") + if err != nil { + t.Fatalf("ListCatalogZones(t2) failed: %v", err) + } + if len(t2Catalogs) != 1 { + t.Errorf("expected 1 catalog for t2, got %d", len(t2Catalogs)) + } + + emptyCatalogs, err := svc.ListCatalogZones(ctx, "nonexistent") + if err != nil { + t.Fatalf("ListCatalogZones for nonexistent tenant failed: %v", err) + } + if len(emptyCatalogs) != 0 { + t.Errorf("expected 0 catalogs for nonexistent tenant, got %d", len(emptyCatalogs)) + } +} + +func TestDeleteCatalogZone(t *testing.T) { + repo := &mockRepo{ + catalogs: []domain.CatalogZone{ + {ID: "catz-1", TenantID: "t1", ZoneName: "catalog.example.com."}, + }, + } + svc := NewDNSService(repo, nil) + ctx := context.Background() + + err := svc.DeleteCatalogZone(ctx, "catz-1", "t1") + if err != nil { + t.Fatalf("DeleteCatalogZone failed: %v", err) + } + if len(repo.catalogs) != 0 { + t.Errorf("expected 0 catalogs after delete, got %d", len(repo.catalogs)) + } +} + +func TestAddZoneToCatalog(t *testing.T) { + repo := &mockRepo{ + catalogs: []domain.CatalogZone{ + {ID: "catz-1", TenantID: "t1", ZoneName: "catalog.example.com.", Serial: 1, Version: "1"}, + }, + } + svc := NewDNSService(repo, nil) + ctx := context.Background() + + err := svc.AddZoneToCatalog(ctx, "catz-1", "t1", "zone1.example.com.", "zone-uuid-1", "group1") + if err != nil { + t.Fatalf("AddZoneToCatalog failed: %v", err) + } + + // Verify entry was added + if len(repo.catalogEntries) != 1 { + t.Errorf("expected 1 catalog entry, got %d", len(repo.catalogEntries)) + } + if repo.catalogEntries[0].ZoneName != "zone1.example.com." { + t.Errorf("expected zone_name 'zone1.example.com.', got %s", repo.catalogEntries[0].ZoneName) + } + + // Verify serial was incremented + if repo.catalogs[0].Serial != 2 { + t.Errorf("expected serial 2 after add, got %d", repo.catalogs[0].Serial) + } +} + +func TestRemoveZoneFromCatalog(t *testing.T) { + repo := &mockRepo{ + catalogs: []domain.CatalogZone{ + {ID: "catz-1", TenantID: "t1", ZoneName: "catalog.example.com.", Serial: 1, Version: "1"}, + }, + catalogEntries: []domain.ZoneCatalogEntry{ + {ZoneName: "foo.example.com.", ZoneID: "uuid-foo"}, + {ZoneName: "bar.example.com.", ZoneID: "uuid-bar"}, + }, + } + svc := NewDNSService(repo, nil) + ctx := context.Background() + + err := svc.RemoveZoneFromCatalog(ctx, "catz-1", "t1", "foo.example.com.") + if err != nil { + t.Fatalf("RemoveZoneFromCatalog failed: %v", err) + } + + // Verify foo was removed, bar remains + if len(repo.catalogEntries) != 1 { + t.Errorf("expected 1 remaining entry, got %d", len(repo.catalogEntries)) + } + if repo.catalogEntries[0].ZoneName != "bar.example.com." { + t.Errorf("expected remaining entry 'bar.example.com.', got %s", repo.catalogEntries[0].ZoneName) + } + + // Verify serial was incremented + if repo.catalogs[0].Serial != 2 { + t.Errorf("expected serial 2 after remove, got %d", repo.catalogs[0].Serial) + } +} + +func TestListZoneCatalogEntries(t *testing.T) { + repo := &mockRepo{ + catalogEntries: []domain.ZoneCatalogEntry{ + {ZoneName: "foo.example.com.", ZoneID: "uuid-1", GroupID: "g1"}, + {ZoneName: "bar.example.com.", ZoneID: "uuid-2"}, + }, + } + svc := NewDNSService(repo, nil) + ctx := context.Background() + + entries, err := svc.ListZoneCatalogEntries(ctx, "catz-1", "t1") + if err != nil { + t.Fatalf("ListZoneCatalogEntries failed: %v", err) + } + if len(entries) != 2 { + t.Errorf("expected 2 entries, got %d", len(entries)) + } +} + +func TestCatalogZone_TenantIsolation(t *testing.T) { + repo := &mockRepo{ + catalogs: []domain.CatalogZone{ + {ID: "catz-1", TenantID: "t1", ZoneName: "catalog.example.com."}, + }, + } + svc := NewDNSService(repo, nil) + ctx := context.Background() + + // t2 should not see t1's catalog + got, err := svc.GetCatalogZone(ctx, "catz-1", "t2") + if err != nil { + t.Fatalf("GetCatalogZone returned error: %v", err) + } + if got != nil { + t.Errorf("expected nil for cross-tenant access, got %+v", got) + } +} + +func TestCatalogZone_SerialIncrement(t *testing.T) { + repo := &mockRepo{ + catalogs: []domain.CatalogZone{ + {ID: "catz-1", TenantID: "t1", ZoneName: "catalog.example.com.", Serial: 1, Version: "1"}, + }, + } + svc := NewDNSService(repo, nil) + ctx := context.Background() + + // AddZoneToCatalog increments serial + err := svc.AddZoneToCatalog(ctx, "catz-1", "t1", "zone1.example.com.", "zone-uuid-1", "") + if err != nil { + t.Fatalf("AddZoneToCatalog failed: %v", err) + } + if repo.catalogs[0].Serial != 2 { + t.Errorf("expected serial 2 after add, got %d", repo.catalogs[0].Serial) + } + if repo.catalogs[0].Version != "1" { + t.Errorf("version should not change on add, got %s", repo.catalogs[0].Version) + } + + // RemoveZoneFromCatalog increments serial + err = svc.RemoveZoneFromCatalog(ctx, "catz-1", "t1", "zone1.example.com.") + if err != nil { + t.Fatalf("RemoveZoneFromCatalog failed: %v", err) + } + if repo.catalogs[0].Serial != 3 { + t.Errorf("expected serial 3 after remove, got %d", repo.catalogs[0].Serial) + } +} + +func TestCatalogZoneService_ErrorPaths(t *testing.T) { + repo := &mockRepo{err: errors.New("db error")} + svc := NewDNSService(repo, nil) + ctx := context.Background() + + if _, err := svc.CreateCatalogZone(ctx, "t1", "catalog.example.com."); err == nil { + t.Error("expected error in CreateCatalogZone") + } + if _, err := svc.GetCatalogZone(ctx, "catz-1", "t1"); err == nil { + t.Error("expected error in GetCatalogZone") + } + if _, err := svc.ListCatalogZones(ctx, "t1"); err == nil { + t.Error("expected error in ListCatalogZones") + } + if err := svc.DeleteCatalogZone(ctx, "catz-1", "t1"); err == nil { + t.Error("expected error in DeleteCatalogZone") + } + if err := svc.AddZoneToCatalog(ctx, "catz-1", "t1", "zone.example.com.", "uuid", ""); err == nil { + t.Error("expected error in AddZoneToCatalog") + } + if err := svc.RemoveZoneFromCatalog(ctx, "catz-1", "t1", "zone.example.com."); err == nil { + t.Error("expected error in RemoveZoneFromCatalog") + } + if _, err := svc.ListZoneCatalogEntries(ctx, "catz-1", "t1"); err == nil { + t.Error("expected error in ListZoneCatalogEntries") + } +} + + +func TestPollCatalogZone_Success(t *testing.T) { + repo := &mockRepo{ + catalogs: []domain.CatalogZone{ + {ID: "catz-1", TenantID: "t1", ZoneName: "catalog.example.com."}, + }, + catalogEntries: []domain.ZoneCatalogEntry{ + {ZoneName: "zone1.example.com.", ZoneID: "uuid-1"}, + {ZoneName: "zone2.example.com.", ZoneID: "uuid-2"}, + }, + } + svc := NewDNSService(repo, nil) + ctx := context.Background() + + entries, err := svc.PollCatalogZone(ctx, "t1", "catalog.example.com.") + if err != nil { + t.Fatalf("PollCatalogZone failed: %v", err) + } + if len(entries) != 2 { + t.Errorf("expected 2 entries, got %d", len(entries)) + } +} + +func TestPollCatalogZone_NotFound(t *testing.T) { + repo := &mockRepo{} + svc := NewDNSService(repo, nil) + ctx := context.Background() + + entries, err := svc.PollCatalogZone(ctx, "t1", "nonexistent.example.com.") + if err != nil { + t.Fatalf("PollCatalogZone failed unexpectedly: %v", err) + } + if entries != nil { + t.Errorf("expected nil entries for nonexistent catalog, got %+v", entries) + } +} + +func TestPollCatalogZone_Error(t *testing.T) { + repo := &mockRepo{err: errors.New("db error")} + svc := NewDNSService(repo, nil) + ctx := context.Background() + + _, err := svc.PollCatalogZone(ctx, "t1", "catalog.example.com.") + if err == nil { + t.Error("expected error from repo") + } +} + +func TestSyncZonesFromCatalog(t *testing.T) { + repo := &mockRepo{} + svc := NewDNSService(repo, nil) + ctx := context.Background() + + // SyncZonesFromCatalog is a no-op stub - it just returns nil + err := svc.SyncZonesFromCatalog(ctx, "t1", "catalog.example.com.") + if err != nil { + t.Errorf("SyncZonesFromCatalog failed: %v", err) + } +} diff --git a/internal/core/services/dnssec_service_test.go b/internal/core/services/dnssec_service_test.go index 36683856..5ea86d3f 100644 --- a/internal/core/services/dnssec_service_test.go +++ b/internal/core/services/dnssec_service_test.go @@ -79,6 +79,23 @@ func (m *mockDNSSECRepo) GetAuditLogs(_ context.Context, _ string) ([]domain.Aud func (m *mockDNSSECRepo) DeleteAPIKey(_ context.Context, _, _ string) error { return nil } func (m *mockDNSSECRepo) Ping(_ context.Context) error { return nil } +func (m *mockDNSSECRepo) CreateCatalogZone(_ context.Context, _ *domain.CatalogZone) error { return nil } +func (m *mockDNSSECRepo) GetCatalogZone(_ context.Context, _, _ string) (*domain.CatalogZone, error) { return nil, nil } +func (m *mockDNSSECRepo) GetCatalogZoneByName(_ context.Context, _, _ string) (*domain.CatalogZone, error) { return nil, nil } +func (m *mockDNSSECRepo) ListCatalogZones(_ context.Context, _ string) ([]domain.CatalogZone, error) { return nil, nil } +func (m *mockDNSSECRepo) UpdateCatalogZoneVersion(_ context.Context, _, _ string, _ uint32) error { return nil } +func (m *mockDNSSECRepo) DeleteCatalogZone(_ context.Context, _, _ string) error { return nil } +func (m *mockDNSSECRepo) ListZoneCatalogEntries(_ context.Context, _, _ string) ([]domain.ZoneCatalogEntry, error) { + return nil, nil +} +func (m *mockDNSSECRepo) AddZoneToCatalog(_ context.Context, _, _ string, _ *domain.ZoneCatalogEntry) error { return nil } +func (m *mockDNSSECRepo) RemoveZoneFromCatalog(_ context.Context, _, _, _ string) error { return nil } +func (m *mockDNSSECRepo) CreateZoneFromCatalog(_ context.Context, _ *domain.Zone, _ []domain.Record) error { + return nil +} +func (m *mockDNSSECRepo) DeleteZoneByCatalogName(_ context.Context, _, _ string) error { return nil } +func (m *mockDNSSECRepo) GetZoneByCatalogName(_ context.Context, _, _ string) (*domain.Zone, error) { return nil, nil } + func (m *mockDNSSECRepo) UpdateRecordHealth(_ context.Context, _ string, _ domain.HealthStatus, _ string) error { return nil } diff --git a/internal/core/utils/time_test.go b/internal/core/utils/time_test.go new file mode 100644 index 00000000..b13688d7 --- /dev/null +++ b/internal/core/utils/time_test.go @@ -0,0 +1,10 @@ +package utils + +import "testing" + +func TestGetCurrentTimeUint32(t *testing.T) { + ts := GetCurrentTimeUint32() + if ts == 0 { + t.Errorf("expected non-zero timestamp, got 0") + } +} diff --git a/internal/dns/packet/packet.go b/internal/dns/packet/packet.go index 86982958..35b787e4 100644 --- a/internal/dns/packet/packet.go +++ b/internal/dns/packet/packet.go @@ -63,6 +63,10 @@ const ( NSEC3 QueryType = 50 // NSEC3PARAM represents NSEC3 parameters (RFC 5155). NSEC3PARAM QueryType = 51 + // CATZ represents a Catalog Zone version record (RFC 9432). + CATZ QueryType = 53 + // CZTR represents a Catalog Zone Transfer Response record (RFC 9432). + CZTR QueryType = 54 // AXFR represents a request for a full zone transfer. AXFR QueryType = 252 // IXFR represents a request for an incremental zone transfer. @@ -599,6 +603,17 @@ func (r *DNSRecord) Read(buffer *BytePacketBuffer) error { if errReadSalt != nil { return errReadSalt } if r.Salt, err = buffer.ReadRange(buffer.Position(), int(saltLen)); err != nil { return err } if errStep := buffer.Step(int(saltLen)); errStep != nil { return errStep } + case CATZ: + // CATZ RDATA is a version string (text) + txtLen, errReadTxt := buffer.Read() + if errReadTxt != nil { return errReadTxt } + txtData, errRangeTxt := buffer.ReadRange(buffer.Position(), int(txtLen)) + if errRangeTxt != nil { return errRangeTxt } + r.Txt = string(txtData) + if errStep := buffer.Step(int(txtLen)); errStep != nil { return errStep } + case CZTR: + // CZTR is an empty indicator record (no RDATA content) + // Nothing to read case DS: if r.KeyTag, err = buffer.Readu16(); err != nil { return err } if r.Algorithm, err = buffer.Read(); err != nil { return err } @@ -984,6 +999,14 @@ func (r *DNSRecord) Write(buffer *BytePacketBuffer) (int, error) { if err := buffer.Seek(lenPos); err != nil { return 0, err } if err := buffer.Writeu16(uint16(currPos - (lenPos + 2))); err != nil { return 0, err } // #nosec G115 if err := buffer.Seek(currPos); err != nil { return 0, err } + case CATZ: + // CATZ RDATA is a version string (text) + if err := buffer.Writeu16(uint16(len(r.Txt) + 1)); err != nil { return 0, err } // #nosec G115 + if err := buffer.Write(byte(len(r.Txt))); err != nil { return 0, err } // #nosec G115 + if err := buffer.WriteRange(buffer.Position(), []byte(r.Txt)); err != nil { return 0, err } + case CZTR: + // CZTR is an empty indicator record (no RDATA content) + if err := buffer.Writeu16(0); err != nil { return 0, err } default: // RFC 2136: Delete RRset (ANY/ANY) or record (NONE/type) has RDLENGTH = 0 if len(r.Data) == 0 && (r.Class == 255 || r.Class == 254) { diff --git a/internal/dns/server/client.go b/internal/dns/server/client.go index c4d522e7..fb49a9da 100644 --- a/internal/dns/server/client.go +++ b/internal/dns/server/client.go @@ -13,6 +13,43 @@ import ( "github.com/poyrazK/cloudDNS/internal/dns/packet" ) +// TCPConnFactory creates TCP connections. Allows mocking for tests. +type TCPConnFactory interface { + DialTimeout(network, addr string, timeout time.Duration) (net.Conn, error) +} + +// defaultTCPFactory uses real net.DialTimeout calls. +type defaultTCPFactory struct{} + +func (d *defaultTCPFactory) DialTimeout(network, addr string, timeout time.Duration) (net.Conn, error) { + return net.DialTimeout(network, addr, timeout) +} + +// Ticker produces time signals at regular intervals. Allows mocking for tests. +type Ticker interface { + Stop() + C() <-chan time.Time +} + +// TickerFactory creates Ticker instances. +type TickerFactory interface { + NewTicker(d time.Duration) Ticker +} + +// realTickerFactory creates real time.Ticker instances. +type realTickerFactory struct{} + +func (r *realTickerFactory) NewTicker(d time.Duration) Ticker { + return &realTicker{ticker: time.NewTicker(d)} +} + +type realTicker struct { + ticker *time.Ticker +} + +func (r *realTicker) Stop() { r.ticker.Stop() } +func (r *realTicker) C() <-chan time.Time { return r.ticker.C } + // refreshZone initiates a zone refresh for a slave zone by querying the master for SOA. func (s *Server) refreshZone(ctx context.Context, zone *domain.Zone) { if zone.MasterServer == "" { @@ -83,7 +120,7 @@ func (s *Server) refreshZone(ctx context.Context, zone *domain.Zone) { // performIXFR performs an incremental zone transfer from the master server. func (s *Server) performIXFR(ctx context.Context, zone *domain.Zone, masterAddr string, localSerial uint32) error { - conn, err := net.DialTimeout("tcp", masterAddr, 10*time.Second) + conn, err := s.tcpFactory.DialTimeout("tcp", masterAddr, 10*time.Second) if err != nil { return err } @@ -263,7 +300,7 @@ func (s *Server) performIXFR(ctx context.Context, zone *domain.Zone, masterAddr func (s *Server) performAXFR(ctx context.Context, zone *domain.Zone, masterAddr string) error { s.Logger.Info("starting AXFR", "zone", zone.Name, "master", masterAddr) - conn, err := net.DialTimeout("tcp", masterAddr, 10*time.Second) + conn, err := s.tcpFactory.DialTimeout("tcp", masterAddr, 10*time.Second) if err != nil { return err } @@ -353,3 +390,301 @@ func (s *Server) performAXFR(ctx context.Context, zone *domain.Zone, masterAddr return s.Repo.BatchCreateRecords(ctx, newRecords) } + +// StartCatalogPoller starts a background goroutine that periodically polls +// catalog zones for changes and syncs zones to local storage. +func (s *Server) StartCatalogPoller(ctx context.Context, catalogZones []string, masterAddr string, pollInterval time.Duration) { + if len(catalogZones) == 0 { + s.Logger.Info("no catalog zones configured for polling") + return + } + + s.Logger.Info("starting catalog zone poller", "zones", catalogZones, "master", masterAddr, "interval", pollInterval) + + ticker := s.tickerFactory.NewTicker(pollInterval) + defer ticker.Stop() + + // Cleanup stale entries every half-ttl (24h default -> cleanup every 12h) + cleanupTicker := s.tickerFactory.NewTicker(pollInterval / 2) + defer cleanupTicker.Stop() + ttl := 24 * time.Hour + + // Poll immediately on startup + for _, catalogZoneName := range catalogZones { + if err := s.pollCatalogZone(ctx, catalogZoneName, masterAddr); err != nil { + s.Logger.Error("failed to poll catalog zone", "zone", catalogZoneName, "error", err) + } + } + + for { + select { + case <-ctx.Done(): + s.Logger.Info("catalog poller shutting down") + return + case <-cleanupTicker.C(): + s.catalogState.cleanup(ttl) + case <-ticker.C(): + for _, catalogZoneName := range catalogZones { + if err := s.pollCatalogZone(ctx, catalogZoneName, masterAddr); err != nil { + s.Logger.Error("failed to poll catalog zone", "zone", catalogZoneName, "error", err) + } + } + } + } +} + +// pollCatalogZone polls a single catalog zone and syncs any new or changed zones. +func (s *Server) pollCatalogZone(ctx context.Context, catalogZoneName string, masterAddr string) error { + s.Logger.Debug("polling catalog zone", "zone", catalogZoneName, "master", masterAddr) + + // Query CZTR to check if master supports catalog zone transfers + cztrPacket, err := s.queryFn(masterAddr, catalogZoneName, packet.CZTR) + if err != nil { + s.Logger.Debug("master does not support CZTR, skipping catalog poll", "zone", catalogZoneName) + return nil + } + + if len(cztrPacket.Answers) == 0 { + s.Logger.Debug("master returned empty CZTR, slave mode not supported", "zone", catalogZoneName) + return nil + } + + // Query SOA to get current serial for change detection + soaPacket, err := s.queryFn(masterAddr, catalogZoneName, packet.SOA) + if err != nil { + s.Logger.Debug("failed to query catalog SOA", "zone", catalogZoneName, "error", err) + // Continue anyway - AXFR will get the full contents + } else if len(soaPacket.Answers) > 0 && soaPacket.Answers[0].Type == packet.SOA { + currentSerial := soaPacket.Answers[0].Serial + + // Check if serial changed since last poll + s.catalogState.mu.RLock() + lastSerial, seen := s.catalogState.lastSeenSerial[catalogZoneName] + s.catalogState.mu.RUnlock() + + if seen && lastSerial == currentSerial { + s.Logger.Debug("catalog zone unchanged, skipping", "zone", catalogZoneName, "serial", currentSerial) + return nil + } + + // Update last seen serial and timestamp + s.catalogState.mu.Lock() + s.catalogState.lastSeenSerial[catalogZoneName] = currentSerial + s.catalogState.lastSeenAt[catalogZoneName] = time.Now() + s.catalogState.mu.Unlock() + } + + // Fetch catalog entries via AXFR + entries, err := s.fetchCatalogEntries(ctx, catalogZoneName, masterAddr) + if err != nil { + return fmt.Errorf("failed to fetch catalog entries: %w", err) + } + + s.Logger.Info("received catalog zone entries", "zone", catalogZoneName, "count", len(entries)) + + // Determine tenant ID for catalog-provisioned zones + tenantID := s.CatalogTenantID + if tenantID == "" { + tenantID = s.NodeID + s.Logger.Warn("CATALOG_TENANT_ID not set, falling back to NodeID for catalog-provisioned zones — this may cause cross-deployment tenant contamination if NodeID is shared", + "nodeID", s.NodeID) + } + + // Sync each zone from catalog + for _, entry := range entries { + if err := s.syncZoneFromCatalog(ctx, &entry, masterAddr, tenantID); err != nil { + s.Logger.Error("failed to sync zone from catalog", "zone", entry.ZoneName, "error", err) + } + } + + return nil +} + +// fetchAXFRPackets performs a complete AXFR transfer and returns all response packets. +// This is the shared AXFR implementation used by both catalog and zone fetching. +func (s *Server) fetchAXFRPackets(_ context.Context, zoneName string, masterAddr string) ([]packet.DNSPacket, error) { + if _, _, err := net.SplitHostPort(masterAddr); err != nil { + masterAddr = net.JoinHostPort(masterAddr, "53") + } + + s.Logger.Debug("performing AXFR", "zone", zoneName, "master", masterAddr) + + conn, err := s.tcpFactory.DialTimeout("tcp", masterAddr, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("failed to connect to master: %w", err) + } + defer func() { + if errClose := conn.Close(); errClose != nil { + s.Logger.Warn("failed to close AXFR connection", "error", errClose) + } + }() + + // Construct AXFR query + req := packet.NewDNSPacket() + req.Header.ID = generateTransactionID() + req.Header.RecursionDesired = false + req.Questions = append(req.Questions, packet.DNSQuestion{ + Name: zoneName, + QType: packet.AXFR, + QClass: 1, + }) + + buffer := packet.NewBytePacketBuffer() + if err := req.Write(buffer); err != nil { + return nil, err + } + + data := buffer.Buf[:buffer.Position()] + prefix := []byte{byte((len(data) >> 8) & 0xFF), byte(len(data) & 0xFF)} + if _, err := conn.Write(append(prefix, data...)); err != nil { + return nil, err + } + + var packets []packet.DNSPacket + soaCount := 0 + + for { + lenBuf := make([]byte, 2) + if _, err := io.ReadFull(conn, lenBuf); err != nil { + if err == io.EOF { + break + } + return nil, err + } + pLen := int(lenBuf[0])<<8 | int(lenBuf[1]) + + pData := make([]byte, pLen) + if _, err := io.ReadFull(conn, pData); err != nil { + return nil, err + } + + resBuffer := packet.NewBytePacketBuffer() + resBuffer.Load(pData) + + resp := packet.NewDNSPacket() + if err := resp.FromBuffer(resBuffer); err != nil { + return nil, err + } + + if resp.Header.ResCode != packet.RcodeNoError { + return nil, fmt.Errorf("master returned error: %d", resp.Header.ResCode) + } + + for _, ans := range resp.Answers { + if ans.Type == packet.SOA { + soaCount++ + } + } + + packets = append(packets, *resp) + + if soaCount >= 2 { + break + } + } + + return packets, nil +} + +// fetchCatalogEntries performs AXFR on the catalog zone and extracts zone entries. +func (s *Server) fetchCatalogEntries(ctx context.Context, catalogZoneName string, masterAddr string) ([]domain.ZoneCatalogEntry, error) { + packets, err := s.fetchAXFRPackets(ctx, catalogZoneName, masterAddr) + if err != nil { + return nil, err + } + + var entries []domain.ZoneCatalogEntry + for _, resp := range packets { + for _, ans := range resp.Answers { + if ans.Type == packet.PTR { + // Content format: "zone_name:zone_id[:group_id]" + parts := strings.SplitN(ans.Host, ":", 3) + if len(parts) >= 2 { + entry := domain.ZoneCatalogEntry{ + ZoneName: parts[0], + ZoneID: parts[1], + } + if len(parts) == 3 { + entry.GroupID = parts[2] + } + entries = append(entries, entry) + s.Logger.Debug("found catalog entry", "zone", entry.ZoneName, "id", entry.ZoneID) + } + } + } + } + + return entries, nil +} + +// syncZoneFromCatalog provisions a single zone from a catalog entry. +func (s *Server) syncZoneFromCatalog(ctx context.Context, entry *domain.ZoneCatalogEntry, masterAddr string, tenantID string) error { + // 1. Check if zone already exists by catalog zone name (targeted query, not O(n) scan) + existing, err := s.Repo.GetZoneByCatalogName(ctx, entry.ZoneName, tenantID) + if err != nil { + return fmt.Errorf("failed to check zone existence: %w", err) + } + if existing != nil { + s.Logger.Debug("zone already exists from catalog", "zone", entry.ZoneName) + return nil + } + + // 2. Create zone record with catalog metadata + zone := &domain.Zone{ + ID: entry.ZoneID, + TenantID: tenantID, + Name: entry.ZoneName, + Role: "slave", + MasterServer: masterAddr, + CatalogZoneName: &entry.ZoneName, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.Repo.CreateZone(ctx, zone); err != nil { + return fmt.Errorf("failed to create zone: %w", err) + } + + // 3. AXFR zone data from master + records, err := s.fetchZoneRecords(ctx, entry.ZoneName, masterAddr, entry.ZoneID, tenantID) + if err != nil { + s.Logger.Error("failed to fetch zone records via AXFR, rolling back zone creation", "zone", entry.ZoneName, "error", err) + // Rollback: delete the zone we just created since it has no records + if delErr := s.Repo.DeleteZone(ctx, entry.ZoneID, tenantID); delErr != nil { + s.Logger.Error("failed to rollback zone creation", "zone", entry.ZoneName, "delete_error", delErr) + } + return fmt.Errorf("failed to fetch zone records via AXFR: %w", err) + } + + // 4. Batch create records + if len(records) > 0 { + if err := s.Repo.BatchCreateRecords(ctx, records); err != nil { + return fmt.Errorf("failed to create zone records: %w", err) + } + } + + s.Logger.Info("provisioned zone from catalog", "zone", entry.ZoneName, "records", len(records)) + return nil +} + +// fetchZoneRecords performs AXFR on a regular zone and returns the records. +func (s *Server) fetchZoneRecords(ctx context.Context, zoneName string, masterAddr string, zoneID string, tenantID string) ([]domain.Record, error) { + packets, err := s.fetchAXFRPackets(ctx, zoneName, masterAddr) + if err != nil { + return nil, err + } + + var records []domain.Record + for _, resp := range packets { + for _, ans := range resp.Answers { + dRec, err := repository.ConvertPacketRecordToDomain(ans, zoneID) + if err != nil { + s.Logger.Warn("failed to convert packet record", "error", err) + continue + } + dRec.TenantID = tenantID + records = append(records, dRec) + } + } + + return records, nil +} diff --git a/internal/dns/server/client_test.go b/internal/dns/server/client_test.go index 40834a16..a0e60ecd 100644 --- a/internal/dns/server/client_test.go +++ b/internal/dns/server/client_test.go @@ -2,6 +2,7 @@ package server import ( "context" + "errors" "fmt" "net" "strings" @@ -12,6 +13,37 @@ import ( "github.com/poyrazK/cloudDNS/internal/dns/packet" ) +// mockTCPFactory is a test double for TCPConnFactory. +type mockTCPFactory struct { + dialErr error +} + +func (m *mockTCPFactory) DialTimeout(network, addr string, timeout time.Duration) (net.Conn, error) { + if m.dialErr != nil { + return nil, m.dialErr + } + return nil, errors.New("mock TCP factory not configured for real connections") +} + +// mockTicker is a test double for Ticker. +type mockTicker struct { + c chan time.Time +} + +func (m *mockTicker) Stop() {} +func (m *mockTicker) C() <-chan time.Time { + return m.c +} + +// mockTickerFactory creates mockTicker instances. +type mockTickerFactory struct { + ticker *mockTicker +} + +func (m *mockTickerFactory) NewTicker(d time.Duration) Ticker { + return m.ticker +} + func TestRefreshZone(t *testing.T) { repo := &mockServerRepo{} srv := NewServer(":0", repo, nil) @@ -183,3 +215,133 @@ func TestRefreshZone_HostnameMaster(t *testing.T) { srv.refreshZone(context.Background(), zone) // refreshZone is void - the test verifies queryFn receives the correct server address } + +func TestPerformIXFR_DialError(t *testing.T) { + repo := &mockServerRepo{} + srv := NewServer(":0", repo, nil) + srv.tcpFactory = &mockTCPFactory{dialErr: errors.New("connection refused")} + + zone := &domain.Zone{ID: "z1", Name: "ixfr.test."} + err := srv.performIXFR(context.Background(), zone, "127.0.0.1:1", 100) + if err == nil { + t.Errorf("Expected dial error") + } +} + +func TestPerformAXFR_DialError(t *testing.T) { + repo := &mockServerRepo{} + srv := NewServer(":0", repo, nil) + srv.tcpFactory = &mockTCPFactory{dialErr: errors.New("connection refused")} + + zone := &domain.Zone{ID: "z1", Name: "axfr.test."} + err := srv.performAXFR(context.Background(), zone, "127.0.0.1:1") + if err == nil { + t.Errorf("Expected dial error") + } +} + +func TestFetchAXFRPackets_DialError(t *testing.T) { + repo := &mockServerRepo{} + srv := NewServer(":0", repo, nil) + srv.tcpFactory = &mockTCPFactory{dialErr: errors.New("connection refused")} + + _, err := srv.fetchAXFRPackets(context.Background(), "test.", "127.0.0.1:1") + if err == nil { + t.Errorf("Expected dial error") + } +} + +func TestStartCatalogPoller_Ticker(t *testing.T) { + repo := &mockServerRepo{} + srv := NewServer(":0", repo, nil) + + // Create a mock ticker that we can control + mockTick := &mockTicker{c: make(chan time.Time, 1)} + srv.tickerFactory = &mockTickerFactory{ticker: mockTick} + + ctx, cancel := context.WithCancel(context.Background()) + + // Start the poller - it should return when context is cancelled + go srv.StartCatalogPoller(ctx, []string{"catalog.test."}, "127.0.0.1:1", time.Hour) + + // Cancel after a short delay + time.Sleep(10 * time.Millisecond) + cancel() + + // The function should return without hanging +} + +func TestStartCatalogPoller_ImmediateCancel(t *testing.T) { + repo := &mockServerRepo{} + srv := NewServer(":0", repo, nil) + + mockTick := &mockTicker{c: make(chan time.Time, 1)} + srv.tickerFactory = &mockTickerFactory{ticker: mockTick} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + // Should return immediately without hanging + srv.StartCatalogPoller(ctx, []string{"catalog.test."}, "127.0.0.1:1", time.Hour) +} + +func TestFetchCatalogEntries_Empty(t *testing.T) { + repo := &mockServerRepo{} + srv := NewServer(":0", repo, nil) + srv.tcpFactory = &mockTCPFactory{dialErr: errors.New("no connection")} + + _, err := srv.fetchCatalogEntries(context.Background(), "catalog.test.", "127.0.0.1:1") + if err == nil { + t.Errorf("Expected error from TCP factory") + } +} + + + +func TestFetchZoneRecords_DialError(t *testing.T) { + repo := &mockServerRepo{} + srv := NewServer(":0", repo, nil) + srv.tcpFactory = &mockTCPFactory{dialErr: errors.New("connection refused")} + + _, err := srv.fetchZoneRecords(context.Background(), "test.", "127.0.0.1:1", "z1", "tenant1") + if err == nil { + t.Errorf("Expected dial error") + } +} + +func TestPollCatalogZone_CZTRError(t *testing.T) { + repo := &mockServerRepo{} + srv := NewServer(":0", repo, nil) + + // Make queryFn return error (master doesn't support CZTR) + srv.queryFn = func(server string, name string, qType packet.QueryType) (*packet.DNSPacket, error) { + return nil, errors.New("network error") + } + + err := srv.pollCatalogZone(context.Background(), "catalog.test.", "127.0.0.1:1") + if err != nil { + t.Errorf("Expected nil for CZTR error (graceful skip), got %v", err) + } +} + +func TestPollCatalogZone_EmptyCZTR(t *testing.T) { + repo := &mockServerRepo{} + srv := NewServer(":0", repo, nil) + + // Return empty CZTR response (slave mode not supported) + srv.queryFn = func(server string, name string, qType packet.QueryType) (*packet.DNSPacket, error) { + resp := packet.NewDNSPacket() + resp.Header.Response = true + // No answers - slave mode not supported + return resp, nil + } + + err := srv.pollCatalogZone(context.Background(), "catalog.test.", "127.0.0.1:1") + if err != nil { + t.Errorf("Expected nil for empty CZTR (graceful skip), got %v", err) + } +} + +func strPtr(s string) *string { + return &s +} diff --git a/internal/dns/server/server.go b/internal/dns/server/server.go index aff10c28..a96a978f 100644 --- a/internal/dns/server/server.go +++ b/internal/dns/server/server.go @@ -81,6 +81,13 @@ type Server struct { TLSConfig *tls.Config DoQAddr string // DNS-over-QUIC listen address (default ":853") + // Catalog Zone Polling (RFC 9432 slave-side) + CatalogPollingEnabled bool + CatalogZones []string // catalog zone names to poll + CatalogMasterAddr string // master server address (host:port) + CatalogPollInterval time.Duration + CatalogTenantID string // tenant ID for catalog-provisioned zones + // ServerConfig holds timeout and timing values. ServerConfig *config.ServerConfig @@ -98,6 +105,14 @@ type Server struct { done chan struct{} wg sync.WaitGroup + // catalogState tracks the last-seen serial for each catalog zone to avoid unnecessary re-syncs + catalogState *catalogPollerState + + // tcpFactory creates TCP connections for zone transfers. Defaults to real connections. + tcpFactory TCPConnFactory + // tickerFactory creates Ticker instances for periodic polling. Defaults to real tickers. + tickerFactory TickerFactory + // inflightCache prevents thundering herd: tracks keys currently being fetched from L2. // Key -> *inflightEntry (done channel closed when fetch completes). inflightCache sync.Map @@ -109,6 +124,41 @@ type Server struct { querySingleflight singleflight.Group } +// catalogPollerState tracks catalog zone polling state for efficient change detection. +type catalogPollerState struct { + mu sync.RWMutex + lastSeenSerial map[string]uint32 // catalogZoneName -> serial + lastSeenAt map[string]time.Time // catalogZoneName -> last update time (for TTL eviction) +} + +// cleanup removes stale entries older than ttl from the catalog state. +func (s *catalogPollerState) cleanup(ttl time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + cutoff := time.Now().Add(-ttl) + for name, t := range s.lastSeenAt { + if t.Before(cutoff) { + delete(s.lastSeenSerial, name) + delete(s.lastSeenAt, name) + } + } +} + +// parseCatalogZones parses a comma-separated list of zone names. +func parseCatalogZones(env string) []string { + if env == "" { + return nil + } + var zones []string + for _, z := range strings.Split(env, ",") { + z = strings.TrimSpace(z) + if z != "" { + zones = append(zones, z) + } + } + return zones +} + // NewServer creates a new DNS server instance. func NewServer(addr string, repo ports.DNSRepository, logger *slog.Logger) *Server { if logger == nil { @@ -127,17 +177,37 @@ func NewServer(addr string, repo ports.DNSRepository, logger *slog.Logger) *Serv recursion := os.Getenv("RECURSION_ENABLED") == "true" + // Catalog polling configuration (RFC 9432 slave-side) + catalogPollingEnabled := os.Getenv("CATALOG_POLLING_ENABLED") == "true" + catalogZones := parseCatalogZones(os.Getenv("CATALOG_ZONES")) + catalogMasterAddr := os.Getenv("CATALOG_MASTER_ADDR") + catalogPollInterval := 5 * time.Minute + if interval := os.Getenv("CATALOG_POLL_INTERVAL"); interval != "" { + if d, err := time.ParseDuration(interval); err == nil { + catalogPollInterval = d + } + } + catalogTenantID := os.Getenv("CATALOG_TENANT_ID") + s := &Server{ - Addr: addr, - Repo: repo, - WorkerCount: runtime.NumCPU() * 32, // High concurrency tuning - udpQueue: make(chan udpTask, 50000), - Logger: logger, - limiter: newRateLimiter(500000, 200000, 1000000), - TsigKeys: make(map[string]TsigKey), - NodeID: nodeID, - RecursionEnabled: recursion, - CookieSecret: make([]byte, 32), + Addr: addr, + Repo: repo, + WorkerCount: runtime.NumCPU() * 32, // High concurrency tuning + udpQueue: make(chan udpTask, 50000), + Logger: logger, + limiter: newRateLimiter(500000, 200000, 1000000), + TsigKeys: make(map[string]TsigKey), + NodeID: nodeID, + RecursionEnabled: recursion, + CookieSecret: make([]byte, 32), + catalogState: &catalogPollerState{lastSeenSerial: make(map[string]uint32), lastSeenAt: make(map[string]time.Time)}, + CatalogPollingEnabled: catalogPollingEnabled, + CatalogZones: catalogZones, + CatalogMasterAddr: catalogMasterAddr, + CatalogPollInterval: catalogPollInterval, + CatalogTenantID: catalogTenantID, + tcpFactory: &defaultTCPFactory{}, + tickerFactory: &realTickerFactory{}, } s.lifecycleCtx, s.cancel = context.WithCancel(context.Background()) s.done = make(chan struct{}) @@ -473,6 +543,25 @@ func (s *Server) Run(ctx context.Context) error { }() } + // Start catalog zone poller if configured (RFC 9432 slave-side) + if s.CatalogPollingEnabled && len(s.CatalogZones) > 0 { + if s.CatalogMasterAddr == "" { + s.Logger.Warn("catalog polling enabled but CATALOG_MASTER_ADDR not set, skipping") + } else { + s.Logger.Info("starting catalog zone poller", + "zones", s.CatalogZones, + "master", s.CatalogMasterAddr, + "interval", s.CatalogPollInterval, + "tenant", s.CatalogTenantID, + ) + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.StartCatalogPoller(s.lifecycleCtx, s.CatalogZones, s.CatalogMasterAddr, s.CatalogPollInterval) + }() + } + } + lc := net.ListenConfig{ Control: func(_, _ string, c syscall.RawConn) error { return c.Control(func(fd uintptr) { diff --git a/internal/dns/server/server_test.go b/internal/dns/server/server_test.go index 26c02a49..b5532a40 100644 --- a/internal/dns/server/server_test.go +++ b/internal/dns/server/server_test.go @@ -686,6 +686,43 @@ func (m *mockServerRepo) Ping(ctx context.Context) error { return m.pingErr } +func (m *mockServerRepo) CreateCatalogZone(ctx context.Context, catz *domain.CatalogZone) error { + return nil +} +func (m *mockServerRepo) GetCatalogZone(ctx context.Context, catalogID string, tenantID string) (*domain.CatalogZone, error) { + return nil, nil +} +func (m *mockServerRepo) GetCatalogZoneByName(ctx context.Context, zoneName string, tenantID string) (*domain.CatalogZone, error) { + return nil, nil +} +func (m *mockServerRepo) ListCatalogZones(ctx context.Context, tenantID string) ([]domain.CatalogZone, error) { + return nil, nil +} +func (m *mockServerRepo) UpdateCatalogZoneVersion(ctx context.Context, catalogID string, version string, serial uint32) error { + return nil +} +func (m *mockServerRepo) DeleteCatalogZone(ctx context.Context, catalogID string, tenantID string) error { + return nil +} +func (m *mockServerRepo) ListZoneCatalogEntries(ctx context.Context, catalogID string, tenantID string) ([]domain.ZoneCatalogEntry, error) { + return nil, nil +} +func (m *mockServerRepo) AddZoneToCatalog(ctx context.Context, catalogID string, tenantID string, entry *domain.ZoneCatalogEntry) error { + return nil +} +func (m *mockServerRepo) RemoveZoneFromCatalog(ctx context.Context, catalogID string, tenantID string, zoneName string) error { + return nil +} +func (m *mockServerRepo) CreateZoneFromCatalog(ctx context.Context, zone *domain.Zone, records []domain.Record) error { + return nil +} +func (m *mockServerRepo) DeleteZoneByCatalogName(ctx context.Context, catalogZoneName string, tenantID string) error { + return nil +} +func (m *mockServerRepo) GetZoneByCatalogName(ctx context.Context, catalogZoneName string, tenantID string) (*domain.Zone, error) { + return nil, nil +} + func TestHandlePacketLocalHit(t *testing.T) { repo := &mockServerRepo{ records: []domain.Record{ @@ -1653,6 +1690,35 @@ func TestDLQRetryWorker_Shutdown(t *testing.T) { } } +func TestCatalogPollerState_Cleanup(t *testing.T) { + state := &catalogPollerState{ + lastSeenSerial: map[string]uint32{ + "zone1.example.com.": 5, + "zone2.example.com.": 3, + }, + lastSeenAt: map[string]time.Time{ + "zone1.example.com.": time.Now().Add(-25 * time.Hour), // stale (>24h ttl) + "zone2.example.com.": time.Now().Add(-1 * time.Hour), // fresh + }, + } + + state.cleanup(24 * time.Hour) + + // zone1 should be removed (stale), zone2 should remain (fresh) + if _, ok := state.lastSeenSerial["zone1.example.com."]; ok { + t.Error("expected zone1 to be cleaned up") + } + if _, ok := state.lastSeenSerial["zone2.example.com."]; !ok { + t.Error("expected zone2 to remain") + } + if _, ok := state.lastSeenAt["zone1.example.com."]; ok { + t.Error("expected zone1 lastSeenAt to be cleaned up") + } + if _, ok := state.lastSeenAt["zone2.example.com."]; !ok { + t.Error("expected zone2 lastSeenAt to remain") + } +} + func TestDLQRetryWorker_MalformedMessage(t *testing.T) { mr, _ := miniredis.Run() defer mr.Close() diff --git a/internal/testutil/mock_repo.go b/internal/testutil/mock_repo.go index 795ba352..f9e761a5 100644 --- a/internal/testutil/mock_repo.go +++ b/internal/testutil/mock_repo.go @@ -261,6 +261,87 @@ func (m *MockRepo) GetRecordsToProbeStreaming(_ context.Context) (ports.RecordIt return args.Get(0).(ports.RecordIterator), args.Error(1) } +// CreateCatalogZone implements ports.DNSRepository for testing. +func (m *MockRepo) CreateCatalogZone(_ context.Context, catz *domain.CatalogZone) error { + args := m.Called(catz) + return args.Error(0) +} + +// GetCatalogZone implements ports.DNSRepository for testing. +func (m *MockRepo) GetCatalogZone(_ context.Context, catalogID string, tenantID string) (*domain.CatalogZone, error) { + args := m.Called(catalogID, tenantID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.CatalogZone), args.Error(1) +} + +// GetCatalogZoneByName implements ports.DNSRepository for testing. +func (m *MockRepo) GetCatalogZoneByName(_ context.Context, zoneName string, tenantID string) (*domain.CatalogZone, error) { + args := m.Called(zoneName, tenantID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.CatalogZone), args.Error(1) +} + +// ListCatalogZones implements ports.DNSRepository for testing. +func (m *MockRepo) ListCatalogZones(_ context.Context, tenantID string) ([]domain.CatalogZone, error) { + args := m.Called(tenantID) + return args.Get(0).([]domain.CatalogZone), args.Error(1) +} + +// UpdateCatalogZoneVersion implements ports.DNSRepository for testing. +func (m *MockRepo) UpdateCatalogZoneVersion(_ context.Context, catalogID string, version string, serial uint32) error { + args := m.Called(catalogID, version, serial) + return args.Error(0) +} + +// DeleteCatalogZone implements ports.DNSRepository for testing. +func (m *MockRepo) DeleteCatalogZone(_ context.Context, catalogID string, tenantID string) error { + args := m.Called(catalogID, tenantID) + return args.Error(0) +} + +// ListZoneCatalogEntries implements ports.DNSRepository for testing. +func (m *MockRepo) ListZoneCatalogEntries(_ context.Context, catalogID string, tenantID string) ([]domain.ZoneCatalogEntry, error) { + args := m.Called(catalogID, tenantID) + return args.Get(0).([]domain.ZoneCatalogEntry), args.Error(1) +} + +// AddZoneToCatalog implements ports.DNSRepository for testing. +func (m *MockRepo) AddZoneToCatalog(_ context.Context, catalogID string, tenantID string, entry *domain.ZoneCatalogEntry) error { + args := m.Called(catalogID, tenantID, entry) + return args.Error(0) +} + +// RemoveZoneFromCatalog implements ports.DNSRepository for testing. +func (m *MockRepo) RemoveZoneFromCatalog(_ context.Context, catalogID string, tenantID string, zoneName string) error { + args := m.Called(catalogID, tenantID, zoneName) + return args.Error(0) +} + +// CreateZoneFromCatalog implements ports.DNSRepository for testing. +func (m *MockRepo) CreateZoneFromCatalog(_ context.Context, zone *domain.Zone, records []domain.Record) error { + args := m.Called(zone, records) + return args.Error(0) +} + +// DeleteZoneByCatalogName implements ports.DNSRepository for testing. +func (m *MockRepo) DeleteZoneByCatalogName(_ context.Context, catalogZoneName string, tenantID string) error { + args := m.Called(catalogZoneName, tenantID) + return args.Error(0) +} + +// GetZoneByCatalogName implements ports.DNSRepository for testing. +func (m *MockRepo) GetZoneByCatalogName(_ context.Context, catalogZoneName string, tenantID string) (*domain.Zone, error) { + args := m.Called(catalogZoneName, tenantID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Zone), args.Error(1) +} + // MockDNSService implements ports.DNSService for testing. type MockDNSService struct { mock.Mock @@ -352,3 +433,63 @@ func (m *MockDNSService) HealthCheck(_ context.Context) map[string]error { args := m.Called() return args.Get(0).(map[string]error) } + +// CreateCatalogZone implements ports.DNSService for testing. +func (m *MockDNSService) CreateCatalogZone(_ context.Context, _ string, _ string) (*domain.CatalogZone, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.CatalogZone), args.Error(1) +} + +// GetCatalogZone implements ports.DNSService for testing. +func (m *MockDNSService) GetCatalogZone(_ context.Context, _ string, _ string) (*domain.CatalogZone, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.CatalogZone), args.Error(1) +} + +// ListCatalogZones implements ports.DNSService for testing. +func (m *MockDNSService) ListCatalogZones(_ context.Context, _ string) ([]domain.CatalogZone, error) { + args := m.Called() + return args.Get(0).([]domain.CatalogZone), args.Error(1) +} + +// DeleteCatalogZone implements ports.DNSService for testing. +func (m *MockDNSService) DeleteCatalogZone(_ context.Context, _ string, _ string) error { + args := m.Called() + return args.Error(0) +} + +// AddZoneToCatalog implements ports.DNSService for testing. +func (m *MockDNSService) AddZoneToCatalog(_ context.Context, _ string, _ string, _ string, _ string, _ string) error { + args := m.Called() + return args.Error(0) +} + +// RemoveZoneFromCatalog implements ports.DNSService for testing. +func (m *MockDNSService) RemoveZoneFromCatalog(_ context.Context, _ string, _ string, _ string) error { + args := m.Called() + return args.Error(0) +} + +// ListZoneCatalogEntries implements ports.DNSService for testing. +func (m *MockDNSService) ListZoneCatalogEntries(_ context.Context, _ string, _ string) ([]domain.ZoneCatalogEntry, error) { + args := m.Called() + return args.Get(0).([]domain.ZoneCatalogEntry), args.Error(1) +} + +// PollCatalogZone implements ports.DNSService for testing. +func (m *MockDNSService) PollCatalogZone(_ context.Context, _ string, _ string) ([]domain.ZoneCatalogEntry, error) { + args := m.Called() + return args.Get(0).([]domain.ZoneCatalogEntry), args.Error(1) +} + +// SyncZonesFromCatalog implements ports.DNSService for testing. +func (m *MockDNSService) SyncZonesFromCatalog(_ context.Context, _ string, _ string) error { + args := m.Called() + return args.Error(0) +} diff --git a/internal/testutil/mock_repo_test.go b/internal/testutil/mock_repo_test.go index 12cc7068..c4b1a745 100644 --- a/internal/testutil/mock_repo_test.go +++ b/internal/testutil/mock_repo_test.go @@ -2,6 +2,7 @@ package testutil import ( "context" + "errors" "testing" "github.com/poyrazK/cloudDNS/internal/core/domain" @@ -341,3 +342,352 @@ func TestMockDNSService(t *testing.T) { checks := m.HealthCheck(ctx) if len(checks) != 1 || checks["postgres"] != nil { t.Errorf("HealthCheck failed") } } + +func TestMockRepo_GetCatalogZoneByName(t *testing.T) { + m := new(MockRepo) + m.On("GetCatalogZoneByName", "catalog.example.com.", "t1").Return(&domain.CatalogZone{ + ID: "catz-1", TenantID: "t1", ZoneName: "catalog.example.com.", Version: "1", Serial: 1, + }, nil).Once() + + catz, err := m.GetCatalogZoneByName(context.Background(), "catalog.example.com.", "t1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if catz == nil || catz.ID != "catz-1" { + t.Errorf("unexpected result: %+v", catz) + } + m.AssertExpectations(t) +} + +func TestMockRepo_GetCatalogZoneByName_NotFound(t *testing.T) { + m := new(MockRepo) + m.On("GetCatalogZoneByName", "nonexistent.example.com.", "t1").Return(nil, nil).Once() + + catz, err := m.GetCatalogZoneByName(context.Background(), "nonexistent.example.com.", "t1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if catz != nil { + t.Errorf("expected nil, got %+v", catz) + } + m.AssertExpectations(t) +} + +func TestMockRepo_CatalogZoneMethods(t *testing.T) { + t.Run("CreateCatalogZone", func(t *testing.T) { + m := new(MockRepo) + m.On("CreateCatalogZone", mock.Anything).Return(nil).Once() + err := m.CreateCatalogZone(context.Background(),&domain.CatalogZone{ID: "catz-1"}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) + }) + + t.Run("GetCatalogZone", func(t *testing.T) { + m := new(MockRepo) + m.On("GetCatalogZone", "catz-1", "t1").Return(&domain.CatalogZone{ID: "catz-1"}, nil).Once() + catz, err := m.GetCatalogZone(context.Background(), "catz-1", "t1") + if err != nil || catz == nil || catz.ID != "catz-1" { + t.Errorf("unexpected result: %+v, err: %v", catz, err) + } + m.AssertExpectations(t) + }) + + t.Run("GetCatalogZone_NotFound", func(t *testing.T) { + m := new(MockRepo) + m.On("GetCatalogZone", "nonexistent", "t1").Return(nil, nil).Once() + catz, err := m.GetCatalogZone(context.Background(), "nonexistent", "t1") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if catz != nil { + t.Errorf("expected nil, got %+v", catz) + } + m.AssertExpectations(t) + }) + + t.Run("ListCatalogZones", func(t *testing.T) { + m := new(MockRepo) + m.On("ListCatalogZones", "t1").Return([]domain.CatalogZone{{ID: "catz-1"}}, nil).Once() + catzs, err := m.ListCatalogZones(context.Background(), "t1") + if err != nil || len(catzs) != 1 { + t.Errorf("unexpected result: %+v, err: %v", catzs, err) + } + m.AssertExpectations(t) + }) + + t.Run("UpdateCatalogZoneVersion", func(t *testing.T) { + m := new(MockRepo) + m.On("UpdateCatalogZoneVersion", "catz-1", "2", uint32(10)).Return(nil).Once() + err := m.UpdateCatalogZoneVersion(context.Background(), "catz-1", "2", 10) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) + }) + + t.Run("DeleteCatalogZone", func(t *testing.T) { + m := new(MockRepo) + m.On("DeleteCatalogZone", "catz-1", "t1").Return(nil).Once() + err := m.DeleteCatalogZone(context.Background(), "catz-1", "t1") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) + }) + + t.Run("ListZoneCatalogEntries", func(t *testing.T) { + m := new(MockRepo) + entries := []domain.ZoneCatalogEntry{{ZoneName: "zone1.example.com."}} + m.On("ListZoneCatalogEntries", "catz-1", "t1").Return(entries, nil).Once() + result, err := m.ListZoneCatalogEntries(context.Background(), "catz-1", "t1") + if err != nil || len(result) != 1 { + t.Errorf("unexpected result: %+v, err: %v", result, err) + } + m.AssertExpectations(t) + }) + + t.Run("AddZoneToCatalog", func(t *testing.T) { + m := new(MockRepo) + entry := &domain.ZoneCatalogEntry{ZoneName: "zone1.example.com."} + m.On("AddZoneToCatalog", "catz-1", "t1", entry).Return(nil).Once() + err := m.AddZoneToCatalog(context.Background(), "catz-1", "t1", entry) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) + }) + + t.Run("RemoveZoneFromCatalog", func(t *testing.T) { + m := new(MockRepo) + m.On("RemoveZoneFromCatalog", "catz-1", "t1", "zone1.example.com.").Return(nil).Once() + err := m.RemoveZoneFromCatalog(context.Background(), "catz-1", "t1", "zone1.example.com.") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) + }) + + t.Run("CreateZoneFromCatalog", func(t *testing.T) { + m := new(MockRepo) + m.On("CreateZoneFromCatalog", mock.Anything, mock.Anything).Return(nil).Once() + err := m.CreateZoneFromCatalog(context.Background(), &domain.Zone{ID: "z1"}, []domain.Record{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) + }) + + t.Run("DeleteZoneByCatalogName", func(t *testing.T) { + m := new(MockRepo) + m.On("DeleteZoneByCatalogName", "zone1.example.com.", "t1").Return(nil).Once() + err := m.DeleteZoneByCatalogName(context.Background(), "zone1.example.com.", "t1") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) + }) + + t.Run("GetZoneByCatalogName", func(t *testing.T) { + m := new(MockRepo) + m.On("GetZoneByCatalogName", "zone1.example.com.", "t1").Return(&domain.Zone{ID: "z1"}, nil).Once() + zone, err := m.GetZoneByCatalogName(context.Background(), "zone1.example.com.", "t1") + if err != nil || zone == nil || zone.ID != "z1" { + t.Errorf("unexpected result: %+v, err: %v", zone, err) + } + m.AssertExpectations(t) + }) + + t.Run("GetZoneByCatalogName_NotFound", func(t *testing.T) { + m := new(MockRepo) + m.On("GetZoneByCatalogName", "nonexistent.example.com.", "t1").Return(nil, nil).Once() + zone, err := m.GetZoneByCatalogName(context.Background(), "nonexistent.example.com.", "t1") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if zone != nil { + t.Errorf("expected nil, got %+v", zone) + } + m.AssertExpectations(t) + }) +} + +func TestMockDNSService_CatalogMethods(t *testing.T) { + t.Run("CreateCatalogZone", func(t *testing.T) { + m := new(MockDNSService) + m.On("CreateCatalogZone", mock.Anything, mock.Anything, mock.Anything).Return(&domain.CatalogZone{ID: "catz-1"}, nil).Once() + catz, err := m.CreateCatalogZone(context.Background(), "catz-1", "t1") + if err != nil || catz == nil || catz.ID != "catz-1" { + t.Errorf("unexpected result: %+v, err: %v", catz, err) + } + m.AssertExpectations(t) + }) + + t.Run("GetCatalogZone", func(t *testing.T) { + m := new(MockDNSService) + m.On("GetCatalogZone", mock.Anything, mock.Anything, mock.Anything).Return(&domain.CatalogZone{ID: "catz-1"}, nil).Once() + catz, err := m.GetCatalogZone(context.Background(), "catz-1", "t1") + if err != nil || catz == nil || catz.ID != "catz-1" { + t.Errorf("unexpected result: %+v, err: %v", catz, err) + } + m.AssertExpectations(t) + }) + + t.Run("ListCatalogZones", func(t *testing.T) { + m := new(MockDNSService) + m.On("ListCatalogZones", mock.Anything, mock.Anything).Return([]domain.CatalogZone{{ID: "catz-1"}}, nil).Once() + catzs, err := m.ListCatalogZones(context.Background(), "t1") + if err != nil || len(catzs) != 1 { + t.Errorf("unexpected result: %+v, err: %v", catzs, err) + } + m.AssertExpectations(t) + }) + + t.Run("DeleteCatalogZone", func(t *testing.T) { + m := new(MockDNSService) + m.On("DeleteCatalogZone", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + err := m.DeleteCatalogZone(context.Background(), "catz-1", "t1") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) + }) + + t.Run("AddZoneToCatalog", func(t *testing.T) { + m := new(MockDNSService) + m.On("AddZoneToCatalog", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + err := m.AddZoneToCatalog(context.Background(), "catz-1", "t1", "zone1", "tenant", "192.168.1.1") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) + }) + + t.Run("RemoveZoneFromCatalog", func(t *testing.T) { + m := new(MockDNSService) + m.On("RemoveZoneFromCatalog", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + err := m.RemoveZoneFromCatalog(context.Background(), "catz-1", "t1", "zone1") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) + }) + + t.Run("ListZoneCatalogEntries", func(t *testing.T) { + m := new(MockDNSService) + entries := []domain.ZoneCatalogEntry{{ZoneName: "zone1.example.com."}} + m.On("ListZoneCatalogEntries", mock.Anything, mock.Anything, mock.Anything).Return(entries, nil).Once() + result, err := m.ListZoneCatalogEntries(context.Background(), "catz-1", "t1") + if err != nil || len(result) != 1 { + t.Errorf("unexpected result: %+v, err: %v", result, err) + } + m.AssertExpectations(t) + }) +} + +func TestMockDNSService_UpdateRecord(t *testing.T) { + m := new(MockDNSService) + m.On("UpdateRecord", mock.Anything).Return(nil).Once() + err := m.UpdateRecord(context.Background(), &domain.Record{ID: "r1", ZoneID: "z1"}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) +} + +func TestMockDNSService_UpdateRecord_Error(t *testing.T) { + m := new(MockDNSService) + m.On("UpdateRecord", mock.Anything).Return(errors.New("update failed")).Once() + err := m.UpdateRecord(context.Background(), &domain.Record{ID: "r2"}) + if err == nil { + t.Error("expected error, got nil") + } + m.AssertExpectations(t) +} + +func TestMockRepo_GetRecordsByNames(t *testing.T) { + m := new(MockRepo) + recs := map[string][]domain.Record{"a.example.com.": {{ID: "r1", Name: "a.example.com."}}} + m.On("GetRecordsByNames", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(recs, nil).Once() + result, err := m.GetRecordsByNames(context.Background(), []string{"a.example.com.", "b.example.com."}, domain.TypeA, "1.1") + if err != nil || len(result) != 1 { + t.Errorf("unexpected result: %+v, err: %v", result, err) + } + m.AssertExpectations(t) +} + +func TestMockRepo_GetZoneLongestMatch(t *testing.T) { + m := new(MockRepo) + m.On("GetZoneLongestMatch", "www.example.com.").Return(&domain.Zone{ID: "z1", Name: "example.com."}, nil).Once() + zone, err := m.GetZoneLongestMatch(context.Background(), "www.example.com.") + if err != nil || zone == nil || zone.ID != "z1" { + t.Errorf("unexpected result: %+v, err: %v", zone, err) + } + m.AssertExpectations(t) +} + +func TestMockRepo_GetZoneLongestMatch_NotFound(t *testing.T) { + m := new(MockRepo) + m.On("GetZoneLongestMatch", "unknown.example.com.").Return(nil, nil).Once() + zone, err := m.GetZoneLongestMatch(context.Background(), "unknown.example.com.") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if zone != nil { + t.Errorf("expected nil, got %+v", zone) + } + m.AssertExpectations(t) +} + +func TestMockRepo_UpdateRecord(t *testing.T) { + m := new(MockRepo) + m.On("UpdateRecord", mock.Anything).Return(nil).Once() + err := m.UpdateRecord(context.Background(), &domain.Record{ID: "r1"}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m.AssertExpectations(t) +} + +func TestMockRepo_ListRecordsForZoneStreaming(t *testing.T) { + m := new(MockRepo) + iter :=&mockRecordIterator{records: []domain.Record{{ID: "r1"}}, index: 0} + m.On("ListRecordsForZoneStreaming", mock.Anything, mock.Anything, mock.Anything).Return(iter, nil).Once() + result, err := m.ListRecordsForZoneStreaming(context.Background(), "z1", "t1") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result == nil { + t.Error("expected non-nil iterator") + } + m.AssertExpectations(t) +} + +func TestMockRepo_GetDNSKEYs(t *testing.T) { + m := new(MockRepo) + m.On("GetDNSKEYs", "example.com.").Return([]domain.Record{ + {ID: "k1", Name: "example.com.", Type: "DNSKEY", Content: "257 3 13 base64key=="}, + }, nil).Once() + recs, err := m.GetDNSKEYs(context.Background(), "example.com.") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(recs) != 1 { + t.Errorf("expected 1 record, got %d", len(recs)) + } + m.AssertExpectations(t) +} + +func TestMockRepo_GetDNSKEYs_NotFound(t *testing.T) { + m := new(MockRepo) + m.On("GetDNSKEYs", "nonexistent.com.").Return([]domain.Record{}, nil).Once() + recs, err := m.GetDNSKEYs(context.Background(), "nonexistent.com.") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(recs) != 0 { + t.Errorf("expected 0 records, got %d", len(recs)) + } + m.AssertExpectations(t) +}