Compare commits

...

8 Commits

Author SHA1 Message Date
shankar0123 d6959a75c1 Merge branch 'test/l1-repo-integration-coverage' 2026-04-20 20:39:10 +00:00
shankar0123 97b23e98d9 test(repository): close L-1 integration-coverage gap for HealthCheck + RenewalPolicy
The coverage-gap audit flagged L-1 (P2): `HealthCheckRepository` (453 LOC,
11 methods) and `RenewalPolicyRepository` (289 LOC, 5 methods post-G-1 —
the audit's "92 lines, 2 methods" figure was stale) ship to production
with zero live-DB integration coverage. The existing `repo_test.go`
header self-documents the gap: "15 of 17 PostgreSQL repository files".

Operationally load-bearing piece: M48's scheduler calls
`HealthCheckRepository.ListDueForCheck` every tick to drive continuous
TLS health monitoring. A silent SQL regression there — wrong INTERVAL
math, NULL-handling slip, lost ORDER BY — would fail open: operator
adds endpoint → scheduler never picks it up → endpoint degrades in
production → no alert. The loop continues ticking and logs "processed
0 endpoints" normally, so the failure mode is operationally invisible.

Closure shape (test-only; no production code touched):

- internal/repository/postgres/health_check_test.go (new file, 7 tests)
  · TestHealthCheckRepository_CRUD
  · TestHealthCheckRepository_GetByEndpoint
  · TestHealthCheckRepository_List_Filters
  · TestHealthCheckRepository_ListDueForCheck  (the load-bearing one —
    seeds four rows with differing last_checked_at+interval
    relationships to NOW() plus one NULL-last_checked_at row,
    asserts the correct subset returns and ORDER BY last_checked_at
    ASC NULLS FIRST holds)
  · TestHealthCheckRepository_RecordHistory_GetHistory
  · TestHealthCheckRepository_PurgeHistory
  · TestHealthCheckRepository_GetSummary

- internal/repository/postgres/renewal_policy_test.go (new file, 3 tests)
  · TestRenewalPolicyRepository_CRUD  (exercises auto-generated
    rp-<slug(name)> PK, JSONB round-trip of [30,14,7,0] thresholds,
    UpdatedAt monotonic advance, ORDER BY name for List)
  · TestRenewalPolicyRepository_DuplicateName  (asserts
    errors.Is(err, repository.ErrRenewalPolicyDuplicateName) on both
    Create-name-unique and Update-name-unique collision paths, the pg
    23505 sentinel mapping)
  · TestRenewalPolicyRepository_DeleteInUse  (raw-INSERTs a
    managed_certificates row FK'ing the policy, asserts
    errors.Is(err, repository.ErrRenewalPolicyInUse) from pg 23503
    ON DELETE RESTRICT, cleans up, then asserts not-found surfaces
    distinctly)

- internal/repository/postgres/repo_test.go (one-line header flip)
  "covering 15 of 17 ... repository files" → "17 of 17"; added
  cross-reference pointing readers at the two sibling files.

Both new files use the existing getTestDB(t) + schema-per-test-isolation
convention and skip via testing.Short() in CI, matching M26 TICKET-003
scaffolding byte-for-byte. Repository/postgres is not in the CI
coverage-gate path (grep -nE "internal/repository/postgres"
.github/workflows/ci.yml → no hits), so adding test-only files cannot
regress gated coverage elsewhere.

Verification gates run locally (sandbox without Docker, so the -short
skip gate itself is what's exercised; operator runs the testcontainer
path locally):

  1.  go vet ./...                                              — clean
  2.  go build ./...                                            — clean
  3.  go test -short -count=1 ./...                             — clean
  4.  go test -race -short ./internal/repository/postgres/...   — clean
  5.  staticcheck                         — absent; CI checkset holds
  6.  govulncheck                         — skipped; test-only, no deps
  7.  per-layer coverage no-regression    — N/A; repo/pg not gated
  8.  tsc --noEmit                        — N/A; no frontend change
  9.  vitest run                          — N/A; no frontend change
  10. vite build                          — N/A; no frontend change
  11. OpenAPI lint                        — N/A; no spec change

No migration, no interface change, no production code diff. The
RenewalPolicyRepository drift between audit ("92 lines, 2 methods")
and HEAD (289 lines, 5 methods post-G-1) is documented honestly in
the audit report's Resolution Log, not papered over.

Closes: coverage-gap-audit L-1 (P2)
2026-04-20 20:39:06 +00:00
shankar0123 4cf5fcdb4f Merge branch 'fix/d1-cli-status-endpoint' 2026-04-20 19:41:03 +00:00
shankar0123 1ee67b7792 D-1: correct certctl-cli status endpoint path (/api/v1/health -> /health)
The CLI's GetStatus() was issuing GET /api/v1/health, but the real
liveness route is GET /health at internal/api/router/router.go:76
(mounted at root, not under /api/v1/). Every 'certctl-cli status'
invocation 404'd since M16b.

The regression was masked because TestClient_GetStatus encoded the
same wrong path on both sides of the contract -- the mock server
also dispatched on /api/v1/health -- so the production request
matched the test's buggy dispatch and the green bar hid the bug.

Two-line fix:
  - internal/cli/client.go:615: "/api/v1/health" -> "/health"
  - internal/cli/client_test.go:296: mock dispatch to match

Red receipt captured before the green fix: with the test fixture
corrected but production still wrong, TestClient_GetStatus fails
'parsing response: unexpected end of JSON input' (the client falls
through the mock's if/else to the default 200 OK empty body and
the JSON decoder chokes). After the production edit the test
passes.

GetStatus()'s response decoder is already compatible with the real
/health shape (graceful 'ok' check on health["status"], optional
health["timestamp"]). No interface change. No migration. No
frontend change. No OpenAPI delta -- /health is a root-level
liveness probe, not part of the /api/v1/ surface.
2026-04-20 19:40:58 +00:00
shankar0123 128d0eeaa8 Merge branch 'fix/g1-renewal-policies-api'
G-1: renewal-policies API + frontend FK-drift fix. Adds /api/v1/renewal-policies
CRUD backing the dropdown that managed_certificates.renewal_policy_id FKs into.
Three frontend call sites swapped from getPolicies() (pol-*, compliance rules)
to getRenewalPolicies() (rp-*, lifecycle policies). Validation bounds, pg
23503/23505 error mapping to HTTP 409, OpenAPI coverage, test suite.

No migration — renewal_policies table already exists from schema 000001.
2026-04-20 18:53:09 +00:00
shankar0123 9834b4e4a4 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
shankar0123 cab579368b Merge branch 'fix/audit-f001-f002-f003'
Closes F-001 (CRL scoped query via composite index), F-002 (digest error
body sanitization), and F-003 (ctx-aware sleep at three sites).

Verification: build, vet, race-short test sweep across all packages green.
govulncheck clean. golangci-lint run deferred — local environment's
golangci-lint is v1.64.8 built with go1.24 and rejects the go1.25.9
project; fresh install blocked by disk constraints. CI lane will cover it.
2026-04-20 16:52:00 +00:00
shankar0123 4e5522a999 F-001/F-002/F-003: CRL prefix-scan, digest error sanitization, ctx-aware sleeps
F-001 (P3): GenerateDERCRL scoped to issuer via composite index
  - Add RevocationRepository.ListByIssuer leveraging migration 000012's
    idx_certificate_revocations_issuer_serial composite index as a
    prefix-scan target. Previously CAOperationsSvc.GenerateDERCRL called
    ListAll() and filtered by IssuerID in Go — O(total revocations)
    regardless of how many revocations belonged to the target issuer.
  - Rewrite GenerateDERCRL to call ListByIssuer(ctx, issuerID) so PostgreSQL
    drives a prefix scan of the composite index. Drops the in-memory filter.
  - New regression test in ca_operations_test.go asserts the CRL hot path
    invokes ListByIssuer exactly once and never ListAll, and that the
    issuerID is threaded through correctly.

F-002 (P3): digest.go admin-auth endpoints no longer leak internal errors
  - PreviewDigest (GET /api/v1/digest/preview) and SendDigest
    (POST /api/v1/digest/send) previously wrote err.Error() into the HTTP
    response body on 500s. Replace with slog.Error server-side logging plus
    a generic "internal error" response body, matching the house pattern
    in certificates.go and export.go.

F-003 (P4): three blocking time.Sleep sites now honor ctx cancellation
  - internal/connector/issuer/acme/acme.go:672 (DNS-01 propagation wait)
    now runs under a select{case <-ctx.Done(): CleanUp + return ctx.Err();
    case <-time.After(d):} so graceful shutdown doesn't get stuck behind
    the propagation delay.
  - internal/connector/issuer/acme/acme.go:786 (dns-persist-01 propagation
    wait) same pattern, returns ctx.Err() on cancel.
  - cmd/agent/main.go:272 (polling backoff inside the heartbeat loop) now
    wraps the sleep in select{case <-ctx.Done(): continue; case <-time.After(backoff):}
    so the outer <-ctx.Done() case on the parent loop fires cleanly.

Verification: build, vet, and race-enabled short tests green across all
55+ packages. govulncheck reports zero vulnerabilities in the code path.
No migration needed — F-001 reuses the existing 000012 composite index.
No frontend changes.
2026-04-20 16:51:52 +00:00
29 changed files with 2945 additions and 78 deletions
+259
View File
@@ -42,6 +42,8 @@ tags:
description: Job queue — issuance, renewal, deployment, validation
- name: Policies
description: Policy rules and violation tracking
- name: RenewalPolicies
description: Lifecycle renewal policies (distinct from compliance policy rules above)
- name: Profiles
description: Certificate enrollment profiles with crypto constraints
- name: Teams
@@ -1528,6 +1530,137 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
# ─── Renewal Policies ────────────────────────────────────────────────
# G-1: lifecycle policies (rp-* ids, table renewal_policies). DISTINCT from
# /api/v1/policies above, which returns compliance rules (pol-* ids, table
# policy_rules). `managed_certificates.renewal_policy_id` FK points at
# renewal_policies(id) — populating that dropdown from /api/v1/policies
# caused 23503 FK violations; hence this endpoint.
/api/v1/renewal-policies:
get:
tags: [RenewalPolicies]
summary: List renewal policies
operationId: listRenewalPolicies
parameters:
- $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/per_page"
responses:
"200":
description: Paginated list of renewal policies
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PaginationEnvelope"
- type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/RenewalPolicy"
"500":
$ref: "#/components/responses/InternalError"
post:
tags: [RenewalPolicies]
summary: Create renewal policy
operationId: createRenewalPolicy
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RenewalPolicyCreateRequest"
responses:
"201":
description: Renewal policy created
content:
application/json:
schema:
$ref: "#/components/schemas/RenewalPolicy"
"400":
$ref: "#/components/responses/BadRequest"
"409":
description: Duplicate policy name
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/renewal-policies/{id}:
get:
tags: [RenewalPolicies]
summary: Get renewal policy
operationId: getRenewalPolicy
parameters:
- $ref: "#/components/parameters/resourceId"
responses:
"200":
description: Renewal policy details
content:
application/json:
schema:
$ref: "#/components/schemas/RenewalPolicy"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/InternalError"
put:
tags: [RenewalPolicies]
summary: Update renewal policy
operationId: updateRenewalPolicy
parameters:
- $ref: "#/components/parameters/resourceId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RenewalPolicyUpdateRequest"
responses:
"200":
description: Renewal policy updated
content:
application/json:
schema:
$ref: "#/components/schemas/RenewalPolicy"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Duplicate policy name
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/InternalError"
delete:
tags: [RenewalPolicies]
summary: Delete renewal policy
operationId: deleteRenewalPolicy
parameters:
- $ref: "#/components/parameters/resourceId"
responses:
"204":
description: Renewal policy deleted
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Policy in use by one or more certificates (FK restrict)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/InternalError"
# ─── Profiles ────────────────────────────────────────────────────────
/api/v1/profiles:
get:
@@ -3765,6 +3898,132 @@ components:
type: string
format: date-time
# ─── Renewal Policies ─────────────────────────────────────────────
# G-1: renewal_policies table — lifecycle policies, referenced by
# managed_certificates.renewal_policy_id ON DELETE RESTRICT. Distinct
# from PolicyRule above (compliance rules, table policy_rules).
RenewalPolicy:
type: object
required:
- id
- name
- renewal_window_days
- auto_renew
- max_retries
- retry_interval_seconds
- alert_thresholds_days
- created_at
- updated_at
properties:
id:
type: string
description: Human-readable ID, prefixed `rp-` (e.g., `rp-default`).
name:
type: string
description: Unique display name (UNIQUE in DB).
renewal_window_days:
type: integer
minimum: 1
maximum: 365
description: Days before expiry to trigger renewal.
auto_renew:
type: boolean
description: Whether renewal is triggered automatically by the scheduler.
max_retries:
type: integer
minimum: 0
maximum: 10
description: Maximum renewal retry attempts on failure.
retry_interval_seconds:
type: integer
minimum: 60
maximum: 86400
description: Seconds to wait between retry attempts.
alert_thresholds_days:
type: array
items:
type: integer
minimum: 0
maximum: 365
description: Days-before-expiry thresholds at which to emit alerts.
certificate_profile_id:
type: string
nullable: true
description: Optional certificate profile binding. Read-only at this endpoint; UI does not currently edit this field.
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
RenewalPolicyCreateRequest:
type: object
required:
- name
properties:
id:
type: string
description: Optional human-readable ID. Auto-generated from name when omitted.
name:
type: string
minLength: 1
maxLength: 255
renewal_window_days:
type: integer
minimum: 1
maximum: 365
default: 30
auto_renew:
type: boolean
default: true
max_retries:
type: integer
minimum: 0
maximum: 10
description: Required. Not defaulted — 0 is a valid operator choice.
retry_interval_seconds:
type: integer
minimum: 60
maximum: 86400
default: 3600
alert_thresholds_days:
type: array
items:
type: integer
minimum: 0
maximum: 365
default: [30, 14, 7, 0]
RenewalPolicyUpdateRequest:
type: object
description: Partial update. Omitted fields are left unchanged.
properties:
name:
type: string
minLength: 1
maxLength: 255
renewal_window_days:
type: integer
minimum: 1
maximum: 365
auto_renew:
type: boolean
max_retries:
type: integer
minimum: 0
maximum: 10
retry_interval_seconds:
type: integer
minimum: 60
maximum: 86400
alert_thresholds_days:
type: array
items:
type: integer
minimum: 0
maximum: 365
# ─── Profiles ────────────────────────────────────────────────────
CertificateProfile:
type: object
+8 -1
View File
@@ -269,7 +269,14 @@ func (a *Agent) Run(ctx context.Context) error {
a.logger.Warn("backing off due to consecutive failures",
"failures", a.consecutiveFailures,
"backoff", backoff.String())
time.Sleep(backoff)
// F-003: ctx-aware wait so graceful shutdown does not stall on
// a long backoff. If ctx cancels mid-backoff, return to the
// outer loop so the <-ctx.Done() case can trigger clean exit.
select {
case <-ctx.Done():
continue
case <-time.After(backoff):
}
}
a.pollForWork(ctx)
+10
View File
@@ -147,6 +147,11 @@ func main() {
auditService := service.NewAuditService(auditRepo)
policyService := service.NewPolicyService(policyRepo, auditService)
policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter
// G-1: RenewalPolicyService — distinct from PolicyService (compliance rules).
// Drives /api/v1/renewal-policies CRUD; the service layer owns slugify + validation,
// the repo layer owns sentinel translation for 23505 (name UNIQUE) and 23503
// (FK-RESTRICT against managed_certificates.renewal_policy_id).
renewalPolicyService := service.NewRenewalPolicyService(renewalPolicyRepo)
certificateService := service.NewCertificateService(certificateRepo, policyService, auditService)
notifierRegistry := make(map[string]service.Notifier)
@@ -368,6 +373,10 @@ func main() {
agentHandler := handler.NewAgentHandler(agentService)
jobHandler := handler.NewJobHandler(jobService)
policyHandler := handler.NewPolicyHandler(policyService)
// G-1: RenewalPolicyHandler — /api/v1/renewal-policies CRUD. Value-returning
// constructor matches the house pattern (PolicyHandler, IssuerHandler etc.);
// the registry stores it by value in HandlerRegistry.RenewalPolicies.
renewalPolicyHandler := handler.NewRenewalPolicyHandler(renewalPolicyService)
profileHandler := handler.NewProfileHandler(profileService)
teamHandler := handler.NewTeamHandler(teamService)
ownerHandler := handler.NewOwnerHandler(ownerService)
@@ -508,6 +517,7 @@ func main() {
Agents: agentHandler,
Jobs: jobHandler,
Policies: policyHandler,
RenewalPolicies: renewalPolicyHandler,
Profiles: profileHandler,
Teams: teamHandler,
Owners: ownerHandler,
+5 -2
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
)
@@ -39,7 +40,8 @@ func (h *DigestHandler) PreviewDigest(w http.ResponseWriter, r *http.Request) {
html, err := h.service.PreviewDigest(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
slog.Error("digest preview failed", "error", err.Error())
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
@@ -64,9 +66,10 @@ func (h *DigestHandler) SendDigest(w http.ResponseWriter, r *http.Request) {
}
if err := h.service.SendDigest(r.Context()); err != nil {
slog.Error("digest send failed", "error", err.Error())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
json.NewEncoder(w).Encode(map[string]string{"error": "internal error"})
return
}
+243
View File
@@ -0,0 +1,243 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// RenewalPolicyService defines the service interface for renewal policy
// operations. G-1: all methods take ctx so the handler can propagate
// request-scoped cancellation/deadlines through the full stack.
type RenewalPolicyService interface {
ListRenewalPolicies(ctx context.Context, page, perPage int) ([]domain.RenewalPolicy, int64, error)
GetRenewalPolicy(ctx context.Context, id string) (*domain.RenewalPolicy, error)
CreateRenewalPolicy(ctx context.Context, rp domain.RenewalPolicy) (*domain.RenewalPolicy, error)
UpdateRenewalPolicy(ctx context.Context, id string, rp domain.RenewalPolicy) (*domain.RenewalPolicy, error)
DeleteRenewalPolicy(ctx context.Context, id string) error
}
// RenewalPolicyHandler serves /api/v1/renewal-policies CRUD endpoints.
//
// G-1 design note: the service-level `ErrRenewalPolicyDuplicateName` /
// `ErrRenewalPolicyInUse` sentinels alias the repository sentinels (same var
// identity), so `errors.Is` walks transparently across layers. Delete/Update
// not-found detection intentionally uses a `strings.Contains(err.Error(),
// "not found")` substring check — the repo wraps `sql.ErrNoRows` as
// `fmt.Errorf("renewal policy not found: %s", id)` which strips the sentinel,
// and the handler red-tests' `ErrMockNotFound = errors.New("mock not found
// error")` follows the same substring convention.
type RenewalPolicyHandler struct {
svc RenewalPolicyService
}
// NewRenewalPolicyHandler constructs the handler with its service dependency.
// Returned by value to match the house pattern (PolicyHandler, IssuerHandler
// etc.) — the registry stores handlers by value in router.HandlerRegistry.
func NewRenewalPolicyHandler(svc RenewalPolicyService) RenewalPolicyHandler {
return RenewalPolicyHandler{svc: svc}
}
// ListRenewalPolicies lists all renewal policies (paginated).
// GET /api/v1/renewal-policies?page=1&per_page=50
func (h RenewalPolicyHandler) ListRenewalPolicies(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
policies, total, err := h.svc.ListRenewalPolicies(r.Context(), page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list renewal policies", requestID)
return
}
response := PagedResponse{
Data: policies,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetRenewalPolicy retrieves a single renewal policy by ID.
// GET /api/v1/renewal-policies/{id}
func (h RenewalPolicyHandler) GetRenewalPolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/renewal-policies/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Renewal policy ID is required", requestID)
return
}
id = parts[0]
policy, err := h.svc.GetRenewalPolicy(r.Context(), id)
if err != nil {
// Matches the PolicyHandler.GetPolicy convention: any error from the
// service surfaces as 404. The repo wraps sql.ErrNoRows as
// "renewal policy not found: %s" and there's no other expected failure
// mode on Get — the caller gets a clean 404.
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
return
}
JSON(w, http.StatusOK, policy)
}
// CreateRenewalPolicy inserts a new renewal policy.
// POST /api/v1/renewal-policies
//
// Error mapping:
// - invalid JSON / missing name → 400
// - ErrRenewalPolicyDuplicateName (pg 23505 on name UNIQUE) → 409
// - anything else → 500
func (h RenewalPolicyHandler) CreateRenewalPolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var rp domain.RenewalPolicy
if err := json.NewDecoder(r.Body).Decode(&rp); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
if err := ValidateRequired("name", rp.Name); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
created, err := h.svc.CreateRenewalPolicy(r.Context(), rp)
if err != nil {
if errors.Is(err, service.ErrRenewalPolicyDuplicateName) {
ErrorWithRequestID(w, http.StatusConflict, "A renewal policy with that name already exists", requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create renewal policy", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateRenewalPolicy replaces the fields of an existing renewal policy.
// PUT /api/v1/renewal-policies/{id}
//
// Error mapping:
// - invalid JSON / empty ID → 400
// - ErrRenewalPolicyDuplicateName → 409
// - error text contains "not found" → 404 (see struct doc comment re: substring check)
// - anything else → 500
func (h RenewalPolicyHandler) UpdateRenewalPolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/renewal-policies/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Renewal policy ID is required", requestID)
return
}
id = parts[0]
var rp domain.RenewalPolicy
if err := json.NewDecoder(r.Body).Decode(&rp); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateRenewalPolicy(r.Context(), id, rp)
if err != nil {
if errors.Is(err, service.ErrRenewalPolicyDuplicateName) {
ErrorWithRequestID(w, http.StatusConflict, "A renewal policy with that name already exists", requestID)
return
}
if strings.Contains(err.Error(), "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update renewal policy", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeleteRenewalPolicy removes a renewal policy.
// DELETE /api/v1/renewal-policies/{id}
//
// Error mapping:
// - empty ID (trailing slash) → 400
// - ErrRenewalPolicyInUse (pg 23503 FK-RESTRICT against managed_certificates.renewal_policy_id) → 409
// - error text contains "not found" → 404
// - anything else → 500
func (h RenewalPolicyHandler) DeleteRenewalPolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/renewal-policies/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Renewal policy ID is required", requestID)
return
}
id = parts[0]
if err := h.svc.DeleteRenewalPolicy(r.Context(), id); err != nil {
if errors.Is(err, service.ErrRenewalPolicyInUse) {
ErrorWithRequestID(w, http.StatusConflict, "Renewal policy is still referenced by managed certificates", requestID)
return
}
if strings.Contains(err.Error(), "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete renewal policy", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -0,0 +1,434 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// G-1 red tests: lock in the HTTP surface of /api/v1/renewal-policies before
// the production code exists. Every subtest here references a symbol that
// Phase 2b must introduce:
//
// - NewRenewalPolicyHandler(svc) (constructor)
// - RenewalPolicyService (service-layer interface, in this package)
// - handler.ListRenewalPolicies / GetRenewalPolicy / CreateRenewalPolicy /
// UpdateRenewalPolicy / DeleteRenewalPolicy
// - service.ErrRenewalPolicyDuplicateName (pg 23505 → 409 mapping)
// - service.ErrRenewalPolicyInUse (pg 23503 → 409 mapping)
// MockRenewalPolicyService is a mock implementation of RenewalPolicyService.
type MockRenewalPolicyService struct {
ListRenewalPoliciesFn func(page, perPage int) ([]domain.RenewalPolicy, int64, error)
GetRenewalPolicyFn func(id string) (*domain.RenewalPolicy, error)
CreateRenewalPolicyFn func(rp domain.RenewalPolicy) (*domain.RenewalPolicy, error)
UpdateRenewalPolicyFn func(id string, rp domain.RenewalPolicy) (*domain.RenewalPolicy, error)
DeleteRenewalPolicyFn func(id string) error
}
func (m *MockRenewalPolicyService) ListRenewalPolicies(_ context.Context, page, perPage int) ([]domain.RenewalPolicy, int64, error) {
if m.ListRenewalPoliciesFn != nil {
return m.ListRenewalPoliciesFn(page, perPage)
}
return nil, 0, nil
}
func (m *MockRenewalPolicyService) GetRenewalPolicy(_ context.Context, id string) (*domain.RenewalPolicy, error) {
if m.GetRenewalPolicyFn != nil {
return m.GetRenewalPolicyFn(id)
}
return nil, nil
}
func (m *MockRenewalPolicyService) CreateRenewalPolicy(_ context.Context, rp domain.RenewalPolicy) (*domain.RenewalPolicy, error) {
if m.CreateRenewalPolicyFn != nil {
return m.CreateRenewalPolicyFn(rp)
}
return nil, nil
}
func (m *MockRenewalPolicyService) UpdateRenewalPolicy(_ context.Context, id string, rp domain.RenewalPolicy) (*domain.RenewalPolicy, error) {
if m.UpdateRenewalPolicyFn != nil {
return m.UpdateRenewalPolicyFn(id, rp)
}
return nil, nil
}
func (m *MockRenewalPolicyService) DeleteRenewalPolicy(_ context.Context, id string) error {
if m.DeleteRenewalPolicyFn != nil {
return m.DeleteRenewalPolicyFn(id)
}
return nil
}
// ----- List -----
func TestListRenewalPolicies_Success(t *testing.T) {
now := time.Now()
rp1 := domain.RenewalPolicy{
ID: "rp-default", Name: "Default", RenewalWindowDays: 30,
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
CreatedAt: now, UpdatedAt: now,
}
rp2 := domain.RenewalPolicy{
ID: "rp-urgent", Name: "Urgent", RenewalWindowDays: 7,
MaxRetries: 5, RetryInterval: 600, AutoRenew: true,
CreatedAt: now, UpdatedAt: now,
}
mock := &MockRenewalPolicyService{
ListRenewalPoliciesFn: func(page, perPage int) ([]domain.RenewalPolicy, int64, error) {
return []domain.RenewalPolicy{rp1, rp2}, 2, nil
},
}
handler := NewRenewalPolicyHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/renewal-policies", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListRenewalPolicies(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var resp PagedResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Total != 2 {
t.Errorf("expected total 2, got %d", resp.Total)
}
}
func TestListRenewalPolicies_ServiceError(t *testing.T) {
mock := &MockRenewalPolicyService{
ListRenewalPoliciesFn: func(page, perPage int) ([]domain.RenewalPolicy, int64, error) {
return nil, 0, ErrMockServiceFailed
},
}
handler := NewRenewalPolicyHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/renewal-policies", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListRenewalPolicies(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", w.Code)
}
}
func TestListRenewalPolicies_MethodNotAllowed(t *testing.T) {
handler := NewRenewalPolicyHandler(&MockRenewalPolicyService{})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/renewal-policies", nil)
w := httptest.NewRecorder()
handler.ListRenewalPolicies(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status 405, got %d", w.Code)
}
}
// ----- Get -----
func TestGetRenewalPolicy_Success(t *testing.T) {
now := time.Now()
mock := &MockRenewalPolicyService{
GetRenewalPolicyFn: func(id string) (*domain.RenewalPolicy, error) {
return &domain.RenewalPolicy{
ID: id, Name: "Default", RenewalWindowDays: 30,
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
CreatedAt: now, UpdatedAt: now,
}, nil
},
}
handler := NewRenewalPolicyHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/renewal-policies/rp-default", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetRenewalPolicy(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
}
func TestGetRenewalPolicy_NotFound(t *testing.T) {
mock := &MockRenewalPolicyService{
GetRenewalPolicyFn: func(id string) (*domain.RenewalPolicy, error) {
return nil, ErrMockNotFound
},
}
handler := NewRenewalPolicyHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/renewal-policies/nonexistent", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetRenewalPolicy(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", w.Code)
}
}
// ----- Create -----
func TestCreateRenewalPolicy_Success(t *testing.T) {
now := time.Now()
mock := &MockRenewalPolicyService{
CreateRenewalPolicyFn: func(rp domain.RenewalPolicy) (*domain.RenewalPolicy, error) {
rp.ID = "rp-new"
rp.CreatedAt = now
rp.UpdatedAt = now
return &rp, nil
},
}
body := map[string]interface{}{
"name": "New Policy",
"renewal_window_days": 30,
"max_retries": 3,
"retry_interval_seconds": 3600,
"auto_renew": true,
}
bodyBytes, _ := json.Marshal(body)
handler := NewRenewalPolicyHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/renewal-policies", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateRenewalPolicy(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d", w.Code)
}
}
func TestCreateRenewalPolicy_MissingName(t *testing.T) {
body := map[string]interface{}{
"renewal_window_days": 30,
"max_retries": 3,
"retry_interval_seconds": 3600,
}
bodyBytes, _ := json.Marshal(body)
handler := NewRenewalPolicyHandler(&MockRenewalPolicyService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/renewal-policies", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateRenewalPolicy(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestCreateRenewalPolicy_InvalidJSON(t *testing.T) {
handler := NewRenewalPolicyHandler(&MockRenewalPolicyService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/renewal-policies", bytes.NewReader([]byte("not json")))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateRenewalPolicy(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestCreateRenewalPolicy_DuplicateName(t *testing.T) {
// Service bubbles up ErrRenewalPolicyDuplicateName (pg 23505) → handler maps to 409.
mock := &MockRenewalPolicyService{
CreateRenewalPolicyFn: func(rp domain.RenewalPolicy) (*domain.RenewalPolicy, error) {
return nil, service.ErrRenewalPolicyDuplicateName
},
}
body := map[string]interface{}{
"name": "Duplicate",
"renewal_window_days": 30,
"max_retries": 3,
"retry_interval_seconds": 3600,
}
bodyBytes, _ := json.Marshal(body)
handler := NewRenewalPolicyHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/renewal-policies", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateRenewalPolicy(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected status 409 on duplicate name, got %d", w.Code)
}
}
func TestCreateRenewalPolicy_MethodNotAllowed(t *testing.T) {
handler := NewRenewalPolicyHandler(&MockRenewalPolicyService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/renewal-policies", nil)
w := httptest.NewRecorder()
handler.CreateRenewalPolicy(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status 405, got %d", w.Code)
}
}
// ----- Update -----
func TestUpdateRenewalPolicy_Success(t *testing.T) {
now := time.Now()
mock := &MockRenewalPolicyService{
UpdateRenewalPolicyFn: func(id string, rp domain.RenewalPolicy) (*domain.RenewalPolicy, error) {
return &domain.RenewalPolicy{
ID: id, Name: rp.Name, RenewalWindowDays: rp.RenewalWindowDays,
MaxRetries: rp.MaxRetries, RetryInterval: rp.RetryInterval,
AutoRenew: rp.AutoRenew,
CreatedAt: now, UpdatedAt: now,
}, nil
},
}
body := map[string]interface{}{
"name": "Updated Policy",
"renewal_window_days": 45,
"max_retries": 5,
"retry_interval_seconds": 1800,
"auto_renew": true,
}
bodyBytes, _ := json.Marshal(body)
handler := NewRenewalPolicyHandler(mock)
req := httptest.NewRequest(http.MethodPut, "/api/v1/renewal-policies/rp-default", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.UpdateRenewalPolicy(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
}
func TestUpdateRenewalPolicy_NotFound(t *testing.T) {
mock := &MockRenewalPolicyService{
UpdateRenewalPolicyFn: func(id string, rp domain.RenewalPolicy) (*domain.RenewalPolicy, error) {
return nil, ErrMockNotFound
},
}
body := map[string]interface{}{
"name": "Updated",
"renewal_window_days": 30,
"max_retries": 3,
"retry_interval_seconds": 3600,
}
bodyBytes, _ := json.Marshal(body)
handler := NewRenewalPolicyHandler(mock)
req := httptest.NewRequest(http.MethodPut, "/api/v1/renewal-policies/rp-missing", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.UpdateRenewalPolicy(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", w.Code)
}
}
// ----- Delete -----
func TestDeleteRenewalPolicy_Success(t *testing.T) {
var deletedID string
mock := &MockRenewalPolicyService{
DeleteRenewalPolicyFn: func(id string) error {
deletedID = id
return nil
},
}
handler := NewRenewalPolicyHandler(mock)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/renewal-policies/rp-default", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.DeleteRenewalPolicy(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", w.Code)
}
if deletedID != "rp-default" {
t.Errorf("expected deleted ID 'rp-default', got '%s'", deletedID)
}
}
func TestDeleteRenewalPolicy_NotFound(t *testing.T) {
mock := &MockRenewalPolicyService{
DeleteRenewalPolicyFn: func(id string) error {
return ErrMockNotFound
},
}
handler := NewRenewalPolicyHandler(mock)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/renewal-policies/rp-missing", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.DeleteRenewalPolicy(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", w.Code)
}
}
func TestDeleteRenewalPolicy_InUseConflict(t *testing.T) {
// Service bubbles up ErrRenewalPolicyInUse (pg 23503 FK-RESTRICT) → handler maps to 409.
mock := &MockRenewalPolicyService{
DeleteRenewalPolicyFn: func(id string) error {
return service.ErrRenewalPolicyInUse
},
}
handler := NewRenewalPolicyHandler(mock)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/renewal-policies/rp-active", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.DeleteRenewalPolicy(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected status 409 on in-use conflict, got %d", w.Code)
}
}
func TestDeleteRenewalPolicy_EmptyID(t *testing.T) {
handler := NewRenewalPolicyHandler(&MockRenewalPolicyService{})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/renewal-policies/", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.DeleteRenewalPolicy(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
+15 -2
View File
@@ -65,8 +65,9 @@ type HandlerRegistry struct {
Verification handler.VerificationHandler
Export handler.ExportHandler
Digest handler.DigestHandler
HealthChecks *handler.HealthCheckHandler
BulkRevocation handler.BulkRevocationHandler
HealthChecks *handler.HealthCheckHandler
BulkRevocation handler.BulkRevocationHandler
RenewalPolicies handler.RenewalPolicyHandler
}
// RegisterHandlers sets up all API routes with their handlers.
@@ -167,6 +168,18 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
r.Register("DELETE /api/v1/policies/{id}", http.HandlerFunc(reg.Policies.DeletePolicy))
r.Register("GET /api/v1/policies/{id}/violations", http.HandlerFunc(reg.Policies.ListViolations))
// Renewal Policies routes: /api/v1/renewal-policies
// G-1: fixes frontend FK drift — OnboardingWizard + CertificatesPage dropdowns
// were previously populating renewal_policy_id from /api/v1/policies (compliance
// rules, pol-* IDs), violating FK managed_certificates.renewal_policy_id →
// renewal_policies(id) ON DELETE RESTRICT. This block is the backend half; the
// frontend half swaps getPolicies → getRenewalPolicies at 3 call sites.
r.Register("GET /api/v1/renewal-policies", http.HandlerFunc(reg.RenewalPolicies.ListRenewalPolicies))
r.Register("POST /api/v1/renewal-policies", http.HandlerFunc(reg.RenewalPolicies.CreateRenewalPolicy))
r.Register("GET /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.GetRenewalPolicy))
r.Register("PUT /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.UpdateRenewalPolicy))
r.Register("DELETE /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.DeleteRenewalPolicy))
// Profiles routes: /api/v1/profiles
r.Register("GET /api/v1/profiles", http.HandlerFunc(reg.Profiles.ListProfiles))
r.Register("POST /api/v1/profiles", http.HandlerFunc(reg.Profiles.CreateProfile))
+1 -1
View File
@@ -612,7 +612,7 @@ func (c *Client) CancelJob(id string) error {
// GetStatus retrieves server health and summary stats.
func (c *Client) GetStatus() error {
resp, err := c.do("GET", "/api/v1/health", nil, nil)
resp, err := c.do("GET", "/health", nil, nil)
if err != nil {
return err
}
+1 -1
View File
@@ -293,7 +293,7 @@ func TestClient_GetStatus(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/health" {
if r.URL.Path == "/health" {
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "healthy",
"timestamp": time.Now().Format(time.RFC3339),
+13 -4
View File
@@ -664,12 +664,17 @@ func (c *Connector) solveAuthorizationsDNS01(ctx context.Context, authzURLs []st
return fmt.Errorf("failed to present DNS record for %s: %w", domain, err)
}
// Wait for DNS propagation
// Wait for DNS propagation (ctx-aware so graceful shutdown can interrupt — F-003)
propagationWait := time.Duration(c.config.DNSPropagationWait) * time.Second
c.logger.Info("waiting for DNS propagation",
"domain", domain,
"wait_seconds", c.config.DNSPropagationWait)
time.Sleep(propagationWait)
select {
case <-ctx.Done():
_ = c.dnsSolver.CleanUp(ctx, domain, dnsChallenge.Token, keyAuth)
return ctx.Err()
case <-time.After(propagationWait):
}
// Tell the CA we're ready
if _, err := c.client.Accept(ctx, dnsChallenge); err != nil {
@@ -773,12 +778,16 @@ func (c *Connector) solveAuthorizationsDNSPersist01(ctx context.Context, authzUR
return fmt.Errorf("failed to create persistent DNS record for %s: %w", domain, err)
}
// Wait for DNS propagation
// Wait for DNS propagation (ctx-aware so graceful shutdown can interrupt — F-003)
propagationWait := time.Duration(c.config.DNSPropagationWait) * time.Second
c.logger.Info("waiting for DNS propagation",
"domain", domain,
"wait_seconds", c.config.DNSPropagationWait)
time.Sleep(propagationWait)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(propagationWait):
}
// Tell the CA we're ready
if _, err := c.client.Accept(ctx, persistChallenge); err != nil {
+29
View File
@@ -1151,6 +1151,25 @@ func (m *mockRenewalPolicyRepository) List(ctx context.Context) ([]*domain.Renew
return policies, nil
}
// Create/Update/Delete satisfy the G-1 interface extension. The integration
// harness never drives the CRUD endpoints directly — these methods exist
// purely for interface compliance so the binary still builds.
func (m *mockRenewalPolicyRepository) Create(ctx context.Context, policy *domain.RenewalPolicy) error {
m.policies[policy.ID] = policy
return nil
}
func (m *mockRenewalPolicyRepository) Update(ctx context.Context, id string, policy *domain.RenewalPolicy) error {
policy.ID = id
m.policies[id] = policy
return nil
}
func (m *mockRenewalPolicyRepository) Delete(ctx context.Context, id string) error {
delete(m.policies, id)
return nil
}
type mockIssuerRepository struct {
issuers map[string]*domain.Issuer
}
@@ -1371,6 +1390,16 @@ func (m *mockRevocationRepository) ListAll(ctx context.Context) ([]*domain.Certi
return m.revocations, nil
}
func (m *mockRevocationRepository) ListByIssuer(ctx context.Context, issuerID string) ([]*domain.CertificateRevocation, error) {
var result []*domain.CertificateRevocation
for _, r := range m.revocations {
if r.IssuerID == issuerID {
result = append(result, r)
}
}
return result, nil
}
func (m *mockRevocationRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.CertificateRevocation, error) {
var result []*domain.CertificateRevocation
for _, r := range m.revocations {
+49 -2
View File
@@ -2,11 +2,27 @@ package repository
import (
"context"
"errors"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// Repository-level sentinel errors. Repositories (primarily the postgres
// implementation) translate RDBMS-specific errors into these typed envelopes
// so the service/handler layers can branch with errors.Is without importing
// lib/pq or care about SQLSTATE codes.
//
// G-1: renewal-policy sentinels — DuplicateName → HTTP 409 (pg 23505 on
// renewal_policies.name UNIQUE), InUse → HTTP 409 (pg 23503 on the FK from
// managed_certificates.renewal_policy_id to renewal_policies.id with ON
// DELETE RESTRICT). Both map onto the same 409 status but with distinct
// messages so operators can tell them apart.
var (
ErrRenewalPolicyDuplicateName = errors.New("renewal policy name already exists")
ErrRenewalPolicyInUse = errors.New("renewal policy is still referenced by managed certificates")
)
// CertificateRepository defines operations for managing certificates.
type CertificateRepository interface {
// List returns a paginated list of certificates matching the filter criteria.
@@ -47,8 +63,15 @@ type RevocationRepository interface {
// protocol endpoints carry it in the request path; RFC 5280 §5.2.3 guarantees
// uniqueness only within a single issuer.
GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.CertificateRevocation, error)
// ListAll returns all revocations, ordered by revocation time (for CRL generation).
// ListAll returns all revocations, ordered by revocation time (for global
// revocation admin views). CRL generation should prefer ListByIssuer to
// avoid loading and discarding rows that belong to other issuers.
ListAll(ctx context.Context) ([]*domain.CertificateRevocation, error)
// ListByIssuer returns revocations for a single issuer, ordered by revocation
// time (for CRL generation). Pushing the issuer filter into the query lets
// the migration 000012 composite index (issuer_id, serial_number) drive a
// prefix scan instead of a full table read + in-memory filter.
ListByIssuer(ctx context.Context, issuerID string) ([]*domain.CertificateRevocation, error)
// ListByCertificate returns all revocations for a certificate.
ListByCertificate(ctx context.Context, certID string) ([]*domain.CertificateRevocation, error)
// MarkIssuerNotified updates the issuer_notified flag for a revocation.
@@ -251,11 +274,35 @@ type JobRepository interface {
}
// RenewalPolicyRepository defines operations for managing renewal policies.
//
// G-1: extended with Create/Update/Delete so the new /api/v1/renewal-policies
// CRUD surface has a repo contract to lean on. Delete must map the PostgreSQL
// 23503 (foreign_key_violation on managed_certificates.renewal_policy_id
// REFERENCES renewal_policies(id) ON DELETE RESTRICT) onto the typed
// ErrRenewalPolicyInUse sentinel so the handler can emit a 409 Conflict
// instead of an opaque 500. Create/Update map PostgreSQL 23505
// (unique_violation on renewal_policies.name) onto ErrRenewalPolicyDuplicateName
// for the same 409 Conflict reason.
//
// List stays single-shot (no pagination params) because the production row
// count is in the single digits — the service layer paginates/sorts in Go.
// Changing the signature would churn every mock without functional benefit.
type RenewalPolicyRepository interface {
// Get retrieves a renewal policy by ID.
Get(ctx context.Context, id string) (*domain.RenewalPolicy, error)
// List returns all renewal policies.
// List returns all renewal policies, ordered by name.
List(ctx context.Context) ([]*domain.RenewalPolicy, error)
// Create inserts a new renewal policy. The caller is responsible for
// populating Name; Create auto-generates ID (as rp-<slug(name)>) if empty.
// Returns ErrRenewalPolicyDuplicateName on pg 23505.
Create(ctx context.Context, policy *domain.RenewalPolicy) error
// Update modifies an existing renewal policy in-place. Returns
// sql.ErrNoRows-wrapped error when id is unknown, or
// ErrRenewalPolicyDuplicateName on pg 23505 (name collision with another row).
Update(ctx context.Context, id string, policy *domain.RenewalPolicy) error
// Delete removes a renewal policy. Returns ErrRenewalPolicyInUse when the
// policy is still referenced by rows in managed_certificates (pg 23503).
Delete(ctx context.Context, id string) error
}
// PolicyRepository defines operations for managing compliance policies and violations.
@@ -0,0 +1,453 @@
package postgres_test
// Integration tests for HealthCheckRepository (M48). Closes the L-1
// coverage gap flagged in coverage-gap-audit.md: the 453-line repository
// shipped in M48 had zero live-DB tests, leaving 11 methods — including
// the time-sensitive ListDueForCheck, PurgeHistory, and GetSummary —
// without migration-pinned regression protection. These tests exercise
// every method against a real Postgres 16 container through the same
// schema-per-test harness used by repo_test.go.
import (
"context"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
"github.com/shankar0123/certctl/internal/repository/postgres"
)
// newHealthCheck builds a minimal EndpointHealthCheck the repository will
// accept. All time-pointer fields are left nil so callers can override
// exactly the bits each subtest cares about — Create stores nil pointers
// as NULL, which is what ListDueForCheck's `last_checked_at IS NULL`
// branch relies on.
func newHealthCheck(id, endpoint string, status domain.HealthStatus, enabled bool) *domain.EndpointHealthCheck {
now := time.Now().UTC().Truncate(time.Microsecond)
return &domain.EndpointHealthCheck{
ID: id,
Endpoint: endpoint,
Status: status,
DegradedThreshold: 2,
DownThreshold: 5,
CheckIntervalSecs: 300,
Enabled: enabled,
CreatedAt: now,
UpdatedAt: now,
}
}
// TestHealthCheckRepository_CRUD covers Create → Get → Update → Delete on
// the nominal path. Also verifies the sql.NullTime round-trip: a check
// created without timestamps comes back with nil pointers (not
// zero-valued time.Time) so downstream Go code can distinguish "never
// probed" from "probed at epoch".
func TestHealthCheckRepository_CRUD(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewHealthCheckRepository(db)
ctx := context.Background()
check := newHealthCheck("hc-crud", "example.com:443", domain.HealthStatusHealthy, true)
check.ExpectedFingerprint = "sha256:expected"
check.ResponseTimeMs = 42
if err := repo.Create(ctx, check); err != nil {
t.Fatalf("Create failed: %v", err)
}
got, err := repo.Get(ctx, "hc-crud")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got.Endpoint != "example.com:443" {
t.Errorf("Endpoint = %q, want example.com:443", got.Endpoint)
}
if got.Status != domain.HealthStatusHealthy {
t.Errorf("Status = %q, want %q", got.Status, domain.HealthStatusHealthy)
}
if got.ExpectedFingerprint != "sha256:expected" {
t.Errorf("ExpectedFingerprint = %q, want sha256:expected", got.ExpectedFingerprint)
}
if got.CheckIntervalSecs != 300 {
t.Errorf("CheckIntervalSecs = %d, want 300", got.CheckIntervalSecs)
}
if got.LastCheckedAt != nil {
t.Errorf("LastCheckedAt = %v, want nil (never probed)", got.LastCheckedAt)
}
// Update: status transition + observed fingerprint assignment.
// Update() rewrites UpdatedAt to time.Now() regardless of what we
// send, so record the pre-call timestamp to assert monotonic advance.
preUpdate := got.UpdatedAt
time.Sleep(2 * time.Millisecond) // ensure a measurable delta
got.Status = domain.HealthStatusDegraded
got.ObservedFingerprint = "sha256:observed"
got.ConsecutiveFailures = 2
if err := repo.Update(ctx, got); err != nil {
t.Fatalf("Update failed: %v", err)
}
got2, err := repo.Get(ctx, "hc-crud")
if err != nil {
t.Fatalf("Get after Update failed: %v", err)
}
if got2.Status != domain.HealthStatusDegraded {
t.Errorf("Status after Update = %q, want %q", got2.Status, domain.HealthStatusDegraded)
}
if got2.ObservedFingerprint != "sha256:observed" {
t.Errorf("ObservedFingerprint after Update = %q, want sha256:observed", got2.ObservedFingerprint)
}
if got2.ConsecutiveFailures != 2 {
t.Errorf("ConsecutiveFailures after Update = %d, want 2", got2.ConsecutiveFailures)
}
if !got2.UpdatedAt.After(preUpdate) {
t.Errorf("UpdatedAt did not advance: pre=%v post=%v", preUpdate, got2.UpdatedAt)
}
if err := repo.Delete(ctx, "hc-crud"); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := repo.Get(ctx, "hc-crud"); err == nil {
t.Errorf("Get after Delete returned nil error, want not-found")
}
}
// TestHealthCheckRepository_GetByEndpoint verifies the secondary lookup
// path used by AutoCreateFromDeployment to decide whether to INSERT or
// UPDATE. Missing endpoints return an error (not a nil cert) so the
// service layer can branch safely.
func TestHealthCheckRepository_GetByEndpoint(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewHealthCheckRepository(db)
ctx := context.Background()
check := newHealthCheck("hc-byep", "svc.internal:443", domain.HealthStatusHealthy, true)
if err := repo.Create(ctx, check); err != nil {
t.Fatalf("Create failed: %v", err)
}
got, err := repo.GetByEndpoint(ctx, "svc.internal:443")
if err != nil {
t.Fatalf("GetByEndpoint failed: %v", err)
}
if got.ID != "hc-byep" {
t.Errorf("ID = %q, want hc-byep", got.ID)
}
if _, err := repo.GetByEndpoint(ctx, "never-seen.example.com:443"); err == nil {
t.Errorf("GetByEndpoint on unknown endpoint returned nil error")
}
}
// TestHealthCheckRepository_List_Filters seeds rows across the filter
// axes (status, certificate_id, enabled) and asserts each branch of the
// WHERE builder, plus the Page/PerPage pagination shim.
func TestHealthCheckRepository_List_Filters(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewHealthCheckRepository(db)
ctx := context.Background()
// Create prereq managed certificate so certificate_id FK can be
// populated on one row — proves the filter path joins on a real ID.
ownerID, teamID, issuerID, policyID := insertCertPrereqsRaw(t, db, ctx, "hclist")
certID := "mc-hc-list"
now := time.Now().UTC().Truncate(time.Microsecond)
if _, 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)`,
certID, "hc-list-cert", "hc.example.com", "{}", "production",
ownerID, teamID, issuerID, policyID,
string(domain.CertificateStatusActive), now.Add(90*24*time.Hour), "{}",
now, now); err != nil {
t.Fatalf("seed managed_certificate: %v", err)
}
// 4 rows: healthy+enabled+cert, degraded+enabled, down+disabled, unknown+enabled.
rows := []*domain.EndpointHealthCheck{
newHealthCheck("hc-list-1", "a.example.com:443", domain.HealthStatusHealthy, true),
newHealthCheck("hc-list-2", "b.example.com:443", domain.HealthStatusDegraded, true),
newHealthCheck("hc-list-3", "c.example.com:443", domain.HealthStatusDown, false),
newHealthCheck("hc-list-4", "d.example.com:443", domain.HealthStatusUnknown, true),
}
rows[0].CertificateID = &certID
for _, r := range rows {
if err := repo.Create(ctx, r); err != nil {
t.Fatalf("Create %s: %v", r.ID, err)
}
}
// Filter: status=healthy → 1 result.
got, total, err := repo.List(ctx, &repository.HealthCheckFilter{Status: string(domain.HealthStatusHealthy)})
if err != nil {
t.Fatalf("List status=healthy: %v", err)
}
if total != 1 || len(got) != 1 || got[0].ID != "hc-list-1" {
t.Errorf("status=healthy: total=%d rows=%d want 1/1 with hc-list-1", total, len(got))
}
// Filter: certificate_id → 1 result.
got, total, err = repo.List(ctx, &repository.HealthCheckFilter{CertificateID: certID})
if err != nil {
t.Fatalf("List certificate_id: %v", err)
}
if total != 1 || len(got) != 1 || got[0].ID != "hc-list-1" {
t.Errorf("certificate_id filter: total=%d rows=%d want 1/1", total, len(got))
}
// Filter: enabled=false → 1 result.
disabled := false
got, total, err = repo.List(ctx, &repository.HealthCheckFilter{Enabled: &disabled})
if err != nil {
t.Fatalf("List enabled=false: %v", err)
}
if total != 1 || len(got) != 1 || got[0].ID != "hc-list-3" {
t.Errorf("enabled=false: total=%d rows=%d want 1/1 with hc-list-3", total, len(got))
}
// Pagination: per_page=2 → first page has 2, total reflects all 4.
got, total, err = repo.List(ctx, &repository.HealthCheckFilter{Page: 1, PerPage: 2})
if err != nil {
t.Fatalf("List paginated: %v", err)
}
if total != 4 {
t.Errorf("paginated total = %d, want 4", total)
}
if len(got) != 2 {
t.Errorf("paginated rows = %d, want 2", len(got))
}
}
// TestHealthCheckRepository_ListDueForCheck seeds all four branches of
// the WHERE clause — (a) enabled+null → due, (b) enabled+past-due → due,
// (c) enabled+recent → not due, (d) disabled+null → excluded — and
// asserts the ORDER BY last_checked_at NULLS FIRST, ASC ordering.
//
// This is the hot path the scheduler's 8th loop hits every 60 seconds;
// a correctness regression here silently fails every probe.
func TestHealthCheckRepository_ListDueForCheck(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewHealthCheckRepository(db)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
pastDue := now.Add(-10 * time.Minute) // > 300s ago, enabled → due
recent := now.Add(-30 * time.Second) // < 300s ago, enabled → not due
// (a) enabled + null last_checked_at — NULLS FIRST puts this first
a := newHealthCheck("hc-due-a", "a.example.com:443", domain.HealthStatusUnknown, true)
// (b) enabled + past-due last_checked_at
b := newHealthCheck("hc-due-b", "b.example.com:443", domain.HealthStatusHealthy, true)
b.LastCheckedAt = &pastDue
// (c) enabled + recent last_checked_at — must NOT appear
c := newHealthCheck("hc-due-c", "c.example.com:443", domain.HealthStatusHealthy, true)
c.LastCheckedAt = &recent
// (d) disabled + null last_checked_at — must NOT appear
d := newHealthCheck("hc-due-d", "d.example.com:443", domain.HealthStatusUnknown, false)
for _, r := range []*domain.EndpointHealthCheck{a, b, c, d} {
if err := repo.Create(ctx, r); err != nil {
t.Fatalf("Create %s: %v", r.ID, err)
}
}
due, err := repo.ListDueForCheck(ctx)
if err != nil {
t.Fatalf("ListDueForCheck: %v", err)
}
if len(due) != 2 {
ids := make([]string, 0, len(due))
for _, r := range due {
ids = append(ids, r.ID)
}
t.Fatalf("due rows = %d (%v), want exactly 2 (hc-due-a, hc-due-b)", len(due), ids)
}
// NULLS FIRST: variant (a) should precede variant (b).
if due[0].ID != "hc-due-a" {
t.Errorf("due[0].ID = %q, want hc-due-a (NULLS FIRST)", due[0].ID)
}
if due[1].ID != "hc-due-b" {
t.Errorf("due[1].ID = %q, want hc-due-b", due[1].ID)
}
// Sanity: neither excluded row leaked through.
for _, r := range due {
if r.ID == "hc-due-c" {
t.Errorf("recent-probed row hc-due-c leaked into due set")
}
if r.ID == "hc-due-d" {
t.Errorf("disabled row hc-due-d leaked into due set")
}
}
}
// TestHealthCheckRepository_RecordHistory_GetHistory asserts FIFO
// insertion with DESC retrieval (most-recent-first) and the explicit
// limit clamp.
func TestHealthCheckRepository_RecordHistory_GetHistory(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewHealthCheckRepository(db)
ctx := context.Background()
parent := newHealthCheck("hc-hist", "hist.example.com:443", domain.HealthStatusHealthy, true)
if err := repo.Create(ctx, parent); err != nil {
t.Fatalf("Create parent: %v", err)
}
base := time.Now().UTC().Truncate(time.Microsecond)
entries := []*domain.HealthHistoryEntry{
{ID: "hh-1", HealthCheckID: "hc-hist", Status: string(domain.HealthStatusHealthy), ResponseTimeMs: 10, CheckedAt: base.Add(-3 * time.Minute)},
{ID: "hh-2", HealthCheckID: "hc-hist", Status: string(domain.HealthStatusDegraded), ResponseTimeMs: 20, CheckedAt: base.Add(-2 * time.Minute)},
{ID: "hh-3", HealthCheckID: "hc-hist", Status: string(domain.HealthStatusHealthy), ResponseTimeMs: 30, CheckedAt: base.Add(-1 * time.Minute)},
}
for _, e := range entries {
if err := repo.RecordHistory(ctx, e); err != nil {
t.Fatalf("RecordHistory %s: %v", e.ID, err)
}
}
// limit=2 → newest 2 in DESC order: hh-3, hh-2.
got, err := repo.GetHistory(ctx, "hc-hist", 2)
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
if len(got) != 2 {
t.Fatalf("rows = %d, want 2", len(got))
}
if got[0].ID != "hh-3" || got[1].ID != "hh-2" {
t.Errorf("order = [%s, %s], want [hh-3, hh-2]", got[0].ID, got[1].ID)
}
// limit=0 → default 100 → returns all 3.
got, err = repo.GetHistory(ctx, "hc-hist", 0)
if err != nil {
t.Fatalf("GetHistory limit=0: %v", err)
}
if len(got) != 3 {
t.Errorf("limit=0 rows = %d, want 3", len(got))
}
}
// TestHealthCheckRepository_PurgeHistory exercises the retention-sweep
// hot path (scheduler calls this once/day). 5 past + 5 future straddling
// the cutoff exposes both sides of the < comparator — an off-by-one
// regression here would either nuke live data or skip rows the retention
// policy was meant to remove.
func TestHealthCheckRepository_PurgeHistory(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewHealthCheckRepository(db)
ctx := context.Background()
parent := newHealthCheck("hc-purge", "purge.example.com:443", domain.HealthStatusHealthy, true)
if err := repo.Create(ctx, parent); err != nil {
t.Fatalf("Create parent: %v", err)
}
cutoff := time.Now().UTC().Truncate(time.Microsecond)
// 5 rows BEFORE cutoff (should be purged).
for i := 0; i < 5; i++ {
e := &domain.HealthHistoryEntry{
ID: "hh-past-" + string(rune('0'+i)),
HealthCheckID: "hc-purge",
Status: string(domain.HealthStatusHealthy),
CheckedAt: cutoff.Add(time.Duration(-10-i) * time.Minute),
}
if err := repo.RecordHistory(ctx, e); err != nil {
t.Fatalf("RecordHistory past %d: %v", i, err)
}
}
// 5 rows AFTER cutoff (should remain).
for i := 0; i < 5; i++ {
e := &domain.HealthHistoryEntry{
ID: "hh-future-" + string(rune('0'+i)),
HealthCheckID: "hc-purge",
Status: string(domain.HealthStatusHealthy),
CheckedAt: cutoff.Add(time.Duration(1+i) * time.Minute),
}
if err := repo.RecordHistory(ctx, e); err != nil {
t.Fatalf("RecordHistory future %d: %v", i, err)
}
}
deleted, err := repo.PurgeHistory(ctx, cutoff)
if err != nil {
t.Fatalf("PurgeHistory: %v", err)
}
if deleted != 5 {
t.Errorf("deleted = %d, want 5", deleted)
}
remaining, err := repo.GetHistory(ctx, "hc-purge", 100)
if err != nil {
t.Fatalf("GetHistory after purge: %v", err)
}
if len(remaining) != 5 {
t.Errorf("remaining = %d, want 5", len(remaining))
}
}
// TestHealthCheckRepository_GetSummary seeds all 5 HealthStatus values
// in a non-uniform distribution so the GROUP BY status branch-table gets
// exercised on each arm. The Total field is computed inside the
// aggregator — its drift would not surface unless we assert it too.
func TestHealthCheckRepository_GetSummary(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewHealthCheckRepository(db)
ctx := context.Background()
seed := map[domain.HealthStatus]int{
domain.HealthStatusHealthy: 3,
domain.HealthStatusDegraded: 2,
domain.HealthStatusDown: 2,
domain.HealthStatusCertMismatch: 1,
domain.HealthStatusUnknown: 1,
}
idx := 0
for status, count := range seed {
for i := 0; i < count; i++ {
check := newHealthCheck(
"hc-sum-"+string(rune('a'+idx)),
"sum.example.com:443",
status,
true,
)
// Endpoint uniqueness isn't enforced by the schema but making
// it unique documents intent and rules out false-positives.
check.Endpoint = check.ID + "-" + check.Endpoint
if err := repo.Create(ctx, check); err != nil {
t.Fatalf("Create %s: %v", check.ID, err)
}
idx++
}
}
summary, err := repo.GetSummary(ctx)
if err != nil {
t.Fatalf("GetSummary: %v", err)
}
if summary.Healthy != 3 {
t.Errorf("Healthy = %d, want 3", summary.Healthy)
}
if summary.Degraded != 2 {
t.Errorf("Degraded = %d, want 2", summary.Degraded)
}
if summary.Down != 2 {
t.Errorf("Down = %d, want 2", summary.Down)
}
if summary.CertMismatch != 1 {
t.Errorf("CertMismatch = %d, want 1", summary.CertMismatch)
}
if summary.Unknown != 1 {
t.Errorf("Unknown = %d, want 1", summary.Unknown)
}
if summary.Total != 9 {
t.Errorf("Total = %d, want 9 (3+2+2+1+1)", summary.Total)
}
}
+237 -40
View File
@@ -4,46 +4,61 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"github.com/lib/pq"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// RenewalPolicyRepository implements repository.RenewalPolicyRepository
// RenewalPolicyRepository implements repository.RenewalPolicyRepository.
type RenewalPolicyRepository struct {
db *sql.DB
}
// NewRenewalPolicyRepository creates a new RenewalPolicyRepository
// NewRenewalPolicyRepository creates a new RenewalPolicyRepository.
func NewRenewalPolicyRepository(db *sql.DB) *RenewalPolicyRepository {
return &RenewalPolicyRepository{db: db}
}
// Get retrieves a renewal policy by ID
func (r *RenewalPolicyRepository) Get(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
// SELECT column order is the shared contract between scanRenewalPolicy and
// every SELECT/RETURNING in this file. Keep them in lockstep; if you add a
// new column, add it to all SELECTs, all scan calls, and scanRenewalPolicy.
//
// Note: certificate_profile_id and agent_group_id live on renewal_policies
// (migrations 000003 and 000004) but are deliberately NOT read here — that
// pre-existing drift is out of G-1's minimum-viable-delta and is tracked in
// the design doc §8. Introducing them would change struct shapes / JSON tags
// and require domain-layer churn we're not taking on in this change.
const renewalPolicyColumns = `
id, name, renewal_window_days, auto_renew, max_retries,
retry_interval_minutes, alert_thresholds_days, created_at, updated_at
`
// scanRenewalPolicy decodes one renewal_policies row from a Row or Rows
// scanner, unmarshaling alert_thresholds_days JSONB into the domain slice.
// Malformed JSONB silently falls back to DefaultAlertThresholds() — same
// behavior as the pre-G-1 code so we don't change observable semantics.
func scanRenewalPolicy(scanner interface {
Scan(dest ...any) error
}) (*domain.RenewalPolicy, error) {
var policy domain.RenewalPolicy
var thresholdsJSON []byte
err := r.db.QueryRowContext(ctx, `
SELECT id, name, renewal_window_days, auto_renew, max_retries,
retry_interval_minutes, alert_thresholds_days, created_at, updated_at
FROM renewal_policies
WHERE id = $1
`, id).Scan(&policy.ID, &policy.Name, &policy.RenewalWindowDays, &policy.AutoRenew,
if err := scanner.Scan(
&policy.ID, &policy.Name, &policy.RenewalWindowDays, &policy.AutoRenew,
&policy.MaxRetries, &policy.RetryInterval, &thresholdsJSON,
&policy.CreatedAt, &policy.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("renewal policy not found: %s", id)
}
return nil, fmt.Errorf("failed to query renewal policy: %w", err)
&policy.CreatedAt, &policy.UpdatedAt,
); err != nil {
return nil, err
}
// Parse alert thresholds from JSONB
if len(thresholdsJSON) > 0 {
if err := json.Unmarshal(thresholdsJSON, &policy.AlertThresholdsDays); err != nil {
// Fall back to defaults if JSON is malformed
policy.AlertThresholdsDays = domain.DefaultAlertThresholds()
}
}
@@ -51,14 +66,23 @@ func (r *RenewalPolicyRepository) Get(ctx context.Context, id string) (*domain.R
return &policy, nil
}
// List returns all renewal policies
// Get retrieves a renewal policy by ID.
func (r *RenewalPolicyRepository) Get(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+renewalPolicyColumns+` FROM renewal_policies WHERE id = $1`, id)
policy, err := scanRenewalPolicy(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("renewal policy not found: %s", id)
}
return nil, fmt.Errorf("failed to query renewal policy: %w", err)
}
return policy, nil
}
// List returns all renewal policies, ordered by name (matches the index on
// renewal_policies.name from migration 000001 so ORDER BY is index-served).
func (r *RenewalPolicyRepository) List(ctx context.Context) ([]*domain.RenewalPolicy, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, renewal_window_days, auto_renew, max_retries,
retry_interval_minutes, alert_thresholds_days, created_at, updated_at
FROM renewal_policies
ORDER BY name
`)
rows, err := r.db.QueryContext(ctx, `SELECT `+renewalPolicyColumns+` FROM renewal_policies ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("failed to query renewal policies: %w", err)
}
@@ -66,22 +90,11 @@ func (r *RenewalPolicyRepository) List(ctx context.Context) ([]*domain.RenewalPo
var policies []*domain.RenewalPolicy
for rows.Next() {
var policy domain.RenewalPolicy
var thresholdsJSON []byte
if err := rows.Scan(&policy.ID, &policy.Name, &policy.RenewalWindowDays, &policy.AutoRenew,
&policy.MaxRetries, &policy.RetryInterval, &thresholdsJSON,
&policy.CreatedAt, &policy.UpdatedAt); err != nil {
policy, err := scanRenewalPolicy(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan renewal policy: %w", err)
}
if len(thresholdsJSON) > 0 {
if err := json.Unmarshal(thresholdsJSON, &policy.AlertThresholdsDays); err != nil {
policy.AlertThresholdsDays = domain.DefaultAlertThresholds()
}
}
policies = append(policies, &policy)
policies = append(policies, policy)
}
if err := rows.Err(); err != nil {
@@ -90,3 +103,187 @@ func (r *RenewalPolicyRepository) List(ctx context.Context) ([]*domain.RenewalPo
return policies, nil
}
// slugRegex matches non-alphanumeric characters that slugifyPolicyName strips.
var slugRegex = regexp.MustCompile(`[^a-z0-9-]+`)
// slugifyPolicyName produces `rp-<slug>` for an auto-generated policy ID.
// Slug: lowercase, spaces→hyphens, non-alphanumeric stripped, trimmed to 64
// chars. Matches the existing seed convention (rp-default, rp-standard,
// rp-urgent). Collision resolution is handled by Create's retry loop.
func slugifyPolicyName(name string) string {
slug := strings.ToLower(strings.TrimSpace(name))
slug = strings.ReplaceAll(slug, " ", "-")
slug = slugRegex.ReplaceAllString(slug, "")
slug = strings.Trim(slug, "-")
if slug == "" {
slug = "policy"
}
if len(slug) > 64 {
slug = slug[:64]
}
return "rp-" + slug
}
// isUniqueViolation reports whether err is a PostgreSQL 23505 unique_violation.
// Used by Create/Update to translate name-collision errors onto the typed
// ErrRenewalPolicyDuplicateName sentinel.
func isUniqueViolation(err error) bool {
var pqErr *pq.Error
return errors.As(err, &pqErr) && pqErr.Code == "23505"
}
// isForeignKeyViolation reports whether err is a PostgreSQL 23503
// foreign_key_violation. Used by Delete to translate ON DELETE RESTRICT
// failures onto the typed ErrRenewalPolicyInUse sentinel.
func isForeignKeyViolation(err error) bool {
var pqErr *pq.Error
return errors.As(err, &pqErr) && pqErr.Code == "23503"
}
// Create inserts a new renewal policy. If policy.ID is empty, auto-generates
// `rp-<slug(name)>` with -2/-3/... suffixes on collision (up to 10 attempts).
// Returns ErrRenewalPolicyDuplicateName on pg 23505 (name collision).
//
// alert_thresholds_days is marshaled to JSONB here rather than relying on the
// DB default because the service layer already applies DefaultAlertThresholds
// for empty input — the DB default is a safety net, not the primary path.
func (r *RenewalPolicyRepository) Create(ctx context.Context, policy *domain.RenewalPolicy) error {
if policy == nil {
return errors.New("renewal policy is nil")
}
thresholdsJSON, err := json.Marshal(policy.AlertThresholdsDays)
if err != nil {
return fmt.Errorf("failed to marshal alert thresholds: %w", err)
}
// ID auto-generation with collision retry. We attempt up to 10 suffix
// variants (rp-foo, rp-foo-2, ..., rp-foo-10) before giving up — the
// 23505 error the caller gets back past that point is on Name (their
// job to fix) rather than on a slug-collision we swallowed.
baseID := policy.ID
if baseID == "" {
baseID = slugifyPolicyName(policy.Name)
}
insertSQL := `
INSERT INTO renewal_policies (
id, name, renewal_window_days, auto_renew, max_retries,
retry_interval_minutes, alert_thresholds_days, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING ` + renewalPolicyColumns
maxAttempts := 10
if policy.ID != "" {
// Caller supplied a specific ID — no collision-retry, just one shot.
maxAttempts = 1
}
for attempt := 1; attempt <= maxAttempts; attempt++ {
candidateID := baseID
if attempt > 1 {
candidateID = fmt.Sprintf("%s-%d", baseID, attempt)
}
row := r.db.QueryRowContext(ctx, insertSQL,
candidateID, policy.Name, policy.RenewalWindowDays, policy.AutoRenew,
policy.MaxRetries, policy.RetryInterval, thresholdsJSON,
)
inserted, scanErr := scanRenewalPolicy(row)
if scanErr == nil {
*policy = *inserted
return nil
}
if isUniqueViolation(scanErr) {
// Determine which unique constraint — if it's the name UNIQUE
// we can't recover (caller has to pick a new name); if it's the
// primary-key slug collision we loop to the next suffix.
var pqErr *pq.Error
errors.As(scanErr, &pqErr)
// Postgres reports the constraint name in pqErr.Constraint;
// renewal_policies_name_key is the name UNIQUE, renewal_policies_pkey
// is the PK. Name collision is terminal, PK collision is retryable.
if pqErr.Constraint != "" && !strings.Contains(pqErr.Constraint, "pkey") {
return repository.ErrRenewalPolicyDuplicateName
}
// PK collision — try next suffix.
continue
}
return fmt.Errorf("failed to insert renewal policy: %w", scanErr)
}
// Exhausted retry budget on PK collisions — surface as duplicate so the
// caller at least gets a 409 rather than a mysterious 500.
return repository.ErrRenewalPolicyDuplicateName
}
// Update modifies an existing renewal policy by ID. Returns an error wrapping
// sql.ErrNoRows when id is unknown (detected by RETURNING returning zero rows),
// or ErrRenewalPolicyDuplicateName on pg 23505 (name collision with another row).
func (r *RenewalPolicyRepository) Update(ctx context.Context, id string, policy *domain.RenewalPolicy) error {
if policy == nil {
return errors.New("renewal policy is nil")
}
thresholdsJSON, err := json.Marshal(policy.AlertThresholdsDays)
if err != nil {
return fmt.Errorf("failed to marshal alert thresholds: %w", err)
}
row := r.db.QueryRowContext(ctx, `
UPDATE renewal_policies SET
name = $2,
renewal_window_days = $3,
auto_renew = $4,
max_retries = $5,
retry_interval_minutes = $6,
alert_thresholds_days = $7,
updated_at = NOW()
WHERE id = $1
RETURNING `+renewalPolicyColumns,
id, policy.Name, policy.RenewalWindowDays, policy.AutoRenew,
policy.MaxRetries, policy.RetryInterval, thresholdsJSON,
)
updated, err := scanRenewalPolicy(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("renewal policy not found: %s", id)
}
if isUniqueViolation(err) {
return repository.ErrRenewalPolicyDuplicateName
}
return fmt.Errorf("failed to update renewal policy: %w", err)
}
*policy = *updated
return nil
}
// Delete removes a renewal policy by ID. Returns ErrRenewalPolicyInUse when
// the policy is still referenced by rows in managed_certificates (pg 23503
// foreign_key_violation against the ON DELETE RESTRICT FK from
// managed_certificates.renewal_policy_id). Returns an error wrapping
// sql.ErrNoRows when id is unknown.
func (r *RenewalPolicyRepository) Delete(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM renewal_policies WHERE id = $1`, id)
if err != nil {
if isForeignKeyViolation(err) {
return repository.ErrRenewalPolicyInUse
}
return fmt.Errorf("failed to delete renewal policy: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to read RowsAffected for delete: %w", err)
}
if rows == 0 {
return fmt.Errorf("renewal policy not found: %s", id)
}
return nil
}
@@ -0,0 +1,317 @@
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/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
"github.com/shankar0123/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-<slug(name)>) 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-<slug(name)>.
// "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 in retry_interval_minutes column; passthrough
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")
}
}
+4 -2
View File
@@ -1,6 +1,8 @@
// Package postgres_test provides repository integration tests covering 15 of 17
// Package postgres_test provides repository integration tests covering 17 of 17
// PostgreSQL repository files. Each test function exercises CRUD operations,
// edge cases, and deduplication logic against a real database.
// edge cases, and deduplication logic against a real database. HealthCheck
// and RenewalPolicy integration tests live in sibling *_test.go files in this
// package (see health_check_test.go and renewal_policy_test.go).
package postgres_test
import (
@@ -81,6 +81,29 @@ func (r *RevocationRepository) ListAll(ctx context.Context) ([]*domain.Certifica
return scanRevocations(rows)
}
// ListByIssuer returns all revocations for a single issuer, ordered by revocation time.
//
// This is the hot path for CRL generation. Pushing the issuer filter into the
// SQL query lets the composite index `idx_certificate_revocations_issuer_serial`
// (migration 000012) drive a prefix scan on issuer_id rather than forcing
// callers to load every row in the table and discard the ones belonging to
// other issuers.
func (r *RevocationRepository) ListByIssuer(ctx context.Context, issuerID string) ([]*domain.CertificateRevocation, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, certificate_id, serial_number, reason, revoked_by, revoked_at,
issuer_id, issuer_notified, created_at
FROM certificate_revocations
WHERE issuer_id = $1
ORDER BY revoked_at ASC
`, issuerID)
if err != nil {
return nil, fmt.Errorf("failed to list revocations by issuer: %w", err)
}
defer rows.Close()
return scanRevocations(rows)
}
// ListByCertificate returns all revocations for a certificate.
func (r *RevocationRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.CertificateRevocation, error) {
rows, err := r.db.QueryContext(ctx, `
+8 -8
View File
@@ -56,19 +56,19 @@ func (s *CAOperationsSvc) GenerateDERCRL(ctx context.Context, issuerID string) (
return nil, fmt.Errorf("issuer not found: %s", issuerID)
}
revocations, err := s.revocationRepo.ListAll(ctx)
// Scope the query to this issuer so the migration 000012 composite index
// drives a prefix scan; previously this path read every revocation in the
// table and filtered in Go, which did not scale as the revocation table
// grew across many issuers (F-001).
revocations, err := s.revocationRepo.ListByIssuer(ctx, issuerID)
if err != nil {
return nil, fmt.Errorf("failed to list revocations: %w", err)
return nil, fmt.Errorf("failed to list revocations for issuer %s: %w", issuerID, err)
}
// Filter to this issuer and convert to CRL entries.
// Short-lived certificates (profile TTL < 1 hour) are excluded — expiry is sufficient revocation.
// Convert revocations to CRL entries. Short-lived certificates (profile
// TTL < 1 hour) are excluded — expiry is sufficient revocation.
var entries []CRLEntry
for _, rev := range revocations {
if rev.IssuerID != issuerID {
continue
}
// Check short-lived exemption: look up the cert's profile
if s.profileRepo != nil && s.certRepo != nil {
cert, err := s.certRepo.Get(ctx, rev.CertificateID)
+67
View File
@@ -75,6 +75,73 @@ func TestCAOperationsSvc_GenerateDERCRL_Success(t *testing.T) {
t.Logf("DER CRL generated successfully: %d bytes", len(crl))
}
// TestCAOperationsSvc_GenerateDERCRL_UsesListByIssuer_NotListAll guards F-001.
// Before the fix, GenerateDERCRL called revocationRepo.ListAll(ctx) and filtered
// results in Go (if rev.IssuerID != issuerID { continue }). That was O(N) in the
// size of the entire revocation table and did not scale as revocations piled up
// across many issuers. Migration 000012 added the composite index
// idx_certificate_revocations_issuer_serial(issuer_id, serial_number), which is
// a prefix scan target — so the hot path must now call ListByIssuer(ctx, id) to
// drive an indexed query. This regression test asserts the hot path invokes
// ListByIssuer exactly once and never falls back to the full-table ListAll scan,
// and also double-checks that cross-issuer revocations are correctly excluded
// from the generated CRL (no in-Go filter left to catch them).
func TestCAOperationsSvc_GenerateDERCRL_UsesListByIssuer_NotListAll(t *testing.T) {
caSvc, revocationRepo, _ := newCAOperationsSvcTest()
// Pre-populate with revocations from TWO issuers. If the hot path regresses
// and calls ListAll instead of ListByIssuer, the generated CRL would either
// include the wrong rows or — with the in-Go filter gone — pull in both
// issuers' revocations. ListByIssuer scopes at the query level so only
// iss-local rows come back.
now := time.Now()
revocationRepo.Revocations = []*domain.CertificateRevocation{
{
SerialNumber: "LOCAL-001",
CertificateID: "cert-local-1",
IssuerID: "iss-local",
Reason: "keyCompromise",
RevokedAt: now.Add(-24 * time.Hour),
RevokedBy: "admin",
},
{
SerialNumber: "LOCAL-002",
CertificateID: "cert-local-2",
IssuerID: "iss-local",
Reason: "superseded",
RevokedAt: now.Add(-12 * time.Hour),
RevokedBy: "admin",
},
{
SerialNumber: "OTHER-001",
CertificateID: "cert-other-1",
IssuerID: "iss-other",
Reason: "keyCompromise",
RevokedAt: now.Add(-6 * time.Hour),
RevokedBy: "admin",
},
}
crl, err := caSvc.GenerateDERCRL(context.Background(), "iss-local")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(crl) == 0 {
t.Fatal("expected non-empty CRL")
}
// The contractual assertion: the CRL hot path MUST use the scoped query.
if got, want := revocationRepo.ListByIssuerCalls, 1; got != want {
t.Errorf("ListByIssuerCalls = %d, want %d — CRL hot path must call the scoped query driven by migration 000012 index", got, want)
}
if got := revocationRepo.ListAllCalls; got != 0 {
t.Errorf("ListAllCalls = %d, want 0 — CRL hot path must NOT fall back to the full-table scan after F-001", got)
}
if got, want := revocationRepo.LastListIssuerID, "iss-local"; got != want {
t.Errorf("LastListIssuerID = %q, want %q — issuer scoping argument lost", got, want)
}
}
func TestCAOperationsSvc_GenerateDERCRL_EmptyCRL(t *testing.T) {
caSvc, revocationRepo, _ := newCAOperationsSvcTest()
+211
View File
@@ -0,0 +1,211 @@
package service
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// G-1: service-level sentinels alias the repository sentinels so errors.Is
// walks transparently across layers. Do NOT errors.New a fresh copy — the
// handler's `errors.Is(err, repository.ErrRenewalPolicyInUse)` branch and
// the service-layer tests' `errors.Is(err, service.ErrRenewalPolicyInUse)`
// branch need to match the same sentinel var identity.
var (
ErrRenewalPolicyDuplicateName = repository.ErrRenewalPolicyDuplicateName
ErrRenewalPolicyInUse = repository.ErrRenewalPolicyInUse
)
// RenewalPolicyService implements the /api/v1/renewal-policies CRUD surface.
//
// G-1 scope note: the red-test contract pins NewRenewalPolicyService to a
// repo-only signature (no auditService). Renewal-policy CRUD does not emit
// audit events in this change — if audit coverage is needed later, add a
// SetAuditService setter rather than churning the constructor signature.
type RenewalPolicyService struct {
repo repository.RenewalPolicyRepository
}
// NewRenewalPolicyService constructs the service bound to its repository.
func NewRenewalPolicyService(repo repository.RenewalPolicyRepository) *RenewalPolicyService {
return &RenewalPolicyService{repo: repo}
}
// rpSlugRegex matches non-alphanumeric characters that slugifyRenewalPolicyName strips.
// Mirrors the identical regex in internal/repository/postgres/renewal_policy.go —
// the service owns the rp-<slug> convention so the repo's retry loop is a
// pure PK-collision safety net, not the primary ID generator.
var rpSlugRegex = regexp.MustCompile(`[^a-z0-9-]+`)
// slugifyRenewalPolicyName produces `rp-<slug>` for an auto-generated policy
// ID. Slug: lowercase, spaces→hyphens, non-alphanumeric stripped, trimmed to
// 64 chars. Matches the seed convention (rp-default, rp-standard, rp-urgent)
// and the repo's slugifyPolicyName byte-for-byte.
func slugifyRenewalPolicyName(name string) string {
slug := strings.ToLower(strings.TrimSpace(name))
slug = strings.ReplaceAll(slug, " ", "-")
slug = rpSlugRegex.ReplaceAllString(slug, "")
slug = strings.Trim(slug, "-")
if slug == "" {
slug = "policy"
}
if len(slug) > 64 {
slug = slug[:64]
}
return "rp-" + slug
}
// ListRenewalPolicies returns a single page of renewal policies sorted by
// name (the repo's ORDER BY name is index-served via idx_renewal_policies_name).
// Pagination is done in Go rather than SQL — the expected row count is in the
// single digits so LIMIT/OFFSET would be premature optimization and would
// churn the repo contract for no measurable benefit (design doc §Known
// Caller Audit).
//
// Bounds: page defaults to 1, per_page defaults to 50, caps at 500 to match
// the /api/v1/policies handler's behavior. Past-end slices return an empty
// slice with no error — callers use `total` to detect end of pagination.
func (s *RenewalPolicyService) ListRenewalPolicies(ctx context.Context, page, perPage int) ([]domain.RenewalPolicy, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
if perPage > 500 {
perPage = 500
}
items, err := s.repo.List(ctx)
if err != nil {
return nil, 0, fmt.Errorf("failed to list renewal policies: %w", err)
}
total := int64(len(items))
start := (page - 1) * perPage
if start >= int(total) {
return nil, total, nil
}
end := start + perPage
if end > int(total) {
end = int(total)
}
out := make([]domain.RenewalPolicy, 0, end-start)
for _, p := range items[start:end] {
if p != nil {
out = append(out, *p)
}
}
return out, total, nil
}
// GetRenewalPolicy retrieves one renewal policy by ID. Not-found errors
// surface from the repo verbatim; the handler translates them to 404.
func (s *RenewalPolicyService) GetRenewalPolicy(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
return s.repo.Get(ctx, id)
}
// validateBounds enforces the design doc §Validation Bounds invariants:
// - name required, ≤ 255 chars
// - renewal_window_days in [1, 365]
// - max_retries in [0, 10]
// - retry_interval_seconds in [60, 86400]
// - alert_thresholds_days each in [0, 365]
//
// Called after applyCreateDefaults so zero-value fields that the caller
// expects to be defaulted don't trip the range checks.
func (s *RenewalPolicyService) validateBounds(rp *domain.RenewalPolicy) error {
if strings.TrimSpace(rp.Name) == "" {
return fmt.Errorf("name is required")
}
if len(rp.Name) > 255 {
return fmt.Errorf("name must be 255 characters or fewer, got %d", len(rp.Name))
}
if rp.RenewalWindowDays < 1 || rp.RenewalWindowDays > 365 {
return fmt.Errorf("renewal_window_days must be between 1 and 365, got %d", rp.RenewalWindowDays)
}
if rp.MaxRetries < 0 || rp.MaxRetries > 10 {
return fmt.Errorf("max_retries must be between 0 and 10, got %d", rp.MaxRetries)
}
if rp.RetryInterval < 60 || rp.RetryInterval > 86400 {
return fmt.Errorf("retry_interval_seconds must be between 60 and 86400, got %d", rp.RetryInterval)
}
for i, t := range rp.AlertThresholdsDays {
if t < 0 || t > 365 {
return fmt.Errorf("alert_thresholds_days[%d]=%d must be between 0 and 365", i, t)
}
}
return nil
}
// applyCreateDefaults fills in zero-valued optional fields with the design
// doc defaults. Name is never defaulted — missing name fails validation.
// MaxRetries=0 is a legal explicit value (no retries), so it is NOT
// defaulted; the DB default column handles that path if needed.
func (s *RenewalPolicyService) applyCreateDefaults(rp *domain.RenewalPolicy) {
if rp.RenewalWindowDays == 0 {
rp.RenewalWindowDays = 30
}
if rp.RetryInterval == 0 {
rp.RetryInterval = 3600
}
if len(rp.AlertThresholdsDays) == 0 {
rp.AlertThresholdsDays = domain.DefaultAlertThresholds()
}
}
// CreateRenewalPolicy inserts a new renewal policy. Auto-generates
// `rp-<slug(name)>` for ID if empty. Defaults are applied before bounds
// validation so a caller can omit RenewalWindowDays / RetryInterval and
// still pass bounds. Returns ErrRenewalPolicyDuplicateName unwrapped from
// the repo when a name collision occurs (pg 23505 on the UNIQUE constraint);
// the handler surfaces that as 409 Conflict.
func (s *RenewalPolicyService) CreateRenewalPolicy(ctx context.Context, rp domain.RenewalPolicy) (*domain.RenewalPolicy, error) {
s.applyCreateDefaults(&rp)
if err := s.validateBounds(&rp); err != nil {
return nil, err
}
if rp.ID == "" {
rp.ID = slugifyRenewalPolicyName(rp.Name)
}
if rp.CreatedAt.IsZero() {
rp.CreatedAt = time.Now()
}
if err := s.repo.Create(ctx, &rp); err != nil {
// Propagate repository sentinels verbatim — service-level sentinels
// alias repo sentinels (same var identity), so errors.Is walks
// through without any translation.
return nil, err
}
return &rp, nil
}
// UpdateRenewalPolicy replaces the fields of an existing renewal policy.
// Applies the same defaults+bounds as Create so partial updates do not slip
// an invalid row past validation via zero-value fields. id in the path wins
// over any id the caller supplied in the body.
func (s *RenewalPolicyService) UpdateRenewalPolicy(ctx context.Context, id string, rp domain.RenewalPolicy) (*domain.RenewalPolicy, error) {
s.applyCreateDefaults(&rp)
if err := s.validateBounds(&rp); err != nil {
return nil, err
}
rp.ID = id
if err := s.repo.Update(ctx, id, &rp); err != nil {
return nil, err
}
return &rp, nil
}
// DeleteRenewalPolicy removes a renewal policy. Returns ErrRenewalPolicyInUse
// when the policy is still referenced by rows in managed_certificates (the
// repo translates pg 23503 FK_RESTRICT violations onto that sentinel). The
// handler surfaces that as 409 Conflict.
func (s *RenewalPolicyService) DeleteRenewalPolicy(ctx context.Context, id string) error {
return s.repo.Delete(ctx, id)
}
+341
View File
@@ -0,0 +1,341 @@
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)
}
}
+77 -4
View File
@@ -749,11 +749,21 @@ func (m *mockPolicyRepo) AddRule(rule *domain.PolicyRule) {
m.Rules[rule.ID] = rule
}
// mockRenewalPolicyRepo is a test implementation of RenewalPolicyRepository
// mockRenewalPolicyRepo is a test implementation of RenewalPolicyRepository.
//
// G-1: repo contract extended with Create/Update/Delete to support the
// /api/v1/renewal-policies CRUD endpoints. Per-method *Err fields let tests
// force specific repo failures (duplicate name → 23505, FK RESTRICT on Delete
// → 23503) without standing up a real Postgres connection. The sentinel
// errors `ErrRenewalPolicyDuplicateName` and `ErrRenewalPolicyInUse` are the
// typed envelopes the service / handler layers translate into 409 Conflict.
type mockRenewalPolicyRepo struct {
Policies map[string]*domain.RenewalPolicy
GetErr error
ListErr error
Policies map[string]*domain.RenewalPolicy
GetErr error
ListErr error
CreateErr error
UpdateErr error
DeleteErr error
}
func (m *mockRenewalPolicyRepo) Get(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
@@ -775,9 +785,49 @@ func (m *mockRenewalPolicyRepo) List(ctx context.Context) ([]*domain.RenewalPoli
for _, p := range m.Policies {
policies = append(policies, p)
}
// Deterministic ordering mirrors the production repo's ORDER BY name,
// so pagination-boundary assertions don't become flaky under map
// iteration randomness.
sort.Slice(policies, func(i, j int) bool {
return policies[i].Name < policies[j].Name
})
return policies, nil
}
func (m *mockRenewalPolicyRepo) Create(ctx context.Context, policy *domain.RenewalPolicy) error {
if m.CreateErr != nil {
return m.CreateErr
}
if _, exists := m.Policies[policy.ID]; exists {
return m.CreateErr
}
m.Policies[policy.ID] = policy
return nil
}
func (m *mockRenewalPolicyRepo) Update(ctx context.Context, id string, policy *domain.RenewalPolicy) error {
if m.UpdateErr != nil {
return m.UpdateErr
}
if _, exists := m.Policies[id]; !exists {
return errNotFound
}
policy.ID = id
m.Policies[id] = policy
return nil
}
func (m *mockRenewalPolicyRepo) Delete(ctx context.Context, id string) error {
if m.DeleteErr != nil {
return m.DeleteErr
}
if _, exists := m.Policies[id]; !exists {
return errNotFound
}
delete(m.Policies, id)
return nil
}
func (m *mockRenewalPolicyRepo) AddPolicy(policy *domain.RenewalPolicy) {
m.Policies[policy.ID] = policy
}
@@ -1385,6 +1435,13 @@ type mockRevocationRepo struct {
Revocations []*domain.CertificateRevocation
CreateErr error
ListErr error
// F-001 regression instrumentation: track which list method was invoked
// so tests can assert that the CRL generation hot path uses the scoped
// ListByIssuer query (migration 000012 composite index) rather than
// ListAll followed by in-Go filtering.
ListAllCalls int
ListByIssuerCalls int
LastListIssuerID string
}
func (m *mockRevocationRepo) Create(ctx context.Context, revocation *domain.CertificateRevocation) error {
@@ -1405,12 +1462,28 @@ func (m *mockRevocationRepo) GetByIssuerAndSerial(ctx context.Context, issuerID,
}
func (m *mockRevocationRepo) ListAll(ctx context.Context) ([]*domain.CertificateRevocation, error) {
m.ListAllCalls++
if m.ListErr != nil {
return nil, m.ListErr
}
return m.Revocations, nil
}
func (m *mockRevocationRepo) ListByIssuer(ctx context.Context, issuerID string) ([]*domain.CertificateRevocation, error) {
m.ListByIssuerCalls++
m.LastListIssuerID = issuerID
if m.ListErr != nil {
return nil, m.ListErr
}
var result []*domain.CertificateRevocation
for _, r := range m.Revocations {
if r.IssuerID == issuerID {
result = append(result, r)
}
}
return result, nil
}
func (m *mockRevocationRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.CertificateRevocation, error) {
var result []*domain.CertificateRevocation
for _, r := range m.Revocations {
+58
View File
@@ -33,6 +33,10 @@ import {
updatePolicy,
deletePolicy,
getPolicyViolations,
getRenewalPolicies,
createRenewalPolicy,
updateRenewalPolicy,
deleteRenewalPolicy,
getIssuers,
createIssuer,
testIssuerConnection,
@@ -575,6 +579,60 @@ describe('API Client', () => {
});
});
// ─── Renewal Policies (G-1) ─────────────────────────
// Distinct from compliance Policies above. Populates the
// `renewal_policy_id` dropdown on OnboardingWizard + CertificatesPage +
// CertificateDetailPage.InlinePolicyEditor. Hits `/api/v1/renewal-policies`.
describe('RenewalPolicies', () => {
it('getRenewalPolicies sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getRenewalPolicies();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/renewal-policies');
});
it('createRenewalPolicy sends POST with body', async () => {
mockFetch.mockReturnValueOnce(
mockJsonResponse({
id: 'rp-new',
name: 'New Policy',
renewal_window_days: 30,
max_retries: 3,
retry_interval_seconds: 3600,
auto_renew: true,
}),
);
await createRenewalPolicy({
name: 'New Policy',
renewal_window_days: 30,
max_retries: 3,
retry_interval_seconds: 3600,
auto_renew: true,
});
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/renewal-policies');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body).name).toBe('New Policy');
});
it('updateRenewalPolicy sends PUT with partial data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'rp-default', name: 'Renamed' }));
await updateRenewalPolicy('rp-default', { name: 'Renamed' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/renewal-policies/rp-default');
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body)).toEqual({ name: 'Renamed' });
});
it('deleteRenewalPolicy sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteRenewalPolicy('rp-default');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/renewal-policies/rp-default');
expect(init.method).toBe('DELETE');
});
});
// ─── Issuers ────────────────────────────────────────
describe('Issuers', () => {
+24 -1
View File
@@ -1,4 +1,4 @@
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse } from './types';
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse } from './types';
const BASE = '/api/v1';
@@ -344,6 +344,29 @@ export const deletePolicy = (id: string) =>
export const getPolicyViolations = (id: string) =>
fetchJSON<PaginatedResponse<PolicyViolation>>(`${BASE}/policies/${id}/violations`);
// G-1: Renewal Policies (/api/v1/renewal-policies) — lifecycle policies with
// rp-* IDs in the renewal_policies table. Distinct from getPolicies() above
// which hits /api/v1/policies and returns PolicyRule (compliance, pol-* IDs).
// OnboardingWizard, CertificatesPage, and CertificateDetailPage populate the
// `renewal_policy_id` dropdown from this endpoint; populating it from
// getPolicies() produced FK violations on certificate insert/update.
export const getRenewalPolicies = (page = 1, perPage = 50) => {
const qs = new URLSearchParams({ page: String(page), per_page: String(perPage) }).toString();
return fetchJSON<PaginatedResponse<RenewalPolicy>>(`${BASE}/renewal-policies?${qs}`);
};
export const getRenewalPolicy = (id: string) =>
fetchJSON<RenewalPolicy>(`${BASE}/renewal-policies/${id}`);
export const createRenewalPolicy = (data: Partial<RenewalPolicy>) =>
fetchJSON<RenewalPolicy>(`${BASE}/renewal-policies`, { method: 'POST', body: JSON.stringify(data) });
export const updateRenewalPolicy = (id: string, data: Partial<RenewalPolicy>) =>
fetchJSON<RenewalPolicy>(`${BASE}/renewal-policies/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteRenewalPolicy = (id: string) =>
fetchJSON<void>(`${BASE}/renewal-policies/${id}`, { method: 'DELETE' });
// Issuers
export const getIssuers = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+25
View File
@@ -228,6 +228,31 @@ export interface PolicyViolation {
created_at: string;
}
/**
* G-1: RenewalPolicy is the lifecycle policy attached to managed certificates
* via `managed_certificates.renewal_policy_id` (FK ON DELETE RESTRICT `rp-*`
* IDs in the `renewal_policies` table). Distinct from `PolicyRule` above, which
* models compliance rules in the `policy_rules` table with `pol-*` IDs. The
* OnboardingWizard + CertificatesPage + CertificateDetailPage dropdowns populate
* `renewal_policy_id` from this interface previously they mis-populated it
* from `getPolicies()` which returned `pol-*` IDs and produced FK violations on
* certificate insert/update.
*
* JSON tags mirror internal/domain/renewal_policy.go.
*/
export interface RenewalPolicy {
id: string;
name: string;
renewal_window_days: number;
auto_renew: boolean;
max_retries: number;
retry_interval_seconds: number;
alert_thresholds_days: number[];
certificate_profile_id?: string | null;
created_at: string;
updated_at: string;
}
export interface Issuer {
id: string;
name: string;
+12 -4
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12 } from '../api/client';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getRenewalPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12 } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
@@ -164,9 +164,14 @@ function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { cer
const [policyId, setPolicyId] = useState(currentPolicyId);
const [profileId, setProfileId] = useState(currentProfileId);
// G-1: swap from getPolicies (compliance rules, pol-*) to getRenewalPolicies
// (lifecycle policies, rp-*). managed_certificates.renewal_policy_id FK
// points at renewal_policies(id); the previous getPolicies call populated
// the dropdown with pol-* IDs that would 400/23503 at the server. See also
// OnboardingWizard.tsx:603 and CertificatesPage.tsx:53 for the sibling fixes.
const { data: policies } = useQuery({
queryKey: ['policies'],
queryFn: () => getPolicies(),
queryKey: ['renewal-policies'],
queryFn: () => getRenewalPolicies(1, 500),
enabled: editing,
});
@@ -227,7 +232,10 @@ function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { cer
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink">
<option value="">None</option>
{policies?.data?.map(p => (
<option key={p.id} value={p.id}>{p.name} ({p.type})</option>
// G-1: RenewalPolicy has no `type` field (that was PolicyRule).
// Show the human-readable name + renewal window so operators can
// pick the correct lifecycle policy at a glance.
<option key={p.id} value={p.id}>{p.name} ({p.renewal_window_days}d window)</option>
))}
</select>
</div>
+8 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getTeams, getPolicies, getProfiles, getIssuers, bulkRevokeCertificates } from '../api/client';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getTeams, getRenewalPolicies, getProfiles, getIssuers, bulkRevokeCertificates } from '../api/client';
import { useAuth } from '../components/AuthProvider';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
@@ -48,9 +48,15 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
queryKey: ['teams', 'form'],
queryFn: () => getTeams({ per_page: '500' }),
});
// G-1: swap from getPolicies (compliance rules, pol-*) to getRenewalPolicies
// (lifecycle policies, rp-*). managed_certificates.renewal_policy_id FK
// points at renewal_policies(id), so the dropdown must pull from that table
// — the previous getPolicies call populated the dropdown with pol-* IDs that
// would 400/23503 at the server. See also OnboardingWizard.tsx:603 and
// CertificateDetailPage.tsx:169 for the sibling fixes.
const { data: policiesResp } = useQuery({
queryKey: ['renewal-policies', 'form'],
queryFn: () => getPolicies({ per_page: '500' }),
queryFn: () => getRenewalPolicies(1, 500),
});
const profiles = profilesResp?.data || [];
const issuers = issuersResp?.data || [];
+7 -2
View File
@@ -39,7 +39,10 @@ vi.mock('../api/client', () => ({
getProfiles: vi.fn(),
getOwners: vi.fn(),
getTeams: vi.fn(),
getPolicies: vi.fn(),
// G-1: wizard populates the renewal_policy_id dropdown from
// getRenewalPolicies (rp-* ids), not getPolicies (which returns compliance
// rules with pol-* ids and violates the FK).
getRenewalPolicies: vi.fn(),
createIssuer: vi.fn(),
testIssuerConnection: vi.fn(),
createCertificate: vi.fn(),
@@ -85,7 +88,9 @@ function stubAllQueriesEmpty() {
vi.mocked(client.getTeams).mockResolvedValue({
data: [], total: 0, page: 1, per_page: 500,
} as never);
vi.mocked(client.getPolicies).mockResolvedValue({
// G-1: wizard populates renewal_policy_id from getRenewalPolicies, not
// getPolicies. See comment on the mock factory above.
vi.mocked(client.getRenewalPolicies).mockResolvedValue({
data: [], total: 0, page: 1, per_page: 500,
} as never);
}
+6 -2
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, Link } from 'react-router-dom';
import {
getIssuers, getAgents, getProfiles, getOwners, getTeams, getPolicies,
getIssuers, getAgents, getProfiles, getOwners, getTeams, getRenewalPolicies,
createIssuer, testIssuerConnection,
createCertificate, triggerRenewal,
createTeam, createOwner,
@@ -600,7 +600,11 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents() });
const { data: owners } = useQuery({ queryKey: ['owners'], queryFn: () => getOwners({ per_page: '500' }) });
const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: () => getTeams({ per_page: '500' }) });
const { data: policies } = useQuery({ queryKey: ['renewal-policies'], queryFn: () => getPolicies({ per_page: '500' }) });
// G-1: bind renewal_policy_id dropdown to /api/v1/renewal-policies (rp-* IDs
// from the renewal_policies table). Previously populated from getPolicies()
// which returned compliance rules (pol-* IDs) and violated the FK
// managed_certificates.renewal_policy_id → renewal_policies(id) on submit.
const { data: policies } = useQuery({ queryKey: ['renewal-policies'], queryFn: () => getRenewalPolicies(1, 500) });
const hasAgents = (agents?.data?.length ?? 0) > 0;