package postgres_test // Integration tests for RenewalPolicyRepository (post-G-1, 289 lines, 5 // methods). Closes the L-1 coverage gap flagged in coverage-gap-audit.md: // the repository's auto-generated-ID collision retry loop and its two // typed error sentinels (ErrRenewalPolicyDuplicateName on pg 23505, // ErrRenewalPolicyInUse on pg 23503) shipped with zero live-DB regression // coverage — a mock-only test surface cannot exercise the PostgreSQL // constraint semantics these paths depend on. // // The audit listed the file as "92 lines, 2 methods"; that was stale // pre-G-1. Current state is 5 methods (Get/List/Create/Update/Delete), // all covered below. import ( "context" "errors" "strings" "testing" "time" "github.com/certctl-io/certctl/internal/domain" "github.com/certctl-io/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository/postgres" ) // TestRenewalPolicyRepository_CRUD exercises the happy path for all five // interface methods. In particular it drives the slug-based ID // auto-generation branch (policy.ID left empty → Create emits // rp-) so any regression to slugifyPolicyName or the retry // loop surfaces immediately. The AlertThresholdsDays JSONB round-trip is // asserted end-to-end: marshal on Create → store as JSONB → scan back on // Get preserves the slice ordering and values. func TestRenewalPolicyRepository_CRUD(t *testing.T) { tdb := getTestDB(t) db := tdb.freshSchema(t) repo := postgres.NewRenewalPolicyRepository(db) ctx := context.Background() // Create: leave ID empty so the repository generates rp-. // "Prod TLS 90d" → rp-prod-tls-90d per slugifyPolicyName's rules // (lowercase, spaces→hyphens, non-alphanumeric stripped). policy := &domain.RenewalPolicy{ Name: "Prod TLS 90d", RenewalWindowDays: 30, AutoRenew: true, MaxRetries: 5, RetryInterval: 3600, // stored as seconds in retry_interval_seconds column (renamed in 000017_db_coupling_cleanup, U-3) AlertThresholdsDays: []int{30, 14, 7, 0}, } if err := repo.Create(ctx, policy); err != nil { t.Fatalf("Create failed: %v", err) } if policy.ID != "rp-prod-tls-90d" { t.Errorf("auto-generated ID = %q, want %q", policy.ID, "rp-prod-tls-90d") } if policy.CreatedAt.IsZero() { t.Error("Create did not populate CreatedAt (RETURNING clause regression?)") } if policy.UpdatedAt.IsZero() { t.Error("Create did not populate UpdatedAt (RETURNING clause regression?)") } // Get: pull the just-created row back and confirm every stored field // survives the scanRenewalPolicy path, including the JSONB unmarshal. got, err := repo.Get(ctx, policy.ID) if err != nil { t.Fatalf("Get failed: %v", err) } if got.Name != "Prod TLS 90d" { t.Errorf("Get: Name = %q, want %q", got.Name, "Prod TLS 90d") } if got.RenewalWindowDays != 30 { t.Errorf("Get: RenewalWindowDays = %d, want 30", got.RenewalWindowDays) } if !got.AutoRenew { t.Error("Get: AutoRenew = false, want true") } if got.MaxRetries != 5 { t.Errorf("Get: MaxRetries = %d, want 5", got.MaxRetries) } if len(got.AlertThresholdsDays) != 4 { t.Fatalf("Get: AlertThresholdsDays length = %d, want 4 (JSONB round-trip regression)", len(got.AlertThresholdsDays)) } for i, want := range []int{30, 14, 7, 0} { if got.AlertThresholdsDays[i] != want { t.Errorf("Get: AlertThresholdsDays[%d] = %d, want %d", i, got.AlertThresholdsDays[i], want) } } // Update: 3-arg signature is a house invariant — don't let it slip to // 2-arg without the test catching the breakage. Tweak scalar + JSONB // simultaneously so both SET branches exercise. updated := *got updated.Name = "Prod TLS 90d (tightened)" updated.RenewalWindowDays = 45 updated.AlertThresholdsDays = []int{45, 30, 14, 7, 0} // Sleep long enough that NOW() ticks past the Create timestamp so we // can assert UpdatedAt monotonicity without a flaky equality check. time.Sleep(2 * time.Millisecond) if err := repo.Update(ctx, policy.ID, &updated); err != nil { t.Fatalf("Update failed: %v", err) } if !updated.UpdatedAt.After(got.UpdatedAt) { t.Errorf("Update: UpdatedAt %v not after Create's %v (RETURNING NOW() regression?)", updated.UpdatedAt, got.UpdatedAt) } refetched, err := repo.Get(ctx, policy.ID) if err != nil { t.Fatalf("Get after Update failed: %v", err) } if refetched.Name != "Prod TLS 90d (tightened)" { t.Errorf("Get after Update: Name = %q, want %q", refetched.Name, "Prod TLS 90d (tightened)") } if refetched.RenewalWindowDays != 45 { t.Errorf("Get after Update: RenewalWindowDays = %d, want 45", refetched.RenewalWindowDays) } if len(refetched.AlertThresholdsDays) != 5 { t.Errorf("Get after Update: AlertThresholdsDays length = %d, want 5", len(refetched.AlertThresholdsDays)) } // List: add a second policy so the ORDER BY name contract is non-vacuous. second := &domain.RenewalPolicy{ Name: "Aa Earliest", RenewalWindowDays: 14, AutoRenew: false, MaxRetries: 1, RetryInterval: 60, AlertThresholdsDays: []int{7, 0}, } if err := repo.Create(ctx, second); err != nil { t.Fatalf("Create second failed: %v", err) } all, err := repo.List(ctx) if err != nil { t.Fatalf("List failed: %v", err) } if len(all) != 2 { t.Fatalf("List: len = %d, want 2", len(all)) } // "Aa Earliest" sorts before "Prod TLS 90d (tightened)" under ORDER BY name ASC. if all[0].Name != "Aa Earliest" { t.Errorf("List[0].Name = %q, want %q (ORDER BY name regression?)", all[0].Name, "Aa Earliest") } // Delete: removes the policy and a follow-up Get surfaces "not found". if err := repo.Delete(ctx, policy.ID); err != nil { t.Fatalf("Delete failed: %v", err) } if _, err := repo.Get(ctx, policy.ID); err == nil { t.Error("Get after Delete: err = nil, want not-found") } } // TestRenewalPolicyRepository_DuplicateName verifies the pg 23505 // unique_violation translation. The name UNIQUE constraint is enforced // on the renewal_policies.name column; Create's inner scanRenewalPolicy // must see the pq.Error, call isUniqueViolation, check the constraint // name, and return ErrRenewalPolicyDuplicateName. A non-sentinel error // here would cause the handler to emit 500 instead of 409. func TestRenewalPolicyRepository_DuplicateName(t *testing.T) { tdb := getTestDB(t) db := tdb.freshSchema(t) repo := postgres.NewRenewalPolicyRepository(db) ctx := context.Background() first := &domain.RenewalPolicy{ ID: "rp-first", Name: "Shared Name", RenewalWindowDays: 30, AutoRenew: true, MaxRetries: 3, RetryInterval: 300, AlertThresholdsDays: domain.DefaultAlertThresholds(), } if err := repo.Create(ctx, first); err != nil { t.Fatalf("Create first failed: %v", err) } // Second policy with a distinct ID but the same Name — the name UNIQUE // constraint fires, Create's collision branch inspects pqErr.Constraint, // and because it's NOT *_pkey, it returns ErrRenewalPolicyDuplicateName // without retrying. second := &domain.RenewalPolicy{ ID: "rp-second", Name: "Shared Name", RenewalWindowDays: 60, AutoRenew: false, MaxRetries: 1, RetryInterval: 600, AlertThresholdsDays: domain.DefaultAlertThresholds(), } err := repo.Create(ctx, second) if err == nil { t.Fatal("Create second: err = nil, want ErrRenewalPolicyDuplicateName") } if !errors.Is(err, repository.ErrRenewalPolicyDuplicateName) { t.Errorf("Create second: err = %v, want ErrRenewalPolicyDuplicateName (via errors.Is)", err) } // Also verify Update surfaces the same sentinel when an existing row's // name is changed to collide with another policy's name. third := &domain.RenewalPolicy{ ID: "rp-third", Name: "Third Name", RenewalWindowDays: 90, AutoRenew: true, MaxRetries: 2, RetryInterval: 1200, AlertThresholdsDays: domain.DefaultAlertThresholds(), } if err := repo.Create(ctx, third); err != nil { t.Fatalf("Create third failed: %v", err) } third.Name = "Shared Name" // collide with first err = repo.Update(ctx, third.ID, third) if err == nil { t.Fatal("Update: err = nil, want ErrRenewalPolicyDuplicateName") } if !errors.Is(err, repository.ErrRenewalPolicyDuplicateName) { t.Errorf("Update: err = %v, want ErrRenewalPolicyDuplicateName (via errors.Is)", err) } } // TestRenewalPolicyRepository_DeleteInUse verifies the pg 23503 // foreign_key_violation translation. managed_certificates.renewal_policy_id // REFERENCES renewal_policies(id) ON DELETE RESTRICT; attempting to Delete // a policy while a certificate still references it must surface as // ErrRenewalPolicyInUse so the handler can emit 409 Conflict. Any change // to either the FK definition or the isForeignKeyViolation mapping breaks // this. func TestRenewalPolicyRepository_DeleteInUse(t *testing.T) { tdb := getTestDB(t) db := tdb.freshSchema(t) repo := postgres.NewRenewalPolicyRepository(db) ctx := context.Background() // The policy under test — create via repo so ID auto-generation is // also exercised end-to-end in this path. policy := &domain.RenewalPolicy{ Name: "InUse Policy", RenewalWindowDays: 30, AutoRenew: true, MaxRetries: 3, RetryInterval: 300, AlertThresholdsDays: domain.DefaultAlertThresholds(), } if err := repo.Create(ctx, policy); err != nil { t.Fatalf("Create policy failed: %v", err) } // Create owner/team/issuer prerequisites, then raw-INSERT a // managed_certificate row referencing the policy. Using raw SQL here // (matching insertCertPrereqsRaw's idiom) keeps the test independent // of the service layer. ownerID, teamID, issuerID, _ := insertCertPrereqsRaw(t, db, ctx, "inuse") now := time.Now().UTC().Truncate(time.Microsecond) _, err := db.ExecContext(ctx, ` INSERT INTO managed_certificates ( id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, status, expires_at, tags, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) `, "mc-inuse", "inuse-cert", "inuse.example.com", []string{}, "production", ownerID, teamID, issuerID, policy.ID, string(domain.CertificateStatusActive), now.Add(90*24*time.Hour), "{}", now, now, ) if err != nil { t.Fatalf("INSERT managed_certificates failed: %v", err) } // Delete: the ON DELETE RESTRICT FK fires, the pg driver returns a // *pq.Error with Code 23503, isForeignKeyViolation detects it, and // the repository returns ErrRenewalPolicyInUse. err = repo.Delete(ctx, policy.ID) if err == nil { t.Fatal("Delete: err = nil, want ErrRenewalPolicyInUse (ON DELETE RESTRICT should have fired)") } if !errors.Is(err, repository.ErrRenewalPolicyInUse) { t.Errorf("Delete: err = %v, want ErrRenewalPolicyInUse (via errors.Is)", err) } // And the policy is still there — RESTRICT aborted the delete. if _, err := repo.Get(ctx, policy.ID); err != nil { t.Errorf("Get after failed Delete: err = %v, want nil (policy should still exist)", err) } // After removing the referencing cert, Delete succeeds — proves the // RESTRICT was the only thing blocking the earlier Delete and rules // out any unrelated failure mode. if _, err := db.ExecContext(ctx, `DELETE FROM managed_certificates WHERE id = $1`, "mc-inuse"); err != nil { t.Fatalf("cleanup DELETE managed_certificates failed: %v", err) } if err := repo.Delete(ctx, policy.ID); err != nil { t.Errorf("Delete after cleanup: err = %v, want nil", err) } // Also verify Delete on a non-existent ID returns a not-found error // (not nil, not the InUse sentinel) — guards against a silent no-op // regression in the RowsAffected check. err = repo.Delete(ctx, "rp-does-not-exist") if err == nil { t.Fatal("Delete(non-existent): err = nil, want not-found") } if errors.Is(err, repository.ErrRenewalPolicyInUse) { t.Errorf("Delete(non-existent): err = %v, should not be ErrRenewalPolicyInUse", err) } if !strings.Contains(err.Error(), "not found") { t.Errorf("Delete(non-existent): err = %v, want substring %q", err, "not found") } }