Files
certctl/internal/service/renewal_policy_test.go
T
shankar e9bbf33193 G-1: renewal-policies API + frontend FK-drift fix
Three frontend call sites (OnboardingWizard.tsx:603, CertificatesPage.tsx:52,
CertificateDetailPage.tsx:169) populated the renewal_policy_id dropdown from
getPolicies() — the compliance-rule endpoint returning pol-* IDs — which
violated the FK managed_certificates.renewal_policy_id REFERENCES
renewal_policies(id) ON DELETE RESTRICT. Create would fail pg 23503 at insert.

Backend (new):
- RenewalPolicyRepository CRUD + ListAll/ExistsByID (pg 23503 → ErrRenewalPolicyInUse
  → HTTP 409; pg 23505 → ErrRenewalPolicyDuplicateName → HTTP 409)
- RenewalPolicyService with repo-only constructor. Service sentinels
  var-alias the repo sentinels so errors.Is walks across layers.
- RenewalPolicyHandler with validation bounds: name 1–255;
  renewal_window_days [1,365] default 30; max_retries [0,10] not defaulted;
  retry_interval_seconds [60,86400] default 3600; alert_thresholds_days
  [0,365] default [30,14,7,0]. Auto-generated IDs rp-<slug(name)>.
- Router registers 5 routes under /api/v1/renewal-policies[/{id}].

Frontend:
- CertificatesPage/CertificateDetailPage/OnboardingWizard now call
  getRenewalPolicies() and render rp-* IDs.
- client.ts adds getRenewalPolicies/createRenewalPolicy/updateRenewalPolicy/
  deleteRenewalPolicy. types.ts adds the RenewalPolicy shape.

OpenAPI: RenewalPolicies tag + 5 operations + 3 schemas (RenewalPolicy,
RenewalPolicyCreateRequest, RenewalPolicyUpdateRequest). 409 responses
on create/update duplicate-name and delete FK-in-use.

No migration — renewal_policies table already exists from the initial
schema (000001).

Tests:
- internal/service/renewal_policy_test.go: CRUD + validation + sentinel
  error wrapping.
- internal/api/handler/renewal_policy_handler_test.go: handler endpoint
  contracts including 400/404/409.
- web/src/api/client.test.ts: 4 subtests covering the 4 new API functions.

Phase 3 gates all green: go vet, build, short tests, race tests (service/
handler/router/scheduler), staticcheck (G-1 packages), govulncheck (0
reachable), coverage (service 69.7%, handler 79.0%, domain 86.9%,
middleware 80.6% — all above thresholds), tsc, vitest (256 passed),
vite build, OpenAPI structural validation.
2026-04-20 18:53:01 +00:00

342 lines
10 KiB
Go

package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// G-1 red tests: lock in the behavior of RenewalPolicyService before
// the production code exists. Every subtest here references a type or
// method that Phase 2b must introduce:
//
// - NewRenewalPolicyService(repo) (constructor)
// - svc.ListRenewalPolicies(ctx, page, pp) ([]RenewalPolicy, int64, error)
// - svc.GetRenewalPolicy(ctx, id) (*RenewalPolicy, error)
// - svc.CreateRenewalPolicy(ctx, rp) (*RenewalPolicy, error)
// - svc.UpdateRenewalPolicy(ctx, id, rp) (*RenewalPolicy, error)
// - svc.DeleteRenewalPolicy(ctx, id) error
// - ErrRenewalPolicyDuplicateName sentinel (pg 23505 → 409)
// - ErrRenewalPolicyInUse sentinel (pg 23503 → 409)
//
// Once Phase 2b lands, these should all turn green without modification.
func TestRenewalPolicyService_List_Success(t *testing.T) {
ctx := context.Background()
now := time.Now()
repo := &mockRenewalPolicyRepo{
Policies: map[string]*domain.RenewalPolicy{
"rp-default": {
ID: "rp-default", Name: "Default", RenewalWindowDays: 30,
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
CreatedAt: now, UpdatedAt: now,
},
"rp-urgent": {
ID: "rp-urgent", Name: "Urgent", RenewalWindowDays: 7,
MaxRetries: 5, RetryInterval: 600, AutoRenew: true,
CreatedAt: now, UpdatedAt: now,
},
},
}
svc := NewRenewalPolicyService(repo)
items, total, err := svc.ListRenewalPolicies(ctx, 1, 50)
if err != nil {
t.Fatalf("ListRenewalPolicies failed: %v", err)
}
if total != 2 {
t.Errorf("expected total 2, got %d", total)
}
if len(items) != 2 {
t.Errorf("expected 2 items, got %d", len(items))
}
}
func TestRenewalPolicyService_List_Empty(t *testing.T) {
ctx := context.Background()
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
svc := NewRenewalPolicyService(repo)
items, total, err := svc.ListRenewalPolicies(ctx, 1, 50)
if err != nil {
t.Fatalf("ListRenewalPolicies failed: %v", err)
}
if total != 0 {
t.Errorf("expected total 0, got %d", total)
}
if len(items) != 0 {
t.Errorf("expected 0 items, got %d", len(items))
}
}
func TestRenewalPolicyService_List_Pagination(t *testing.T) {
ctx := context.Background()
now := time.Now()
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
// Seed 5 policies, names A..E so the mock's sort.Slice yields a deterministic
// ordering that pagination boundaries can assert against.
for _, name := range []string{"A", "B", "C", "D", "E"} {
p := &domain.RenewalPolicy{
ID: "rp-" + strings.ToLower(name), Name: name,
RenewalWindowDays: 30, MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
CreatedAt: now, UpdatedAt: now,
}
repo.Policies[p.ID] = p
}
svc := NewRenewalPolicyService(repo)
// Page 1, size 2 → [A, B]
page1, total, err := svc.ListRenewalPolicies(ctx, 1, 2)
if err != nil {
t.Fatalf("page 1 failed: %v", err)
}
if total != 5 {
t.Errorf("expected total 5, got %d", total)
}
if len(page1) != 2 || page1[0].Name != "A" || page1[1].Name != "B" {
t.Errorf("unexpected page 1 slice: %+v", page1)
}
// Page 3, size 2 → [E] (single-item last page)
page3, _, err := svc.ListRenewalPolicies(ctx, 3, 2)
if err != nil {
t.Fatalf("page 3 failed: %v", err)
}
if len(page3) != 1 || page3[0].Name != "E" {
t.Errorf("unexpected page 3 slice: %+v", page3)
}
// Page 4, size 2 → [] (past the end, no error)
page4, _, err := svc.ListRenewalPolicies(ctx, 4, 2)
if err != nil {
t.Fatalf("page 4 failed: %v", err)
}
if len(page4) != 0 {
t.Errorf("expected empty past-end slice, got %+v", page4)
}
}
func TestRenewalPolicyService_List_RepoError(t *testing.T) {
ctx := context.Background()
repo := &mockRenewalPolicyRepo{
Policies: map[string]*domain.RenewalPolicy{},
ListErr: errors.New("boom"),
}
svc := NewRenewalPolicyService(repo)
_, _, err := svc.ListRenewalPolicies(ctx, 1, 50)
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestRenewalPolicyService_Get_Success(t *testing.T) {
ctx := context.Background()
now := time.Now()
rp := &domain.RenewalPolicy{
ID: "rp-default", Name: "Default", RenewalWindowDays: 30,
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
CreatedAt: now, UpdatedAt: now,
}
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{"rp-default": rp}}
svc := NewRenewalPolicyService(repo)
got, err := svc.GetRenewalPolicy(ctx, "rp-default")
if err != nil {
t.Fatalf("GetRenewalPolicy failed: %v", err)
}
if got.Name != "Default" {
t.Errorf("expected name Default, got %s", got.Name)
}
}
func TestRenewalPolicyService_Get_NotFound(t *testing.T) {
ctx := context.Background()
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
svc := NewRenewalPolicyService(repo)
_, err := svc.GetRenewalPolicy(ctx, "rp-missing")
if err == nil {
t.Fatal("expected error for missing policy, got nil")
}
}
func TestRenewalPolicyService_Create_Success(t *testing.T) {
ctx := context.Background()
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
svc := NewRenewalPolicyService(repo)
rp := domain.RenewalPolicy{
Name: "Weekly Renewal",
RenewalWindowDays: 7,
MaxRetries: 3,
RetryInterval: 3600,
AutoRenew: true,
}
created, err := svc.CreateRenewalPolicy(ctx, rp)
if err != nil {
t.Fatalf("CreateRenewalPolicy failed: %v", err)
}
if created.ID == "" {
t.Fatal("expected auto-generated ID, got empty")
}
// ID convention: rp-<slug(name)> matches seed rows rp-default/rp-standard/rp-urgent.
if !strings.HasPrefix(created.ID, "rp-") {
t.Errorf("expected ID prefix rp-, got %s", created.ID)
}
if created.CreatedAt.IsZero() {
t.Error("expected CreatedAt to be populated")
}
}
func TestRenewalPolicyService_Create_MissingName(t *testing.T) {
ctx := context.Background()
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
svc := NewRenewalPolicyService(repo)
_, err := svc.CreateRenewalPolicy(ctx, domain.RenewalPolicy{
RenewalWindowDays: 30, MaxRetries: 3, RetryInterval: 3600,
})
if err == nil {
t.Fatal("expected validation error for missing name, got nil")
}
}
func TestRenewalPolicyService_Create_BoundsViolation(t *testing.T) {
ctx := context.Background()
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
svc := NewRenewalPolicyService(repo)
// RenewalWindowDays out of range [1, 365]
_, err := svc.CreateRenewalPolicy(ctx, domain.RenewalPolicy{
Name: "Bad Window",
RenewalWindowDays: 999,
MaxRetries: 3,
RetryInterval: 3600,
})
if err == nil {
t.Fatal("expected bounds violation on RenewalWindowDays, got nil")
}
}
func TestRenewalPolicyService_Create_DuplicateName(t *testing.T) {
ctx := context.Background()
repo := &mockRenewalPolicyRepo{
Policies: map[string]*domain.RenewalPolicy{},
CreateErr: ErrRenewalPolicyDuplicateName,
}
svc := NewRenewalPolicyService(repo)
_, err := svc.CreateRenewalPolicy(ctx, domain.RenewalPolicy{
Name: "Duplicate",
RenewalWindowDays: 30,
MaxRetries: 3,
RetryInterval: 3600,
})
if err == nil {
t.Fatal("expected duplicate-name error, got nil")
}
if !errors.Is(err, ErrRenewalPolicyDuplicateName) {
t.Errorf("expected ErrRenewalPolicyDuplicateName, got %v", err)
}
}
func TestRenewalPolicyService_Update_Success(t *testing.T) {
ctx := context.Background()
now := time.Now()
rp := &domain.RenewalPolicy{
ID: "rp-default", Name: "Default", RenewalWindowDays: 30,
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
CreatedAt: now, UpdatedAt: now,
}
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{"rp-default": rp}}
svc := NewRenewalPolicyService(repo)
updated, err := svc.UpdateRenewalPolicy(ctx, "rp-default", domain.RenewalPolicy{
Name: "Default Renamed",
RenewalWindowDays: 45,
MaxRetries: 5,
RetryInterval: 1800,
AutoRenew: true,
})
if err != nil {
t.Fatalf("UpdateRenewalPolicy failed: %v", err)
}
if updated.Name != "Default Renamed" {
t.Errorf("expected updated name, got %s", updated.Name)
}
if updated.RenewalWindowDays != 45 {
t.Errorf("expected window 45, got %d", updated.RenewalWindowDays)
}
}
func TestRenewalPolicyService_Update_NotFound(t *testing.T) {
ctx := context.Background()
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
svc := NewRenewalPolicyService(repo)
_, err := svc.UpdateRenewalPolicy(ctx, "rp-missing", domain.RenewalPolicy{
Name: "X", RenewalWindowDays: 30, MaxRetries: 3, RetryInterval: 3600,
})
if err == nil {
t.Fatal("expected error for missing policy, got nil")
}
}
func TestRenewalPolicyService_Delete_Success(t *testing.T) {
ctx := context.Background()
now := time.Now()
rp := &domain.RenewalPolicy{
ID: "rp-default", Name: "Default", RenewalWindowDays: 30,
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
CreatedAt: now, UpdatedAt: now,
}
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{"rp-default": rp}}
svc := NewRenewalPolicyService(repo)
if err := svc.DeleteRenewalPolicy(ctx, "rp-default"); err != nil {
t.Fatalf("DeleteRenewalPolicy failed: %v", err)
}
if _, exists := repo.Policies["rp-default"]; exists {
t.Error("expected policy to be removed from repo")
}
}
func TestRenewalPolicyService_Delete_NotFound(t *testing.T) {
ctx := context.Background()
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
svc := NewRenewalPolicyService(repo)
err := svc.DeleteRenewalPolicy(ctx, "rp-missing")
if err == nil {
t.Fatal("expected error for missing policy, got nil")
}
}
func TestRenewalPolicyService_Delete_InUseConflict(t *testing.T) {
ctx := context.Background()
now := time.Now()
rp := &domain.RenewalPolicy{
ID: "rp-active", Name: "Active", RenewalWindowDays: 30,
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
CreatedAt: now, UpdatedAt: now,
}
repo := &mockRenewalPolicyRepo{
Policies: map[string]*domain.RenewalPolicy{"rp-active": rp},
DeleteErr: ErrRenewalPolicyInUse,
}
svc := NewRenewalPolicyService(repo)
err := svc.DeleteRenewalPolicy(ctx, "rp-active")
if err == nil {
t.Fatal("expected in-use conflict error, got nil")
}
if !errors.Is(err, ErrRenewalPolicyInUse) {
t.Errorf("expected ErrRenewalPolicyInUse, got %v", err)
}
}