mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:01:30 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6a9a76147 | |||
| a54805c63c | |||
| 0e29c416b1 | |||
| 8a3086c4ae | |||
| d4c421b98d | |||
| 1bdab897ef | |||
| 94ca69554b | |||
| c4d231e728 | |||
| 1c6009a920 | |||
| a39f5af22a | |||
| 3e78ecb799 | |||
| 24f25353f8 | |||
| 25c34ace45 | |||
| 5e4eaa78b1 | |||
| 2419f8cd27 | |||
| 6f045293e9 | |||
| 530da674f8 | |||
| 555eef449e | |||
| 55eb7135be | |||
| 2edac7e78b | |||
| b8a4318082 | |||
| 097995e503 | |||
| 3fc1a2222f | |||
| f0865bb051 | |||
| 677524d9ec | |||
| 9dc0742e77 | |||
| 1440a30d28 | |||
| a3d8b9c607 |
@@ -213,6 +213,347 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Forbidden migration mount in compose initdb (U-3)
|
||||||
|
# U-3 closed cat-u-seed_initdb_schema_drift (GitHub #10) by
|
||||||
|
# eliminating the dual-source-of-truth between
|
||||||
|
# `migrations/*.up.sql` mounted into postgres
|
||||||
|
# `/docker-entrypoint-initdb.d/` and the same files re-applied at
|
||||||
|
# runtime by `RunMigrations`. Pre-U-3 every new migration that
|
||||||
|
# the seed depended on (000013 added `policy_rules.severity`,
|
||||||
|
# 000017 renames `retry_interval_seconds`, etc.) had to be added
|
||||||
|
# by hand to the compose mount list; missing the update crashed
|
||||||
|
# initdb on first boot, postgres flagged unhealthy, and the
|
||||||
|
# whole stack failed to start from a fresh clone. Post-U-3 the
|
||||||
|
# server is the single source of truth — `RunMigrations` +
|
||||||
|
# `RunSeed` apply everything at boot.
|
||||||
|
#
|
||||||
|
# This step grep-fails the build if any compose file under
|
||||||
|
# `deploy/` re-introduces a `migrations/.*\.sql` mount into
|
||||||
|
# `/docker-entrypoint-initdb.d`. Comments are exempt so the
|
||||||
|
# post-fix rationale block in the compose files (which
|
||||||
|
# documents WHY the mounts were removed) doesn't trip the guard.
|
||||||
|
# The demo overlay's `seed_demo.sql` is the explicit exception:
|
||||||
|
# it is tolerated only when it lives behind the
|
||||||
|
# CERTCTL_DEMO_SEED env var (post-U-3 demo path) — bare initdb
|
||||||
|
# mounts are NOT tolerated. The grep matches all compose
|
||||||
|
# mount-list shapes (`-` indented, `volumes:` indented, both),
|
||||||
|
# so any future drift surfaces here before the operator hits it
|
||||||
|
# on a fresh clone.
|
||||||
|
#
|
||||||
|
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||||
|
# cat-u-seed_initdb_schema_drift for the closure rationale, or
|
||||||
|
# internal/repository/postgres/db.go::RunSeed for the runtime
|
||||||
|
# contract.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BAD=$(grep -rnEH \
|
||||||
|
-e 'migrations/.*\.sql:.*docker-entrypoint-initdb' \
|
||||||
|
-e 'seed.*\.sql:.*docker-entrypoint-initdb' \
|
||||||
|
deploy/docker-compose.yml \
|
||||||
|
deploy/docker-compose.test.yml \
|
||||||
|
deploy/docker-compose.demo.yml \
|
||||||
|
2>/dev/null \
|
||||||
|
| grep -vE '^\s*[^:]+:[0-9]+:\s*#' \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$BAD" ]; then
|
||||||
|
echo "U-3 regression: migration/seed mount into postgres initdb reappeared:"
|
||||||
|
echo "$BAD"
|
||||||
|
echo ""
|
||||||
|
echo "The post-U-3 contract is: postgres comes up with an empty"
|
||||||
|
echo "schema and the server applies migrations + seed at boot via"
|
||||||
|
echo "internal/repository/postgres.RunMigrations + RunSeed. Demo"
|
||||||
|
echo "data lives behind CERTCTL_DEMO_SEED=true (RunDemoSeed),"
|
||||||
|
echo "not an initdb mount. See"
|
||||||
|
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||||
|
echo "cat-u-seed_initdb_schema_drift for the closure rationale."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)
|
||||||
|
# D-1 master closed cat-d-359e92c20cbf (Agent: 'Stale' dead key,
|
||||||
|
# 'Degraded' missing), cat-d-9f4c8e4a91f1 (Notification: 'dead'
|
||||||
|
# missing), cat-d-1447e04732e7 (Cert: 'PendingIssuance' dead
|
||||||
|
# key), cat-f-cert_detail_page_key_render_fallback (render-site
|
||||||
|
# uses cert.X directly), and cat-f-ae0d06b6588f (Certificate
|
||||||
|
# TS phantom fields). This step grep-fails the build if either
|
||||||
|
# half of the closure is reverted:
|
||||||
|
#
|
||||||
|
# 1. The dead StatusBadge keys ('Stale' for Agent, 'PendingIssuance'
|
||||||
|
# for Cert) reappearing as map literals, OR
|
||||||
|
# 2. The five phantom Certificate TS fields (serial_number,
|
||||||
|
# fingerprint_sha256, key_algorithm, key_size, issued_at)
|
||||||
|
# reappearing on the `Certificate` interface in types.ts
|
||||||
|
# (CertificateVersion legitimately carries them and is
|
||||||
|
# explicitly excluded by the awk pre-filter below).
|
||||||
|
#
|
||||||
|
# Comments are exempt so the closure prose in StatusBadge.tsx +
|
||||||
|
# types.ts can stay. Test files are exempt so negative tests
|
||||||
|
# asserting the dead keys fall through to neutral keep working.
|
||||||
|
#
|
||||||
|
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||||
|
# cat-d-* / cat-f-* for the closure rationale, or
|
||||||
|
# web/src/components/StatusBadge.test.tsx for the live
|
||||||
|
# enum-coverage contract.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BAD_BADGE=$(grep -nE "^\s*(Stale|PendingIssuance)\s*:\s*'badge-" \
|
||||||
|
web/src/components/StatusBadge.tsx 2>/dev/null \
|
||||||
|
| grep -v '\.test\.' \
|
||||||
|
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$BAD_BADGE" ]; then
|
||||||
|
echo "D-1 regression: dead StatusBadge key reappeared:"
|
||||||
|
echo "$BAD_BADGE"
|
||||||
|
echo ""
|
||||||
|
echo "Allowed surface: comment lines naming the removed key in"
|
||||||
|
echo "the file's preamble. The Go-side AgentStatus values are"
|
||||||
|
echo "Online/Offline/Degraded (no Stale); CertificateStatus values"
|
||||||
|
echo "are Pending/Active/... (no PendingIssuance). See"
|
||||||
|
echo "web/src/components/StatusBadge.test.tsx for the contract."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Certificate TS phantom-field check. Scoped to the
|
||||||
|
# `export interface Certificate {` block in web/src/api/types.ts
|
||||||
|
# — CertificateVersion legitimately declares these fields and
|
||||||
|
# must NOT trip the guardrail. The awk window opens on the
|
||||||
|
# exact `Certificate {` header (not `CertificateVersion {`,
|
||||||
|
# not `CertificateProfile {`) and closes at the first `}`,
|
||||||
|
# then the grep matches a phantom-field declaration anywhere
|
||||||
|
# in that window.
|
||||||
|
BAD_TS=$(awk '
|
||||||
|
/^export interface Certificate \{/ { flag=1; next }
|
||||||
|
flag && /^\}/ { flag=0 }
|
||||||
|
flag { print FILENAME":"NR":"$0 }
|
||||||
|
' web/src/api/types.ts \
|
||||||
|
| grep -E '\b(serial_number|fingerprint_sha256|key_algorithm|key_size|issued_at)\??\s*:' \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$BAD_TS" ]; then
|
||||||
|
echo "D-1 regression: Certificate TS interface re-added a phantom field:"
|
||||||
|
echo "$BAD_TS"
|
||||||
|
echo ""
|
||||||
|
echo "These fields live on CertificateVersion, not ManagedCertificate."
|
||||||
|
echo "The Go-side ManagedCertificate has never carried them; the"
|
||||||
|
echo "TS optional declarations were silently undefined on every"
|
||||||
|
echo "list response. Render-site consumers (e.g. CertificateDetailPage)"
|
||||||
|
echo "use latestVersion?.field as the canonical access path."
|
||||||
|
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||||
|
echo "cat-f-ae0d06b6588f for the closure rationale."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# D-2 master closed five diff-05x06-* type-drift findings:
|
||||||
|
# Agent (5 phantoms), Issuer (1 phantom), Notification (1 phantom)
|
||||||
|
# — TRIM half. The Target (2 missing fields) and DiscoveredCertificate
|
||||||
|
# (1 missing field) — ADD half is pinned by the literal-construction
|
||||||
|
# blocks in web/src/api/types.test.ts, not a CI grep. The phantom-
|
||||||
|
# trim regression vector is an awk-windowed grep per interface
|
||||||
|
# mirroring the D-1 Certificate check above.
|
||||||
|
#
|
||||||
|
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||||
|
# diff-05x06-7cdf4e78ae24 (Agent), diff-05x06-97fab8783a5c (Issuer),
|
||||||
|
# diff-05x06-caba9eb3620e (Notification) for the closure rationale.
|
||||||
|
|
||||||
|
# D-2 Agent phantom-field check. The grep matches `last_heartbeat`
|
||||||
|
# but NOT `last_heartbeat_at` (the legitimate Go-emitted field) —
|
||||||
|
# the `\b...\b` boundaries plus the `grep -v 'last_heartbeat_at'`
|
||||||
|
# filter handle that.
|
||||||
|
BAD_AGENT=$(awk '
|
||||||
|
/^export interface Agent \{/ { flag=1; next }
|
||||||
|
flag && /^\}/ { flag=0 }
|
||||||
|
flag { print FILENAME":"NR":"$0 }
|
||||||
|
' web/src/api/types.ts \
|
||||||
|
| grep -E '\b(last_heartbeat|capabilities|tags|created_at|updated_at)\??\s*:' \
|
||||||
|
| grep -v 'last_heartbeat_at' \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$BAD_AGENT" ]; then
|
||||||
|
echo "D-2 regression: Agent TS interface re-added a phantom field:"
|
||||||
|
echo "$BAD_AGENT"
|
||||||
|
echo ""
|
||||||
|
echo "The Go-side internal/domain/connector.go::Agent emits exactly:"
|
||||||
|
echo "id, name, hostname, status, last_heartbeat_at?, registered_at,"
|
||||||
|
echo "os, architecture, ip_address, version, retired_at?, retired_reason?."
|
||||||
|
echo "The five fields blocked by this guard (last_heartbeat,"
|
||||||
|
echo "capabilities, tags, created_at, updated_at) were TS phantoms"
|
||||||
|
echo "the Go struct never emitted. See unified-audit.md"
|
||||||
|
echo "diff-05x06-7cdf4e78ae24 for closure rationale."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# D-2 Issuer phantom-field check.
|
||||||
|
BAD_ISSUER=$(awk '
|
||||||
|
/^export interface Issuer \{/ { flag=1; next }
|
||||||
|
flag && /^\}/ { flag=0 }
|
||||||
|
flag { print FILENAME":"NR":"$0 }
|
||||||
|
' web/src/api/types.ts \
|
||||||
|
| grep -E '\bstatus\??\s*:' \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$BAD_ISSUER" ]; then
|
||||||
|
echo "D-2 regression: Issuer TS interface re-added a phantom 'status' field:"
|
||||||
|
echo "$BAD_ISSUER"
|
||||||
|
echo ""
|
||||||
|
echo "The Go-side internal/domain/connector.go::Issuer has no 'status'"
|
||||||
|
echo "field — only 'enabled' (bool). Render sites derive the displayed"
|
||||||
|
echo "status from 'enabled' at the call site (see"
|
||||||
|
echo "web/src/pages/IssuersPage.tsx::issuerStatus). See unified-audit.md"
|
||||||
|
echo "diff-05x06-97fab8783a5c for closure rationale."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# D-2 Notification phantom-field check.
|
||||||
|
BAD_NOTIF=$(awk '
|
||||||
|
/^export interface Notification \{/ { flag=1; next }
|
||||||
|
flag && /^\}/ { flag=0 }
|
||||||
|
flag { print FILENAME":"NR":"$0 }
|
||||||
|
' web/src/api/types.ts \
|
||||||
|
| grep -E '\bsubject\??\s*:' \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$BAD_NOTIF" ]; then
|
||||||
|
echo "D-2 regression: Notification TS interface re-added a phantom 'subject' field:"
|
||||||
|
echo "$BAD_NOTIF"
|
||||||
|
echo ""
|
||||||
|
echo "The Go-side internal/domain/notification.go::NotificationEvent"
|
||||||
|
echo "has no 'subject' field — only 'message'. Pre-D-2 the consumer"
|
||||||
|
echo "at NotificationsPage.tsx had a dead '|| n.subject' fallback"
|
||||||
|
echo "that always fell through. See unified-audit.md"
|
||||||
|
echo "diff-05x06-caba9eb3620e for closure rationale."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Forbidden client-side bulk-action loop regression guard (L-1)
|
||||||
|
# L-1 master closed cat-l-fa0c1ac07ab5 (bulk-renew loop) and
|
||||||
|
# cat-l-8a1fb258a38a (bulk-reassign loop) by adding server-side
|
||||||
|
# bulk endpoints (POST /api/v1/certificates/bulk-renew and
|
||||||
|
# POST /api/v1/certificates/bulk-reassign) that the GUI calls
|
||||||
|
# in a single round-trip. Pre-L-1 the GUI looped per-cert
|
||||||
|
# HTTP calls — 100 selected certs = 100 round-trips × ~50–200ms
|
||||||
|
# each = a 5–20-second wedge during which the operator stares
|
||||||
|
# at a progress bar.
|
||||||
|
#
|
||||||
|
# This step grep-fails the build if either loop shape reappears
|
||||||
|
# in CertificatesPage.tsx. Patterns catch the actual pre-L-1
|
||||||
|
# shapes:
|
||||||
|
# - `for (const id of ids) { await triggerRenewal(id) }`
|
||||||
|
# - `for (const id of ids) { await updateCertificate(id, { owner_id }) }`
|
||||||
|
# - `for (let i = 0; i < ids.length; i++) { await triggerRenewal(ids[i]) }`
|
||||||
|
#
|
||||||
|
# Allowed: comment lines explaining the pre-L-1 pattern in the
|
||||||
|
# docblock above each handler. Test files (_test.tsx) exempt
|
||||||
|
# so negative-pattern tests can keep working.
|
||||||
|
#
|
||||||
|
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||||
|
# cat-l-fa0c1ac07ab5 and cat-l-8a1fb258a38a for closure
|
||||||
|
# rationale, or web/src/api/client.ts::bulkRenewCertificates
|
||||||
|
# / bulkReassignCertificates for the canonical call path.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BAD_LOOP=$(grep -nE 'for[[:space:]]*\(' web/src/pages/CertificatesPage.tsx 2>/dev/null \
|
||||||
|
| grep -E 'await[[:space:]]+(triggerRenewal|updateCertificate)\(' \
|
||||||
|
| grep -v '\.test\.' \
|
||||||
|
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$BAD_LOOP" ]; then
|
||||||
|
echo "L-1 regression: client-side bulk-action loop reappeared in CertificatesPage.tsx:"
|
||||||
|
echo "$BAD_LOOP"
|
||||||
|
echo ""
|
||||||
|
echo "Use bulkRenewCertificates({ certificate_ids: [...] }) or"
|
||||||
|
echo "bulkReassignCertificates({ certificate_ids: [...], owner_id, team_id? })"
|
||||||
|
echo "instead of looping per-item HTTP calls. See"
|
||||||
|
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-l-* for rationale."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Forbidden orphan-CRUD client function regression guard (B-1)
|
||||||
|
# B-1 master closed four audit findings — three orphan-update fns
|
||||||
|
# (cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9) and one orphan CRUD
|
||||||
|
# surface (cat-b-4631ca092bee, RenewalPolicy) — by wiring per-page
|
||||||
|
# Edit modals so every backend write endpoint has at least one
|
||||||
|
# GUI consumer. The fourth finding (cat-b-9b97ffb35ef7) deleted
|
||||||
|
# the dead `exportCertificatePEM` duplicate.
|
||||||
|
#
|
||||||
|
# Pre-B-1 the failure mode was: backend ships a CRUD handler,
|
||||||
|
# client.ts ships the matching `update*` / `delete*` / `create*`
|
||||||
|
# function, but no page imports it. Operators were forced to
|
||||||
|
# `psql` directly to edit team names, owner emails, agent-group
|
||||||
|
# match rules, issuer names, profile names, or any renewal-policy
|
||||||
|
# field — turning a 30-second GUI task into a 30-minute database
|
||||||
|
# excursion with audit-trail gaps.
|
||||||
|
#
|
||||||
|
# This step fails the build if any of the eight previously-orphan
|
||||||
|
# client functions loses its page consumer (i.e. a future refactor
|
||||||
|
# accidentally re-orphans them). Each fn must have ≥1 non-test
|
||||||
|
# consumer under web/src/pages/. Tests (*.test.ts(x)) and the
|
||||||
|
# client.ts definition file itself are exempt.
|
||||||
|
#
|
||||||
|
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||||
|
# cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9, cat-b-4631ca092bee,
|
||||||
|
# cat-b-9b97ffb35ef7 for closure rationale.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
ORPHAN_FNS="updateOwner updateTeam updateAgentGroup updateIssuer updateProfile createRenewalPolicy updateRenewalPolicy deleteRenewalPolicy"
|
||||||
|
FAIL=0
|
||||||
|
for fn in $ORPHAN_FNS; do
|
||||||
|
HITS=$(grep -rE "\b${fn}\b" web/src/pages/ 2>/dev/null \
|
||||||
|
| grep -vE '\.test\.(ts|tsx):' \
|
||||||
|
| wc -l)
|
||||||
|
if [ "$HITS" -eq 0 ]; then
|
||||||
|
echo "::error::B-1 regression: client function '${fn}' has zero consumers under web/src/pages/."
|
||||||
|
echo " Every backend CRUD endpoint must have a GUI consumer to avoid forcing operators to psql."
|
||||||
|
echo " Either restore the page consumer or delete the client function in the same commit."
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# cat-b-9b97ffb35ef7: exportCertificatePEM was deleted as a dead
|
||||||
|
# duplicate of downloadCertificatePEM. Block resurrection.
|
||||||
|
if grep -nE 'export\s+const\s+exportCertificatePEM' web/src/api/client.ts >/dev/null 2>&1; then
|
||||||
|
echo "::error::B-1 regression: exportCertificatePEM was removed as a dead duplicate of downloadCertificatePEM."
|
||||||
|
echo " If a JSON variant is needed, add an explicit page consumer in the same commit."
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
if [ "$FAIL" -ne 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "B-1 orphan-CRUD client function guardrail: all 8 functions have page consumers."
|
||||||
|
|
||||||
|
- name: Forbidden strings.Contains(err.Error()) regression guard (S-2)
|
||||||
|
# S-2 closure (cat-s6-efc7f6f6bd50): replaced 30 brittle
|
||||||
|
# substring-match error-dispatch sites in internal/api/handler/
|
||||||
|
# with errors.Is + typed sentinels (repository.ErrNotFound,
|
||||||
|
# repository.ErrForeignKeyConstraint via the
|
||||||
|
# repository.IsForeignKeyError helper). This step grep-fails
|
||||||
|
# the build if any new strings.Contains(err.Error(), "not found")
|
||||||
|
# or strings.Contains(err.Error(), "violates foreign key")
|
||||||
|
# site appears under internal/api/handler/.
|
||||||
|
#
|
||||||
|
# Allowed: closure-comments documenting the convention (e.g.
|
||||||
|
# bulk_reassignment.go's "post-M-1 errToStatus convention"
|
||||||
|
# docblock); domain-specific substring patterns that are
|
||||||
|
# legitimately one-off ("cannot approve", "cannot reject",
|
||||||
|
# "cannot be parsed", "challenge password") — flagged as
|
||||||
|
# deferred follow-ups in the S-2 commit message.
|
||||||
|
#
|
||||||
|
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||||
|
# cat-s6-efc7f6f6bd50 for closure rationale.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
BAD=$(grep -rnE 'strings\.Contains\(err\.Error\(\),\s*"(not found|violates foreign key|RESTRICT)"' internal/api/handler/ 2>/dev/null \
|
||||||
|
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$BAD" ]; then
|
||||||
|
echo "S-2 regression: brittle substring-match error-dispatch reappeared:"
|
||||||
|
echo "$BAD"
|
||||||
|
echo ""
|
||||||
|
echo "Use errors.Is(err, repository.ErrNotFound) for not-found dispatch,"
|
||||||
|
echo "or repository.IsForeignKeyError(err) for FK violations."
|
||||||
|
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||||
|
echo "cat-s6-efc7f6f6bd50 for closure rationale."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "S-2 typed-sentinel error-dispatch guardrail: clean."
|
||||||
|
|
||||||
- name: Race Detection
|
- name: Race Detection
|
||||||
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/crypto/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -timeout 300s
|
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/crypto/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -timeout 300s
|
||||||
|
|
||||||
@@ -306,6 +647,176 @@ jobs:
|
|||||||
working-directory: web
|
working-directory: web
|
||||||
run: npx vite build
|
run: npx vite build
|
||||||
|
|
||||||
|
- name: Forbidden hardcoded source-count prose regression guard (S-1)
|
||||||
|
# S-1 master closed cat-s1-9ce1cbe26876 (README + features.md
|
||||||
|
# stale numeric counts; explicit CLAUDE.md violation per
|
||||||
|
# "version-stamped numbers rot") and
|
||||||
|
# cat-s1-features_md_issuer_count_contradiction (features.md
|
||||||
|
# self-disagreed on issuer count: 9 vs 12 in the same doc).
|
||||||
|
# The fix replaced source-derived numbers in prose with
|
||||||
|
# "rebuild via <command>" patterns documented in CLAUDE.md::
|
||||||
|
# "Current-state commands". This step grep-fails the build if
|
||||||
|
# any of the previously-stale sites reintroduces a hardcoded
|
||||||
|
# count.
|
||||||
|
#
|
||||||
|
# Allowed surfaces: demo-fixture prose in README ("32
|
||||||
|
# certificates" — those are seed_demo.sql facts, not live
|
||||||
|
# source counts), historical-milestone counts in
|
||||||
|
# WORKSPACE-CHANGELOG.md, the testing-guide example phrasing
|
||||||
|
# ("README claims 8 issuer connectors but only 6 exist"),
|
||||||
|
# and any number that quotes the source command immediately
|
||||||
|
# adjacent.
|
||||||
|
#
|
||||||
|
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||||
|
# cat-s1-9ce1cbe26876 + cat-s1-features_md_issuer_count_contradiction
|
||||||
|
# for closure rationale.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
BAD=$(grep -rnE '\b[0-9]+\s+(issuer connectors?|target connectors?|notifier connectors?|discovery connectors?|MCP tools|OpenAPI operations|migrations|database tables|frontend pages|HTTP routes)\b' \
|
||||||
|
README.md docs/ 2>/dev/null \
|
||||||
|
| grep -vE 'WORKSPACE-CHANGELOG|seed_demo|demo override' \
|
||||||
|
| grep -vE 'DRIFT HAZARD|Source: |Rebuild|rebuild via|grep -|wc -l|ls -d|find ' \
|
||||||
|
| grep -vE 'README claims [0-9]+ issuer connectors but only [0-9]+ exist' \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$BAD" ]; then
|
||||||
|
echo "S-1 regression: hardcoded source-count prose reappeared:"
|
||||||
|
echo "$BAD"
|
||||||
|
echo ""
|
||||||
|
echo "CLAUDE.md rule: 'Numeric claims about current state rot.'"
|
||||||
|
echo "Replace the count with the grep command from CLAUDE.md::"
|
||||||
|
echo "'Current-state commands' (e.g. 'ls -d internal/connector/issuer/*/ | wc -l')"
|
||||||
|
echo "or rephrase to reference the rebuild command on the same line."
|
||||||
|
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||||
|
echo "cat-s1-9ce1cbe26876 for closure rationale."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "S-1 stale-counts guardrail: clean."
|
||||||
|
|
||||||
|
- name: Documented orphan client fns sync guard (P-1)
|
||||||
|
# P-1 master closed diff-04x03-d24864996ad4 + cat-b-dc46aadab98e
|
||||||
|
# by documenting 17 detail-page-candidate orphan client.ts
|
||||||
|
# functions in a docblock at the top of web/src/api/client.ts.
|
||||||
|
# This step verifies the docblock list ↔ export list relationship:
|
||||||
|
# every name listed in the docblock must still be declared as
|
||||||
|
# an export below it (catches drift where someone deletes the
|
||||||
|
# export but forgets the docblock, or vice versa).
|
||||||
|
#
|
||||||
|
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||||
|
# diff-04x03-d24864996ad4 + cat-b-dc46aadab98e for closure rationale.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
DOCUMENTED='getAgentGroup getAgentGroupMembers getAuditEvent getCertificateDeployments getDiscoveredCertificate getHealthCheck getHealthCheckHistory getNetworkScanTarget getNotification getOCSPStatus getOwner getPolicy getPolicyViolations getRenewalPolicy getTeam registerAgent updateHealthCheck'
|
||||||
|
MISSING=""
|
||||||
|
for fn in $DOCUMENTED; do
|
||||||
|
if ! grep -qE "^export const ${fn}\b" web/src/api/client.ts; then
|
||||||
|
MISSING="${MISSING}${fn} "
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$MISSING" ]; then
|
||||||
|
echo "P-1 regression: documented orphan(s) missing from client.ts exports:"
|
||||||
|
echo " $MISSING"
|
||||||
|
echo ""
|
||||||
|
echo "Either restore the export, or delete the corresponding line"
|
||||||
|
echo "in the documented-orphans docblock at the top of client.ts."
|
||||||
|
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||||||
|
echo "diff-04x03-d24864996ad4 for closure rationale."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "P-1 documented-orphans sync guard: clean ($(echo $DOCUMENTED | wc -w) fns verified)."
|
||||||
|
|
||||||
|
- name: Forbidden env-var docs drift regression guard (G-3)
|
||||||
|
# G-3 master closed cat-g-163dae19bc59 (docs-only env vars
|
||||||
|
# phantom in features.md), cat-g-b8f8f8796159 (6 config-only
|
||||||
|
# env vars never documented), and cat-g-renewal_check_interval_rename_drift
|
||||||
|
# (features.md still advertised the pre-rename
|
||||||
|
# CERTCTL_RENEWAL_CHECK_INTERVAL after it was renamed to
|
||||||
|
# CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL). This step runs
|
||||||
|
# `comm -23` both ways between the env vars defined in Go
|
||||||
|
# source (config.go + cmd/agent + deploy/test fixtures + ACME
|
||||||
|
# DNS-01 script env exports) and the env vars mentioned in
|
||||||
|
# README + docs/ + deploy/helm/.
|
||||||
|
#
|
||||||
|
# Allowlist: env vars that are documented as integration-
|
||||||
|
# surface contracts (script env exports for ACME DNS-01,
|
||||||
|
# OpenSSL CA scripts, StepCA per-issuer-config-blob fields,
|
||||||
|
# Webhook per-notifier-config-blob fields, ACME EAB, audit
|
||||||
|
# exclusion, demo-stack overrides) but not consumed directly
|
||||||
|
# by config.go. Each entry below has a one-line justification
|
||||||
|
# — if you add a new entry, add the justification too.
|
||||||
|
#
|
||||||
|
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||||
|
# cat-g-* for closure rationale.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# Defined: config.go + agent + cli + mcp-server + server cmds + test fixtures + ACME DNS export
|
||||||
|
{
|
||||||
|
grep -nE '"CERTCTL_[A-Z_]+"' internal/config/config.go | sed -E 's/.*"(CERTCTL_[A-Z_]+)".*/\1/'
|
||||||
|
grep -rhoE '"CERTCTL_[A-Z_]+"' cmd/agent/*.go cmd/cli/*.go cmd/mcp-server/*.go cmd/server/*.go 2>/dev/null | sed -E 's/"(CERTCTL_[A-Z_]+)"/\1/'
|
||||||
|
grep -rhoE 'CERTCTL_[A-Z_]+' deploy/test/qa_test.go internal/connector/issuer/acme/dns.go 2>/dev/null
|
||||||
|
} | grep -E '^CERTCTL_' | sort -u > /tmp/g3-defined.txt
|
||||||
|
# Documented: README + docs + helm
|
||||||
|
grep -rhoE '\bCERTCTL_[A-Z_]+\b' README.md docs/ deploy/helm/ 2>/dev/null | sort -u > /tmp/g3-docs.txt
|
||||||
|
# Allowlist of env vars documented as external integration contracts.
|
||||||
|
# Each entry justifies itself in one line; if you add to this list,
|
||||||
|
# add the justification.
|
||||||
|
ALLOWED='^(
|
||||||
|
CERTCTL_OPENSSL_SIGN_SCRIPT|
|
||||||
|
CERTCTL_OPENSSL_REVOKE_SCRIPT|
|
||||||
|
CERTCTL_OPENSSL_CRL_SCRIPT|
|
||||||
|
CERTCTL_OPENSSL_TIMEOUT_SECONDS|
|
||||||
|
CERTCTL_STEPCA_URL|
|
||||||
|
CERTCTL_STEPCA_FINGERPRINT|
|
||||||
|
CERTCTL_STEPCA_PROVISIONER|
|
||||||
|
CERTCTL_STEPCA_PROVISIONER_NAME|
|
||||||
|
CERTCTL_STEPCA_PROVISIONER_KEY|
|
||||||
|
CERTCTL_STEPCA_PROVISIONER_JWK|
|
||||||
|
CERTCTL_STEPCA_PROVISIONER_PASSWORD|
|
||||||
|
CERTCTL_STEPCA_PASSWORD|
|
||||||
|
CERTCTL_STEPCA_KEY_PATH|
|
||||||
|
CERTCTL_STEPCA_ROOT_CA|
|
||||||
|
CERTCTL_WEBHOOK_URL|
|
||||||
|
CERTCTL_WEBHOOK_SECRET|
|
||||||
|
CERTCTL_ACME_EAB_KID|
|
||||||
|
CERTCTL_ACME_EAB_HMAC|
|
||||||
|
CERTCTL_ACME_DNS_PROPAGATION_WAIT|
|
||||||
|
CERTCTL_AUDIT_EXCLUDE_PATHS|
|
||||||
|
CERTCTL_TLS_|
|
||||||
|
CERTCTL_TLS_INSECURE_SKIP_VERIFY|
|
||||||
|
CERTCTL_SERVER_CA_BUNDLE_PATH|
|
||||||
|
CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY|
|
||||||
|
CERTCTL_QA_[A-Z_]+
|
||||||
|
)$'
|
||||||
|
# ^ The CERTCTL_OPENSSL_* / CERTCTL_STEPCA_* / CERTCTL_WEBHOOK_* /
|
||||||
|
# CERTCTL_ACME_EAB_* / CERTCTL_ACME_DNS_PROPAGATION_WAIT /
|
||||||
|
# CERTCTL_AUDIT_EXCLUDE_PATHS / CERTCTL_TLS_* / CERTCTL_SERVER_* /
|
||||||
|
# CERTCTL_QA_* sets are documented integration-surface contracts
|
||||||
|
# (script invocations, per-issuer config-blob field names,
|
||||||
|
# per-notifier config-blob field names, demo-stack overrides,
|
||||||
|
# test fixtures) — not server-side env vars in config.go.
|
||||||
|
# The audit's "37 docs-only" count over-flagged these; the
|
||||||
|
# closure narrows the gate to the specific drift sites
|
||||||
|
# (renewal-interval rename + 6 config-only) and allowlists
|
||||||
|
# the documented external contracts here.
|
||||||
|
ALLOWED_FLAT=$(echo "$ALLOWED" | tr -d '\n ')
|
||||||
|
DOCS_ONLY=$(comm -13 /tmp/g3-defined.txt /tmp/g3-docs.txt | grep -vE "$ALLOWED_FLAT" || true)
|
||||||
|
CONFIG_ONLY=$(comm -23 /tmp/g3-defined.txt /tmp/g3-docs.txt || true)
|
||||||
|
if [ -n "$DOCS_ONLY" ]; then
|
||||||
|
echo "G-3 regression: env var(s) mentioned in docs but not defined in Go source AND not in the documented integration-surface allowlist:"
|
||||||
|
echo "$DOCS_ONLY"
|
||||||
|
echo ""
|
||||||
|
echo "Either delete from docs (phantom/typo) or add to config.go,"
|
||||||
|
echo "or add to the ALLOWED list with a one-line justification."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -n "$CONFIG_ONLY" ]; then
|
||||||
|
echo "G-3 regression: env var(s) defined in Go source but never documented:"
|
||||||
|
echo "$CONFIG_ONLY"
|
||||||
|
echo ""
|
||||||
|
echo "Add an entry to docs/features.md (or another canonical doc) so operators can find it."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "G-3 env-var docs drift guardrail: clean."
|
||||||
|
|
||||||
helm-lint:
|
helm-lint:
|
||||||
name: Helm Chart Validation
|
name: Helm Chart Validation
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+205
-1
@@ -2,7 +2,211 @@
|
|||||||
|
|
||||||
All notable changes to certctl are documented in this file. Dates use ISO 8601. Versions follow [Semantic Versioning](https://semver.org/).
|
All notable changes to certctl are documented in this file. Dates use ISO 8601. Versions follow [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
## [unreleased] — 2026-04-24
|
## [unreleased] — 2026-04-25
|
||||||
|
|
||||||
|
### H-1: Security hardening trio — closed end-to-end
|
||||||
|
|
||||||
|
> Three 2026-04-24 audit findings (all P2) that together complete the HTTPS-Everywhere security baseline. The audit flagged: (1) the unauth surface (EST RFC 7030, SCEP, PKI CRL/OCSP, /health, /ready) accepted arbitrary-size request bodies because the `noAuthHandler` middleware chain was missing the `bodyLimitMiddleware` that the authed `apiHandler` chain has; (2) zero security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) were emitted on any response — enabling clickjacking, MIME-sniffing, and untrusted-origin resource loads against the dashboard and API; (3) `CERTCTL_CONFIG_ENCRYPTION_KEY` was accepted with any non-empty value, including a single character — PBKDF2-SHA256 with 100k rounds does not compensate for low-entropy passphrases at scale (CWE-916 / CWE-329).
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
**Operators with low-entropy `CERTCTL_CONFIG_ENCRYPTION_KEY` will fail to start after upgrade.** Pre-H-1 the field accepted any non-empty string. Post-H-1 it requires ≥32 bytes (e.g. `openssl rand -base64 32`). The startup error names the offending env var, the actual length, the required minimum, and the canonical generation command. Empty (`""`) remains accepted — the existing fail-closed sentinel `crypto.ErrEncryptionKeyRequired` triggers downstream when an empty key tries to encrypt or decrypt. Operators using a short passphrase must rotate before the upgrade.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`internal/api/middleware/securityheaders.go`** (new) — `SecurityHeaders` middleware applies HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and a conservative Content-Security-Policy on every response. Defaults via `SecurityHeadersDefaults()` are: `Strict-Transport-Security: max-age=31536000; includeSubDomains`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer-when-downgrade`, and `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'`. Operators behind a customising reverse proxy can override per-header by setting any field of the config struct to the empty string (omits that header).
|
||||||
|
- **`bodyLimitMiddleware` wired into `noAuthHandler`** in `cmd/server/main.go`. Same default cap (1 MB, configurable via `CERTCTL_MAX_BODY_SIZE`), same 413 response on overflow. Pre-H-1 only the authed surface had this protection.
|
||||||
|
- **`securityHeadersMiddleware` wired into BOTH chains** (`middlewareStack` for authed routes; `noAuthHandler` for unauth routes). Applied before the audit middleware so headers reach 4xx/5xx responses too — critical for security posture (an attacker probing for misconfiguration sees the same headers on a 401 as on a 200).
|
||||||
|
- **`CERTCTL_CONFIG_ENCRYPTION_KEY` length validation** in `internal/config/config.go::Validate()` — rejects keys shorter than 32 bytes with a structured error naming the actual length, the required minimum, and the canonical generation command. Empty keys remain accepted (downstream fail-closed sentinel handles it).
|
||||||
|
- **Tests:** `internal/api/middleware/securityheaders_test.go` (4 cases — defaults present, empty disables single header, override applied, headers on 4xx/5xx). `internal/config/config_test.go` adds 5 cases for the encryption-key length check (empty accepted, 1-byte rejected, 31-byte rejected at boundary, 32-byte accepted, 44-byte realistic operator key accepted).
|
||||||
|
|
||||||
|
### Audit findings closed
|
||||||
|
|
||||||
|
- `cat-s5-4936a1cf0118` (P2, EST/SCEP/PKI unauth endpoints bypass `http.MaxBytesReader`)
|
||||||
|
- `cat-s11-missing_security_headers` (P2, no CSP / HSTS / X-Frame-Options on responses)
|
||||||
|
- `cat-r-encryption_key_no_length_validation` (P2, encryption key accepted with zero entropy validation)
|
||||||
|
|
||||||
|
### Known follow-ups (deferred from H-1 scope)
|
||||||
|
|
||||||
|
A weak-key dictionary check (reject `password123`, common ASCII patterns) is deferred — adds operational friction with low marginal entropy gain at the 32-byte minimum. CSP `'unsafe-inline'` for styles is required because Tailwind via Vite injects per-component `<style>` blocks at build time; removing it would require an HTML report or component refactor outside H-1 scope. A `Permissions-Policy` (formerly Feature-Policy) header is not in the H-1 baseline because the dashboard uses no advanced browser APIs (camera, microphone, geolocation); deferred until a real consumer needs it.
|
||||||
|
|
||||||
|
### D-2: TS ↔ Go type drift cluster — closed end-to-end
|
||||||
|
|
||||||
|
> The 2026-04-24 coverage-gap audit flagged five `diff-05x06-*` findings — every one a TypeScript-vs-Go shape mismatch where the on-wire JSON the backend emits and the TS interface in `web/src/api/types.ts` had drifted apart. D-1 master closed the same pattern for `Certificate` (cat-f-ae0d06b6588f, 5 phantom fields trimmed, plus the cat-f-cert_detail_page_key_render_fallback render-site fix). D-2 closes it for the remaining five entities: Agent, Target, DiscoveredCertificate, Issuer, and Notification. The audit's blunt rule "stricter side is the contract" decides the per-entity verdict — for TS phantoms (fields declared on TS, never emitted by Go) the Go side wins and TS gets trimmed; for TS-missing fields (emitted by Go, absent from TS) the Go side still wins and TS gets the addition. Pre-D-2 the failure modes were: phantom fields silently rendered `'—'` at consumer sites (e.g. AgentDetailPage's "Capabilities" + "Tags" sections always rendered empty; IssuersPage rendered `'Unknown'` for every issuer; NotificationsPage's `n.message || n.subject` fallback always fell through), and missing fields forced `(target as any).retired_at` escapes that lost type-checking. Verify-only side task: Certificate / ManagedCertificate confirmed clean since D-1.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
None on the wire. The JSON the backend emits is byte-identical pre/post-D-2 — D-2 is purely TS-side reconciliation. The interface shapes change in ways that are TypeScript compile errors at consumer sites that read trimmed phantoms (intentionally — that's the closure mechanism) but no operator-visible behaviour shifts.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `Target` interface gains `retired_at?: string | null` and `retired_reason?: string | null` (mirrors the Agent retirement-fields shape and the Go-side `internal/domain/connector.go::DeploymentTarget` I-004 model). An Agent retire cascades to all associated Targets per `service.RetireAgent → repository.RetireTarget`; the GUI can now type-check the retired-state surfacing without `(target as any).retired_at` escapes.
|
||||||
|
- `DiscoveredCertificate` interface gains `pem_data?: string`. The Go-side struct (`internal/domain/discovery.go::DiscoveredCertificate.PEMData`, `omitempty`) emits this field on the wire — populated by the agent filesystem scanner, the cloud-secret-manager connectors, and the repo SELECT. Optional because Go uses `omitempty`. Consumers can now reach the raw PEM with type-checked code.
|
||||||
|
- **CI regression guardrail extension** in `.github/workflows/ci.yml` (renamed `Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)`) — adds three new awk-windowed greps over the Agent / Issuer / Notification interfaces in `types.ts` that fail the build if any of the trimmed phantom fields reappear. The Agent regex `\b(last_heartbeat|capabilities|tags|created_at|updated_at)\b` is paired with a `grep -v 'last_heartbeat_at'` filter to avoid false positives on the legitimate Go-emitted heartbeat field.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `Agent` interface — 5 phantom fields trimmed: `last_heartbeat`, `capabilities`, `tags`, `created_at`, `updated_at`. None emitted by `internal/domain/connector.go::Agent`. Two had real consumers in `AgentDetailPage.tsx` (capabilities + tags sections) — both were removed because their guards always evaluated false. The "Updated" InfoRow that read `agent.updated_at` was also dropped (Go has no equivalent timestamp on Agent). `last_heartbeat_at` flipped from required to optional to match Go's `*time.Time omitempty`.
|
||||||
|
- `Issuer` interface — phantom `status: string` removed. Go has only `Enabled bool`. Both `IssuersPage.tsx::issuerStatus` and `IssuerDetailPage.tsx::issuerStatus` rewritten to compute `i.enabled ? 'Enabled' : 'Disabled'` exclusively (the pre-D-2 fallback `issuer.status || 'Unknown'` always rendered 'Unknown').
|
||||||
|
- `Notification` interface — phantom `subject?: string` removed. The dead `{n.message || n.subject}` fallback at `NotificationsPage.tsx:241` was simplified to `{n.message}`. Test mocks in `NotificationsPage.test.tsx` no longer set the field.
|
||||||
|
|
||||||
|
### Audit findings closed
|
||||||
|
|
||||||
|
- diff-05x06-7cdf4e78ae24 (P2, Agent TS↔Go drift)
|
||||||
|
- diff-05x06-2044a46f4dd0 (P2, Target TS↔DeploymentTarget Go drift)
|
||||||
|
- diff-05x06-85ab6b98a2f7 (P2, DiscoveredCertificate TS↔Go drift)
|
||||||
|
- diff-05x06-97fab8783a5c (P2, Issuer TS↔Go drift)
|
||||||
|
- diff-05x06-caba9eb3620e (P2, Notification TS↔NotificationEvent Go drift)
|
||||||
|
- diff-05x06-af18a8d7ef41 (P2, Certificate / ManagedCertificate) — verified no residual drift since D-1; no edit required
|
||||||
|
|
||||||
|
### Known follow-ups (deferred from D-2 scope)
|
||||||
|
|
||||||
|
A richer Issuer status view that derives from `enabled × test_status` (instead of `enabled` alone) is deferred — a UX scope decision, not a contract drift, and the existing `test_status: 'untested' | 'success' | 'failed'` field is already on the TS interface for whoever picks up that work. Real Agent metadata fields (capabilities advertised at heartbeat time, operator-applied tags) are deferred — D-2 removed the false UI affordance; if/when the product wants real fields, re-introduce in `AgentDetailPage` in the same commit that ships the Go-side change. The `DiscoveredCertificate.pem_data` LIST-response performance optimization (gate emission on the per-id detail path, since pem_data is kilobytes per row) is deferred as a separate backend change — D-2 only closed the contract drift.
|
||||||
|
|
||||||
|
### B-1: Orphan-CRUD client functions + RenewalPolicy GUI gap — closed end-to-end
|
||||||
|
|
||||||
|
> The 2026-04-24 coverage-gap audit flagged a cluster of operator-blocking GUI omissions: six client.ts `update*` functions (`updateOwner`, `updateTeam`, `updateAgentGroup`, `updateIssuer`, `updateProfile`, plus the full `*RenewalPolicy` CRUD trio) had backend handlers, OpenAPI operations, and exported TypeScript fetchers — but zero page consumers. Operators wanting to fix a typo in an owner's email, rename a team, retarget an agent group's match rules, or edit a renewal-policy field were forced to either delete-and-recreate (losing FK history and audit-trail continuity) or open a `psql` session against the production database directly. The audit's blunt summary: "every backend feature ships with its GUI surface" — a load-bearing CLAUDE.md invariant — was being violated for five operator-facing entities. B-1 closes that violation by wiring per-page Edit modals onto five existing pages, adding a brand-new `RenewalPoliciesPage` for the rp-* CRUD surface, and deleting one dead duplicate (`exportCertificatePEM`) so the public client surface area stops growing without consumers.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
None. All five existing pages keep their Create + Delete affordances unchanged; Edit is purely additive. `RenewalPoliciesPage` is a new route at `/renewal-policies` and a new sidebar nav item slotted between Policies and Profiles. The `exportCertificatePEM` helper had zero consumers in `web/`, MCP, CLI, and tests at the time of removal — operators using `downloadCertificatePEM` (the actual call site in `CertificateDetailPage`) are unaffected.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`web/src/pages/RenewalPoliciesPage.tsx`** — a new full-CRUD page for the `rp-*` renewal-policy table. Surfaces a 7-column DataTable (Policy / Renewal Window / Auto / Retries / Alert Thresholds / Created / Actions) with Create, Edit, and Delete affordances. A shared `PolicyFormModal` powers both Create and Edit (the form shape is identical) covering the full domain field set: `name`, `renewal_window_days`, `auto_renew`, `max_retries`, `retry_interval_seconds`, `alert_thresholds_days[]`. The thresholds input parses comma-separated integers (`30, 14, 7, 0`) into the array shape the backend expects. Delete surfaces `repository.ErrRenewalPolicyInUse` (409 from the backend when a policy still has `managed_certificates.renewal_policy_id` references) via an explicit alert so the operator can re-target the dependent certs to a different policy before deletion. Wired into `web/src/main.tsx` routing and `web/src/components/Layout.tsx` sidebar nav.
|
||||||
|
- **EditOwnerModal** in `web/src/pages/OwnersPage.tsx` — pre-populates from the editing owner via `useEffect`, calls `updateOwner(id, {name, email, team_id})`, mirrors the Create modal's TanStack-Query mutation/invalidation pattern.
|
||||||
|
- **EditTeamModal** in `web/src/pages/TeamsPage.tsx` — same shape, fields `name`/`description`.
|
||||||
|
- **EditAgentGroupModal** in `web/src/pages/AgentGroupsPage.tsx` — covers the full match-rule set (`name`, `description`, `match_os`, `match_architecture`, `match_ip_cidr`, `match_version`, `enabled`).
|
||||||
|
- **EditIssuerModal** in `web/src/pages/IssuersPage.tsx` — deliberately rename-only. The `type` field is shown but disabled, the existing `config` blob (which includes credentials for ACME, ADCS, ZeroSSL, etc.) is forwarded untouched, and only `name` is editable. Footer note: "To change issuer type or rotate credentials, delete and recreate." This trades scope for safety — the audit's destructive-rename complaint is closed without surfacing a credential-edit attack surface that has not been threat-modeled.
|
||||||
|
- **EditProfileModal** in `web/src/pages/ProfilesPage.tsx` — same rename-only shape. Forwards full `Partial<CertificateProfile>` with policy fields (`allowed_key_algorithms`, `max_ttl_seconds`, `allowed_ekus`, etc.) preserved untouched. Footer note about deferred policy-field editing.
|
||||||
|
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden orphan-CRUD client function regression guard (B-1)`) — grep-fails the build if any of the eight previously-orphan client functions (`updateOwner`, `updateTeam`, `updateAgentGroup`, `updateIssuer`, `updateProfile`, `createRenewalPolicy`, `updateRenewalPolicy`, `deleteRenewalPolicy`) loses its non-test consumer under `web/src/pages/`. Also blocks resurrection of the deleted `exportCertificatePEM` function. Verified locally on the post-fix tree (passes — all 8 fns have ≥2 consumers); fires against synthetic regressions (delete the Edit modal → guardrail fires the next CI run).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `web/src/api/client.ts::exportCertificatePEM` — closes `cat-b-9b97ffb35ef7`. The function returned `{cert_pem, chain_pem, full_pem}` JSON but had zero consumers across `web/`, MCP, CLI, and tests; `downloadCertificatePEM` (the blob-download path consumed by `CertificateDetailPage`) covers all real call sites. Test references in `web/src/api/client.test.ts` and `client.error.test.ts` were also removed. The CI guardrail blocks resurrection without an accompanying page consumer.
|
||||||
|
|
||||||
|
### Audit findings closed
|
||||||
|
|
||||||
|
- `cat-b-31ceb6aaa9f1` (P1, `updateOwner`/`updateTeam`/`updateAgentGroup` orphan)
|
||||||
|
- `cat-b-7a34f893a8f9` (P1, `updateIssuer`/`updateProfile` orphan, rename-only closure)
|
||||||
|
- `cat-b-4631ca092bee` (P1, RenewalPolicy CRUD orphan — new RenewalPoliciesPage)
|
||||||
|
- `cat-b-9b97ffb35ef7` (P3, `exportCertificatePEM` dead duplicate)
|
||||||
|
|
||||||
|
### Known follow-ups (deferred from B-1 scope)
|
||||||
|
|
||||||
|
A fuller `EditIssuerModal` with explicit credential-rotation flow is deferred — that needs an explicit threat model (rotation reuse window, audit-trail granularity, in-flight CSR cancellation), and the audit's destructive-rename complaint is closed by rename-only Edit alone. Likewise an `EditProfileModal` with policy-field editing (max-TTL, allowed EKUs, allowed key algorithms) is deferred because policy edits affect the `enforce_certificate_policy` evaluator's semantics for already-issued certs and warrant their own scope. Per-page Vitest coverage for the new Edit modals is deferred — the CI grep guardrail catches the same regression vector ("page lost its `update*` fn consumer") at lower cost than five new test files.
|
||||||
|
|
||||||
|
### L-1: Client-side bulk-action loops — closed end-to-end
|
||||||
|
|
||||||
|
> The certctl dashboard's busiest screen (`CertificatesPage.tsx`) had two bulk-action workflows that looped per-cert HTTP calls. Selecting 100 certs and clicking "Renew" issued 100 sequential `POST /api/v1/certificates/{id}/renew` requests; "Reassign owner" issued 100 sequential `PUT /api/v1/certificates/{id}` requests. Each round-trip carried ~50–200 ms of Auth → audit-log → handler → service → repo → DB → audit-write → response, so a 100-cert bulk action was a 5–20-second wedge during which the operator stared at a progress bar. The bulk-revoke endpoint (`POST /api/v1/certificates/bulk-revoke`) already shipped in v2.0.x as the canonical pattern for this; L-1 ports that exact shape to bulk-renew (P1) and bulk-reassign (P2). One backend round-trip; one audit event for the entire operation; per-cert success/skip/error counts in a single response envelope. Bundled with two new MCP tools and an OpenAPI spec update so non-GUI callers (CLI / MCP / blackbox probes) can use the same endpoints.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
None. Both endpoints are additive; the per-cert `POST /certificates/{id}/renew` and `PUT /certificates/{id}` paths remain available and unchanged. The frontend implementation switches from looping to single-call, but operators with custom GUIs hitting the per-cert endpoints continue to work.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`POST /api/v1/certificates/bulk-renew`** — enqueues a renewal job for every matching managed certificate. Supports criteria-mode (`{profile_id, owner_id, agent_id, issuer_id, team_id}`) and explicit-IDs mode (`{certificate_ids}`). Mirrors `BulkRevokeCriteria` field-for-field (sans the RFC-5280 reason code). Returns `{total_matched, total_enqueued, total_skipped, total_failed, enqueued_jobs[], errors[]}`. NOT admin-gated — bulk renewal is non-destructive (worst case it kicks off some redundant ACME orders). Status filter: certs in `Archived/Revoked/Expired/RenewalInProgress` are silent-skipped (TotalSkipped++) rather than returned as errors. Implementation: `internal/domain/bulk_renewal.go`, `internal/service/bulk_renewal.go`, `internal/api/handler/bulk_renewal.go`.
|
||||||
|
- **`POST /api/v1/certificates/bulk-reassign`** — updates `owner_id` (required) and `team_id` (optional) on every cert in `certificate_ids`. Skips certs already owned by the target (silent no-op surfaced as `total_skipped`). Validates the target `owner_id` upfront — a non-existent owner returns 400 (via the typed `service.ErrBulkReassignOwnerNotFound` sentinel) before any cert is touched. NOT admin-gated. Implementation: `internal/domain/bulk_reassignment.go`, `internal/service/bulk_reassignment.go`, `internal/api/handler/bulk_reassignment.go`.
|
||||||
|
- **MCP tools `certctl_bulk_renew_certificates` and `certctl_bulk_reassign_certificates`** in `internal/mcp/tools.go` + `internal/mcp/types.go`. Mirror the existing `certctl_bulk_revoke_certificates` shape so MCP consumers have a uniform bulk-action surface.
|
||||||
|
- **OpenAPI schemas** `BulkRenewRequest`, `BulkRenewResult`, `BulkEnqueuedJob`, `BulkReassignRequest`, `BulkReassignResult` plus the two new operations with shared envelope semantics.
|
||||||
|
- **Frontend client functions** `bulkRenewCertificates(criteria)` and `bulkReassignCertificates(request)` in `web/src/api/client.ts` with full TS types for both request and response envelopes.
|
||||||
|
- **Service-layer regression tests** for both new services (`internal/service/bulk_renewal_test.go` + `internal/service/bulk_reassignment_test.go`): happy path, criteria-mode, status-skip semantics (RenewalInProgress / Revoked / Archived for renew; already-owned for reassign), empty-criteria rejection, partial-failure tolerance, single-bulk-audit-event contract.
|
||||||
|
- **Handler-layer regression tests** (`internal/api/handler/bulk_renewal_handler_test.go` + `internal/api/handler/bulk_reassignment_handler_test.go`): happy path, empty-body 400, wrong-method 405, actor attribution from `middleware.GetUser`, owner-not-found-sentinel-→-400 mapping for reassign, generic-service-error-→-500.
|
||||||
|
- **Domain-layer JSON-shape tests** pinning the wire contract for `BulkRenewalResult` / `BulkReassignmentResult` / `BulkOperationError`.
|
||||||
|
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden client-side bulk-action loop regression guard (L-1)`) — grep-fails the build if `for(...) await triggerRenewal(...)` or `for(...) await updateCertificate(...)` reappears in `web/src/pages/CertificatesPage.tsx`. Verified: passes against the post-fix tree, fires against synthetic regressions.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`web/src/pages/CertificatesPage.tsx::handleBulkRenewal`** — rewritten from N-call loop to a single `bulkRenewCertificates({ certificate_ids })` call. Result envelope drives the progress UI (matched / enqueued / skipped / failed counts).
|
||||||
|
- **`web/src/pages/CertificatesPage.tsx::handleReassign`** (in the reassign modal) — same shape: single `bulkReassignCertificates({ certificate_ids, owner_id })` call. First-error message surfaced when `total_failed > 0`.
|
||||||
|
- **`internal/api/router/router.go`** — three bulk-* routes (revoke / renew / reassign) registered together as a block before the per-cert `{id}` routes; `HandlerRegistry` gains `BulkRenewal` and `BulkReassignment` fields.
|
||||||
|
- **`cmd/server/main.go`** — constructs `BulkRenewalService` (threads `cfg.Keygen.Mode` so bulk-renew jobs land in the same initial status as single-cert `TriggerRenewal`) and `BulkReassignmentService` alongside the existing `BulkRevocationService`.
|
||||||
|
|
||||||
|
### Performance impact
|
||||||
|
|
||||||
|
100-cert bulk-renew workflow goes from ~10 s of sequential per-cert HTTP (worst case) to a single ~100 ms call — roughly 99% latency reduction on the canonical operator workflow. Server-side resource use also drops: one Auth pass, one audit event, one criteria-resolution query, instead of N of each.
|
||||||
|
|
||||||
|
### Closed audit findings
|
||||||
|
|
||||||
|
- `cat-l-fa0c1ac07ab5` (P1, primary) — bulk renew client-side sequential loop
|
||||||
|
- `cat-l-8a1fb258a38a` (P2) — bulk owner-reassign client-side sequential loop
|
||||||
|
|
||||||
|
### Known follow-ups (deferred from L-1 scope)
|
||||||
|
|
||||||
|
- `cat-b-31ceb6aaa9f1` (P1, `updateOwner`/`updateTeam`/`updateAgentGroup` orphan) — different shape; the fix is "wire up the existing PUT endpoints to the GUI", not "add a bulk endpoint".
|
||||||
|
- `cat-k-e85d1099b2d7` (P2, CertificatesPage no pagination UI) — same page; criteria-mode bulk-renew (`{owner_id: 'o-alice'}`) means an operator can already "renew all of Alice's certs" without paginating, but pagination is still wanted for the table view.
|
||||||
|
- `cat-i-b0924b6675f8` (P1, MCP missing `claim`/`dismiss`/`acknowledge`) — L-1 added two new MCP tools but does NOT close that finding.
|
||||||
|
|
||||||
|
### D-1: StatusBadge enum drift + Certificate phantom fields — closed end-to-end
|
||||||
|
|
||||||
|
> The dashboard silently lied in five places. Agents in the `Degraded` state (the only Go-side AgentStatus that means "needs operator attention") rendered as default neutral grey because StatusBadge mapped `Stale` (a key Go has never emitted) to yellow and let the real `Degraded` value fall through to the dictionary default. Dead-letter notifications (`status: 'dead'`, retries exhausted) rendered as default neutral, visually equated with `read` (operator-acknowledged). The Certificate badge map carried a `PendingIssuance` key that no Go enum value ever emits — dead key, latent confusion vector. CertificateDetailPage's Key Algorithm and Key Size rows always rendered `—` even when the data was a single fetch away, because the lookup went through `cert.key_algorithm` directly — and the underlying `Certificate` TypeScript interface declared five optional fields (`serial_number`, `fingerprint_sha256`, `key_algorithm`, `key_size`, `issued_at`) that Go's `ManagedCertificate` has never carried (those values live on `CertificateVersion`). Five findings, two files, one frontend rebuild. Pre-D-1 the only reason this didn't trip a regression suite was that the regression suite never asserted "every Go-emitted enum value gets a non-default StatusBadge class" — D-1 fixes the visual lies and adds a 38-case Vitest property test that walks every Go enum and pins the contract.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **`Certificate` TypeScript interface no longer declares `serial_number?`, `fingerprint_sha256?`, `key_algorithm?`, `key_size?`, or `issued_at?`.** The Go `ManagedCertificate` (`internal/domain/certificate.go`) has never emitted these fields on list responses; they live on `CertificateVersion` and are reachable via `getCertificateVersions(id)`. Pre-D-5 (the cat-f phantom-fields finding) the optional declarations made `cert.X` always-undefined on lists, and downstream consumers silently rendered `—` for every cert. Post-D-5 a `cert.X` access for any of the five fields is a TypeScript compile error, forcing every consumer to acknowledge the version-fallback pattern. The OpenAPI `ManagedCertificate` schema was already correct — only the TS type was drifted.
|
||||||
|
- **StatusBadge no longer maps `Stale` (Agent) or `PendingIssuance` (Certificate).** Both were dead keys — no Go enum value emits them. Operators with custom CSS hooked off `.badge-warning` for `Stale` will see the same color come back via the new `Degraded` mapping (same class), but JS/TS code that switches on the literal `'Stale'` will need to switch on `'Degraded'` instead. The `PendingIssuance` deletion has no documented downstream consumer.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`web/src/components/StatusBadge.tsx`: `Degraded` (Agent) → `badge-warning` and `dead` (Notification) → `badge-danger`.** First mappings restore the color contract for the two real Go-side values that previously fell through to the dictionary default. The `Degraded` mapping cross-references `internal/domain/connector.go::AgentStatusDegraded`; the `dead` mapping cross-references `internal/domain/notification.go::NotificationStatusDead`.
|
||||||
|
- **`web/src/components/StatusBadge.test.tsx`: 38-case Vitest property test.** Iterates every Go-side enum value (`AgentStatus`, `CertificateStatus`, `JobStatus`, `NotificationStatus`, `DiscoveryStatus`, `HealthStatus`) plus the two frontend-synthesized `Enabled`/`Disabled` labels, asserts every value gets a non-default class (or, for the five intentionally-neutral terminal values like `Archived`/`Cancelled`/`read`, an explicit `badge badge-neutral`). Includes negative assertions on the deleted `Stale` and `PendingIssuance` keys (must fall through to neutral) and specific UX-correctness assertions on the operator-attention semantics (`dead` → danger, `Degraded` → warning).
|
||||||
|
- **`web/src/api/types.test.ts`: D-5 Certificate phantom-fields trim regression.** A `Certificate` literal construction pinned post-trim, plus a sibling `CertificateVersion` literal pinning that the trimmed fields still live on the version envelope. The `tsc --noEmit` gate in CI is the primary enforcement; the test is the documentation of intent.
|
||||||
|
- **CI regression guardrail in `.github/workflows/ci.yml` (`Forbidden StatusBadge dead-key + Certificate phantom-field regression guard (D-1)`).** Two grep blocks: (1) catches `Stale: 'badge-...'` or `PendingIssuance: 'badge-...'` in `web/src/components/StatusBadge.tsx`; (2) uses an awk-scoped window over the `export interface Certificate {` block in `web/src/api/types.ts` to catch any of the five phantom fields reappearing — explicitly excludes the `CertificateVersion` block which legitimately carries them. Verified locally on the post-fix tree (passes) and against synthetic regressions (each fires the guardrail).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`web/src/pages/CertificateDetailPage.tsx`: Key Algorithm and Key Size rows now read from `latestVersion?.key_algorithm` / `latestVersion?.key_size`.** Mirrors the existing `latestVersion` fallback used for `serial_number` and `fingerprint_sha256` earlier in the same file. Pre-D-4 these rows accessed `cert.key_algorithm` and `cert.key_size` directly — both phantom fields per D-5 — so the rows always rendered `—`. The same file's `serial_number` / `fingerprint_sha256` / `issued_at` derivations were also simplified to drop the now-impossible `cert.X || latestVersion?.X` cert-side leg.
|
||||||
|
- **`web/src/components/StatusBadge.tsx` adds a leading docblock** naming the Go-side source-of-truth file for every status family it maps (`AgentStatus`, `CertificateStatus`, `JobStatus`, `NotificationStatus`, `DiscoveryStatus`, `HealthStatus`) and pointing at the property test as the regression vector for future enum changes.
|
||||||
|
- **`api/openapi.yaml::ManagedCertificate`** gets a leading comment cross-referencing the D-5 closure and explaining why per-issuance fields legitimately don't appear here (they live on `CertificateVersion`). Schema property list unchanged — the OpenAPI spec was already correct.
|
||||||
|
|
||||||
|
### Closed audit findings
|
||||||
|
|
||||||
|
- `cat-d-359e92c20cbf` (P1 primary) — Agent: `Stale` dead key + `Degraded` neutral fallthrough
|
||||||
|
- `cat-d-9f4c8e4a91f1` (P2) — Notification: `dead` missing
|
||||||
|
- `cat-d-1447e04732e7` (P3) — Certificate: `PendingIssuance` dead key
|
||||||
|
- `cat-f-cert_detail_page_key_render_fallback` (P2) — render-site uses `cert.key_algorithm` directly
|
||||||
|
- `cat-f-ae0d06b6588f` (P2) — Certificate TS phantom fields (root cause)
|
||||||
|
|
||||||
|
### Known follow-ups (deferred from D-1 scope)
|
||||||
|
|
||||||
|
The audit's broader type-drift cluster (`diff-05x06-7cdf4e78ae24` Agent TS, `diff-05x06-2044a46f4dd0` DeploymentTarget TS, `diff-05x06-caba9eb3620e` Notification TS, `diff-05x06-85ab6b98a2f7` DiscoveredCertificate TS, `diff-05x06-97fab8783a5c` Issuer TS) is out of D-1 scope. Recon for those is per-type field-by-field diff Go ↔ TS — codegen-shaped, not edit-shaped — and warrants its own D-2 master prompt.
|
||||||
|
|
||||||
|
### U-3: GitHub #10 reopened — fresh-clone first-up postgres init failure (P1) — closed end-to-end
|
||||||
|
|
||||||
|
> Operator `mikeakasully` cloned v2.0.50 fresh, ran the canonical quickstart `docker compose -f deploy/docker-compose.yml up -d --build`, and postgres reported `unhealthy` indefinitely; dependent containers (certctl-server, certctl-agent) never started. Root cause: the deploy compose stack mounted both a hand-curated subset of `migrations/*.up.sql` and `seed.sql` into postgres `/docker-entrypoint-initdb.d/`. Postgres applied them at initdb time. Once `seed.sql` referenced columns added by migrations *after* the mounted cutoff (e.g., `policy_rules.severity` from migration 000013, which the mount list never included), initdb crashed mid-seed and the container loop wedged. Two sources of truth — the mount list and the in-tree migration ladder — diverged the moment a seed-touching migration shipped, and the only thing that fixed it was hand-editing the compose file every release. The U-3 closure removes the dual source: postgres now boots empty and the server applies the entire migration ladder + seed at startup via `RunMigrations` + `RunSeed`. Same pattern Helm has used since day one. Bundled with four ride-along audit findings whose fixes are in adjacent code (column rename, missing column, dropped orphan columns, new build-identity endpoint) so operators take the schema-change pain only once.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **`deploy/docker-compose.yml` postgres no longer initdb-mounts the migration files or `seed.sql`.** Operators running on a populated `postgres_data` volume from a pre-U-3 release see no behavioral change (the schema is already in place; `RunMigrations` is `IF NOT EXISTS` and `RunSeed` is `ON CONFLICT DO NOTHING`). Operators running on a *fresh* clone now rely on the server to apply both — which is the bug fix. There is no rollback path other than re-introducing the dual-source-of-truth hazard. See `internal/repository/postgres/db.go::RunSeed` for the runtime contract.
|
||||||
|
- **`migrations/000017_db_coupling_cleanup.up.sql` renames `renewal_policies.retry_interval_minutes` → `retry_interval_seconds`.** The column always held seconds; the column name lied (`cat-o-retry_interval_unit_mismatch`). Operators running raw SQL against the old name need to update their queries. The Go layer (`internal/repository/postgres/renewal_policy.go`) is updated in lockstep so the in-tree code path is unaffected.
|
||||||
|
- **`migrations/000017_db_coupling_cleanup.up.sql` drops `network_scan_targets.health_check_enabled` and `network_scan_targets.health_check_interval_seconds`.** These columns were declared by a long-ago migration but never wired into Go code (`cat-o-health_check_column_orphans`) — schema noise that confused operators reading raw SQL. Anyone with custom dashboards selecting those columns will break.
|
||||||
|
- **The compose demo overlay (`deploy/docker-compose.demo.yml`) no longer initdb-mounts `seed_demo.sql`.** It now sets `CERTCTL_DEMO_SEED=true` and the server applies the demo seed at boot via `RunDemoSeed` after baseline migrations + seed.sql are in place. Same single-source-of-truth pattern as the production path.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Migration `000017_db_coupling_cleanup`** (up + down). Bundles three schema changes in idempotent SQL: (1) rename `renewal_policies.retry_interval_minutes` → `retry_interval_seconds` (DO $$ guard so re-application is safe), (2) add `notification_events.created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`, (3) drop the orphan `network_scan_targets.health_check_*` columns. Reduces operator-visible "schema-change releases" from four to one.
|
||||||
|
- **`internal/repository/postgres.RunSeed`** — runtime equivalent of the deleted initdb mount for `seed.sql`. Called from `cmd/server/main.go` immediately after `RunMigrations`. Idempotent (every INSERT in the shipped seed uses `ON CONFLICT (id) DO NOTHING`); missing-file is a no-op so operators with custom packaging that strips the seed don't break.
|
||||||
|
- **`internal/repository/postgres.RunDemoSeed`** + **`config.DatabaseConfig.DemoSeed`** + **`CERTCTL_DEMO_SEED` env var.** Replaces the deleted `seed_demo.sql` initdb mount. The compose demo overlay sets `CERTCTL_DEMO_SEED=true` and the server applies the demo seed after baseline. Same idempotency contract as the baseline path. Default-off so a vanilla deploy never lands fake-history rows.
|
||||||
|
- **`GET /api/v1/version` endpoint** + **`internal/api/handler.VersionHandler`**. Returns `{version, commit, modified, build_time, go_version}` from `runtime/debug.ReadBuildInfo()` with ldflags-supplied `Version` taking priority. Wired through the no-auth dispatch in `cmd/server/main.go` so probes and rollout systems can read build identity without Bearer credentials. Audit middleware excludes the path so rollout polls don't dominate the audit trail. Closes `cat-u-no_version_endpoint`.
|
||||||
|
- **`notification_events.created_at` column** is now populated by `NotificationRepository.Create` (with a `time.Now()` fallback when the caller leaves it zero) and read back by `scanNotification`. Pre-U-3 the JSON API serialised `0001-01-01T00:00:00Z` — closes `cat-o-notification_created_at_dead_field`.
|
||||||
|
- **Five regression tests** for the U-3 contract: `TestRunSeed_AppliesIdempotently`, `TestRunSeed_MissingFileIsNoOp`, `TestRunDemoSeed_AppliesIdempotently`, `TestMigration000017_RetryIntervalRename`, `TestMigration000017_NotificationCreatedAt`, `TestMigration000017_HealthCheckOrphansDropped`, plus `TestNotificationRepository_CreatedAt_IsPersisted` / `TestNotificationRepository_CreatedAt_DefaultsToNow` for the round-trip. All testcontainers-gated (skipped under `-short`). Three handler-layer unit tests pin `/api/v1/version` (`TestVersion_ReturnsBuildInfo`, `TestVersion_RejectsNonGet`, `TestVersion_LdflagsOverride`).
|
||||||
|
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden migration mount in compose initdb (U-3)`) — grep-fails the build if any `migrations/.*\.sql` or `seed.*\.sql` file is re-mounted into `/docker-entrypoint-initdb.d` in any compose file. Catches future drift before a fresh-clone operator hits it.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`deploy/docker-compose.yml`** + **`deploy/docker-compose.test.yml`** — postgres `volumes:` no longer mount migrations or seed files; postgres healthcheck gains `start_period: 30s`; certctl-server healthcheck gains `start_period: 30s` to absorb the runtime migration + seed application window on first boot.
|
||||||
|
- **`deploy/docker-compose.demo.yml`** — replaces the `seed_demo.sql` initdb mount with the `CERTCTL_DEMO_SEED=true` env var on `certctl-server`.
|
||||||
|
- **`migrations/seed.sql`** — `INSERT INTO renewal_policies` updated to use the new `retry_interval_seconds` column name (lockstep with migration 000017).
|
||||||
|
- **`internal/repository/postgres/renewal_policy.go`** — column references updated to `retry_interval_seconds` across SELECT, INSERT, and UPDATE sites (lockstep with migration 000017).
|
||||||
|
|
||||||
|
### Closed audit findings
|
||||||
|
|
||||||
|
- `cat-u-seed_initdb_schema_drift` (P1, primary U-3 finding)
|
||||||
|
- `cat-o-retry_interval_unit_mismatch` (P1)
|
||||||
|
- `cat-o-notification_created_at_dead_field` (P2)
|
||||||
|
- `cat-o-health_check_column_orphans` (P1)
|
||||||
|
- `cat-u-no_version_endpoint` (P2)
|
||||||
|
|
||||||
### G-1: JWT silent auth downgrade — closed end-to-end
|
### G-1: JWT silent auth downgrade — closed end-to-end
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,50 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
|
|
||||||
|
/api/v1/version:
|
||||||
|
get:
|
||||||
|
tags: [Health]
|
||||||
|
summary: Build identity (version, commit, Go runtime)
|
||||||
|
description: |
|
||||||
|
Returns the running server's build identity. Served without
|
||||||
|
auth so rollout systems and blackbox probes can read it without
|
||||||
|
Bearer credentials. U-3 ride-along (cat-u-no_version_endpoint).
|
||||||
|
Excluded from audit logging because rollout polling would
|
||||||
|
otherwise dominate the audit trail.
|
||||||
|
|
||||||
|
The Version field follows a fallback ladder: ldflags-supplied
|
||||||
|
value > VCS commit SHA > "dev". Commit / Modified / BuildTime
|
||||||
|
come from runtime/debug.BuildInfo (Go 1.18+ stamps these on
|
||||||
|
every module-tracked build). GoVersion is runtime.Version().
|
||||||
|
security: []
|
||||||
|
operationId: getVersion
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Build identity
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [version, commit, modified, build_time, go_version]
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
description: Release tag (ldflags-supplied) or VCS SHA fallback or "dev"
|
||||||
|
example: v2.0.51
|
||||||
|
commit:
|
||||||
|
type: string
|
||||||
|
description: Git SHA from runtime/debug.BuildInfo (vcs.revision); empty when not VCS-tracked
|
||||||
|
modified:
|
||||||
|
type: boolean
|
||||||
|
description: True when build had uncommitted changes (vcs.modified)
|
||||||
|
build_time:
|
||||||
|
type: string
|
||||||
|
description: RFC 3339 build timestamp (vcs.time); empty when not VCS-tracked
|
||||||
|
go_version:
|
||||||
|
type: string
|
||||||
|
description: Go toolchain version that compiled the binary (runtime.Version())
|
||||||
|
example: go1.25.9
|
||||||
|
|
||||||
# ─── Certificates ────────────────────────────────────────────────────
|
# ─── Certificates ────────────────────────────────────────────────────
|
||||||
/api/v1/certificates:
|
/api/v1/certificates:
|
||||||
get:
|
get:
|
||||||
@@ -426,6 +470,69 @@ paths:
|
|||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/certificates/bulk-renew:
|
||||||
|
post:
|
||||||
|
tags: [Certificates]
|
||||||
|
summary: Bulk renew certificates by criteria or explicit IDs
|
||||||
|
description: |
|
||||||
|
Enqueues a renewal job for every matching managed certificate. Mirrors POST
|
||||||
|
/api/v1/certificates/bulk-revoke shape exactly so operators who already know
|
||||||
|
that contract have zero new surface to learn. L-1 closure
|
||||||
|
(cat-l-fa0c1ac07ab5): pre-L-1 the GUI looped per-cert HTTP calls;
|
||||||
|
post-L-1 it's a single POST. Status filter: certs in
|
||||||
|
Archived/Revoked/Expired/RenewalInProgress are silent-skipped (TotalSkipped++)
|
||||||
|
rather than returned as errors. Asynchronous: the action ENQUEUES jobs the
|
||||||
|
scheduler picks up; per-cert {certificate_id, job_id} pairs are returned in
|
||||||
|
enqueued_jobs. NOT admin-gated — bulk renewal is non-destructive.
|
||||||
|
operationId: bulkRenewCertificates
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/BulkRenewRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Bulk renewal result
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/BulkRenewResult"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/certificates/bulk-reassign:
|
||||||
|
post:
|
||||||
|
tags: [Certificates]
|
||||||
|
summary: Bulk reassign owner (and optionally team) for a set of certificates
|
||||||
|
description: |
|
||||||
|
Updates owner_id (required) and team_id (optional) on every certificate in
|
||||||
|
certificate_ids. Skips certs already owned by the target (silent no-op,
|
||||||
|
TotalSkipped++). L-2 closure (cat-l-8a1fb258a38a). Narrower than bulk-renew:
|
||||||
|
explicit IDs only, no criteria-mode. The OwnerID is validated upfront — a
|
||||||
|
non-existent owner returns 400 before any cert is touched. Verb chosen as
|
||||||
|
POST (not PATCH) for codebase consistency with bulk-revoke and bulk-renew.
|
||||||
|
operationId: bulkReassignCertificates
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/BulkReassignRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Bulk reassignment result
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/BulkReassignResult"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
# ─── Certificate Export ──────────────────────────────────────────────
|
# ─── Certificate Export ──────────────────────────────────────────────
|
||||||
/api/v1/certificates/{id}/export/pem:
|
/api/v1/certificates/{id}/export/pem:
|
||||||
get:
|
get:
|
||||||
@@ -3448,6 +3555,15 @@ components:
|
|||||||
- Archived
|
- Archived
|
||||||
|
|
||||||
ManagedCertificate:
|
ManagedCertificate:
|
||||||
|
# D-5 (cat-f-ae0d06b6588f, master): per-issuance fields
|
||||||
|
# (serial_number, fingerprint_sha256, key_algorithm, key_size,
|
||||||
|
# issued_at) are intentionally NOT declared here. They live on
|
||||||
|
# CertificateVersion (per-issuance evidence) and are fetched via
|
||||||
|
# /api/v1/certificates/{id}/versions. ManagedCertificate is the
|
||||||
|
# management envelope; CertificateVersion is the issuance record.
|
||||||
|
# Pre-D-5 the TS Certificate interface had them as optional and
|
||||||
|
# the dashboard's Key Algorithm / Key Size rows always rendered
|
||||||
|
# '—' as a result. The TS trim restores parity with this schema.
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
@@ -3604,6 +3720,116 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Per-certificate error details for failed revocations
|
description: Per-certificate error details for failed revocations
|
||||||
|
|
||||||
|
# L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
|
||||||
|
# bulk-renew + bulk-reassign request/result schemas. Mirror
|
||||||
|
# BulkRevokeRequest/Result envelope shape so frontend bulk-result
|
||||||
|
# rendering is one helper. See internal/domain/bulk_renewal.go +
|
||||||
|
# internal/domain/bulk_reassignment.go for the Go-side source of
|
||||||
|
# truth.
|
||||||
|
BulkRenewRequest:
|
||||||
|
type: object
|
||||||
|
description: Criteria for bulk renewal. At least one selector required.
|
||||||
|
properties:
|
||||||
|
profile_id:
|
||||||
|
type: string
|
||||||
|
description: Renew all certificates matching this profile
|
||||||
|
owner_id:
|
||||||
|
type: string
|
||||||
|
description: Renew all certificates owned by this owner
|
||||||
|
agent_id:
|
||||||
|
type: string
|
||||||
|
description: Renew all certificates deployed via this agent
|
||||||
|
issuer_id:
|
||||||
|
type: string
|
||||||
|
description: Renew all certificates issued by this issuer
|
||||||
|
team_id:
|
||||||
|
type: string
|
||||||
|
description: Renew all certificates owned by members of this team
|
||||||
|
certificate_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Explicit list of certificate IDs to renew
|
||||||
|
|
||||||
|
BulkEnqueuedJob:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
certificate_id:
|
||||||
|
type: string
|
||||||
|
job_id:
|
||||||
|
type: string
|
||||||
|
description: ID of the renewal job created for this certificate
|
||||||
|
|
||||||
|
BulkRenewResult:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
total_matched:
|
||||||
|
type: integer
|
||||||
|
description: Number of certificates matching the criteria
|
||||||
|
total_enqueued:
|
||||||
|
type: integer
|
||||||
|
description: Number of renewal jobs successfully created
|
||||||
|
total_skipped:
|
||||||
|
type: integer
|
||||||
|
description: Certs already RenewalInProgress / Revoked / Archived / Expired (silent no-op)
|
||||||
|
total_failed:
|
||||||
|
type: integer
|
||||||
|
description: Number of certificates whose enqueue path returned an error
|
||||||
|
enqueued_jobs:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/BulkEnqueuedJob"
|
||||||
|
description: Per-certificate {certificate_id, job_id} pairs for the successful enqueue path
|
||||||
|
errors:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
certificate_id:
|
||||||
|
type: string
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
description: Per-certificate error details for the failure path
|
||||||
|
|
||||||
|
BulkReassignRequest:
|
||||||
|
type: object
|
||||||
|
required: [certificate_ids, owner_id]
|
||||||
|
properties:
|
||||||
|
certificate_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Explicit list of certificate IDs to reassign
|
||||||
|
owner_id:
|
||||||
|
type: string
|
||||||
|
description: Required. New owner_id for every cert in certificate_ids.
|
||||||
|
team_id:
|
||||||
|
type: string
|
||||||
|
description: Optional. When non-empty, also updates team_id on every cert.
|
||||||
|
|
||||||
|
BulkReassignResult:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
total_matched:
|
||||||
|
type: integer
|
||||||
|
total_reassigned:
|
||||||
|
type: integer
|
||||||
|
description: Number of certs whose owner_id (and optionally team_id) was actually mutated
|
||||||
|
total_skipped:
|
||||||
|
type: integer
|
||||||
|
description: Certs already owned by the target (silent no-op)
|
||||||
|
total_failed:
|
||||||
|
type: integer
|
||||||
|
errors:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
certificate_id:
|
||||||
|
type: string
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
|
||||||
# ─── Issuers ─────────────────────────────────────────────────────
|
# ─── Issuers ─────────────────────────────────────────────────────
|
||||||
IssuerType:
|
IssuerType:
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
+112
-8
@@ -86,6 +86,41 @@ func main() {
|
|||||||
}
|
}
|
||||||
logger.Info("migrations completed")
|
logger.Info("migrations completed")
|
||||||
|
|
||||||
|
// Apply baseline seed data.
|
||||||
|
//
|
||||||
|
// U-3 (P1, cat-u-seed_initdb_schema_drift): pre-U-3 seed.sql was mounted
|
||||||
|
// into postgres `/docker-entrypoint-initdb.d/` alongside a hand-curated
|
||||||
|
// subset of migrations. Adding a migration that introduced a new column
|
||||||
|
// referenced by seed.sql (cat-o-retry_interval_unit_mismatch /
|
||||||
|
// policy_rules.severity / etc.) without also updating the compose volume
|
||||||
|
// mounts caused initdb to crash on first up. Post-U-3 the compose stack
|
||||||
|
// drops all initdb mounts; postgres comes up with empty schema, the
|
||||||
|
// server runs RunMigrations above, then this RunSeed call lands the
|
||||||
|
// baseline data — all from a single source of truth (this binary).
|
||||||
|
// See internal/repository/postgres/db.go::RunSeed for the contract.
|
||||||
|
logger.Info("applying baseline seed", "path", cfg.Database.MigrationsPath)
|
||||||
|
if err := postgres.RunSeed(db, cfg.Database.MigrationsPath); err != nil {
|
||||||
|
logger.Error("failed to apply seed data", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
logger.Info("seed completed")
|
||||||
|
|
||||||
|
// Apply demo overlay seed when CERTCTL_DEMO_SEED=true. Pre-U-3 the demo
|
||||||
|
// overlay (deploy/docker-compose.demo.yml) mounted seed_demo.sql into
|
||||||
|
// postgres `/docker-entrypoint-initdb.d/`; that broke once U-3 dropped
|
||||||
|
// the initdb migration mounts (the demo seed references tables that
|
||||||
|
// wouldn't exist at initdb time). The runtime path here is the
|
||||||
|
// post-U-3 replacement. Default-off so a vanilla deploy never lands
|
||||||
|
// fake-history rows. See postgres.RunDemoSeed for the contract.
|
||||||
|
if cfg.Database.DemoSeed {
|
||||||
|
logger.Info("applying demo seed (CERTCTL_DEMO_SEED=true)", "path", cfg.Database.MigrationsPath)
|
||||||
|
if err := postgres.RunDemoSeed(db, cfg.Database.MigrationsPath); err != nil {
|
||||||
|
logger.Error("failed to apply demo seed data", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
logger.Info("demo seed completed")
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize repositories with real PostgreSQL connection
|
// Initialize repositories with real PostgreSQL connection
|
||||||
auditRepo := postgres.NewAuditRepository(db)
|
auditRepo := postgres.NewAuditRepository(db)
|
||||||
certificateRepo := postgres.NewCertificateRepository(db)
|
certificateRepo := postgres.NewCertificateRepository(db)
|
||||||
@@ -376,6 +411,14 @@ func main() {
|
|||||||
// Initialize bulk revocation service
|
// Initialize bulk revocation service
|
||||||
bulkRevocationService := service.NewBulkRevocationService(revocationSvc, certificateRepo, auditService, logger)
|
bulkRevocationService := service.NewBulkRevocationService(revocationSvc, certificateRepo, auditService, logger)
|
||||||
|
|
||||||
|
// L-1 master (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a): bulk-renew
|
||||||
|
// and bulk-reassign services. Mirror BulkRevocationService wiring so
|
||||||
|
// the construction site is co-located with the existing bulk endpoint.
|
||||||
|
// keygenMode is threaded so bulk-renew jobs land in the same initial
|
||||||
|
// status (AwaitingCSR vs Pending) as single-cert TriggerRenewal.
|
||||||
|
bulkRenewalService := service.NewBulkRenewalService(certificateRepo, jobRepo, auditService, logger, cfg.Keygen.Mode)
|
||||||
|
bulkReassignmentService := service.NewBulkReassignmentService(certificateRepo, ownerRepo, auditService, logger)
|
||||||
|
|
||||||
// Initialize stats and metrics services
|
// Initialize stats and metrics services
|
||||||
statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo)
|
statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo)
|
||||||
// I-005: wire the notification repository so DashboardSummary.NotificationsDead
|
// I-005: wire the notification repository so DashboardSummary.NotificationsDead
|
||||||
@@ -406,6 +449,13 @@ func main() {
|
|||||||
statsHandler := handler.NewStatsHandler(statsService)
|
statsHandler := handler.NewStatsHandler(statsService)
|
||||||
metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
|
metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
|
||||||
healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
|
healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
|
||||||
|
// U-3 ride-along (cat-u-no_version_endpoint, P2): the version handler
|
||||||
|
// answers GET /api/v1/version with build identity (ldflags Version,
|
||||||
|
// VCS commit/dirty/timestamp, Go runtime version). Wired through the
|
||||||
|
// no-auth dispatch + audit ExcludePaths below so probes and rollout
|
||||||
|
// systems can read it without Bearer credentials and without flooding
|
||||||
|
// the audit trail.
|
||||||
|
versionHandler := handler.NewVersionHandler()
|
||||||
discoveryHandler := handler.NewDiscoveryHandler(discoveryService)
|
discoveryHandler := handler.NewDiscoveryHandler(discoveryService)
|
||||||
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
||||||
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
|
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
|
||||||
@@ -414,6 +464,11 @@ func main() {
|
|||||||
exportHandler := handler.NewExportHandler(exportService)
|
exportHandler := handler.NewExportHandler(exportService)
|
||||||
|
|
||||||
bulkRevocationHandler := handler.NewBulkRevocationHandler(bulkRevocationService)
|
bulkRevocationHandler := handler.NewBulkRevocationHandler(bulkRevocationService)
|
||||||
|
// L-1 master closure: handlers for the new bulk-renew + bulk-reassign
|
||||||
|
// endpoints. Both registered via HandlerRegistry below; dispatched
|
||||||
|
// through the standard authed middleware chain (no admin gate).
|
||||||
|
bulkRenewalHandler := handler.NewBulkRenewalHandler(bulkRenewalService)
|
||||||
|
bulkReassignmentHandler := handler.NewBulkReassignmentHandler(bulkReassignmentService)
|
||||||
|
|
||||||
// Initialize digest service (requires email notifier)
|
// Initialize digest service (requires email notifier)
|
||||||
var digestService *service.DigestService
|
var digestService *service.DigestService
|
||||||
@@ -490,6 +545,16 @@ func main() {
|
|||||||
// because they share the NotificationServicer dependency (same placement
|
// because they share the NotificationServicer dependency (same placement
|
||||||
// pattern as I-001's SetJobRetryInterval above).
|
// pattern as I-001's SetJobRetryInterval above).
|
||||||
sched.SetNotificationRetryInterval(cfg.Scheduler.NotificationRetryInterval)
|
sched.SetNotificationRetryInterval(cfg.Scheduler.NotificationRetryInterval)
|
||||||
|
// C-1 closure (cat-g-7e38f9708e20 + diff-10xmain-2bf4a0a60388): pre-C-1
|
||||||
|
// the SetShortLivedExpiryCheckInterval setter was defined + tested but
|
||||||
|
// never called from main.go, so the 30-second hardcoded default in
|
||||||
|
// scheduler.NewScheduler was effectively the only value. Operators
|
||||||
|
// running short-lived cert workloads with high churn (or low-churn
|
||||||
|
// workloads wanting to relax the cadence) had no working knob despite
|
||||||
|
// CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL being documented. Wire it
|
||||||
|
// here alongside the other scheduler-interval setters so the
|
||||||
|
// documented env var actually takes effect.
|
||||||
|
sched.SetShortLivedExpiryCheckInterval(cfg.Scheduler.ShortLivedExpiryCheckInterval)
|
||||||
if cfg.NetworkScan.Enabled {
|
if cfg.NetworkScan.Enabled {
|
||||||
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
|
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
|
||||||
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
|
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
|
||||||
@@ -553,7 +618,10 @@ func main() {
|
|||||||
Export: exportHandler,
|
Export: exportHandler,
|
||||||
Digest: *digestHandler,
|
Digest: *digestHandler,
|
||||||
HealthChecks: healthCheckHandler,
|
HealthChecks: healthCheckHandler,
|
||||||
BulkRevocation: bulkRevocationHandler,
|
BulkRevocation: bulkRevocationHandler,
|
||||||
|
BulkRenewal: bulkRenewalHandler,
|
||||||
|
BulkReassignment: bulkReassignmentHandler,
|
||||||
|
Version: versionHandler,
|
||||||
})
|
})
|
||||||
// Register EST (RFC 7030) handlers if enabled
|
// Register EST (RFC 7030) handlers if enabled
|
||||||
if cfg.EST.Enabled {
|
if cfg.EST.Enabled {
|
||||||
@@ -683,6 +751,17 @@ func main() {
|
|||||||
})
|
})
|
||||||
logger.Info("request body size limit enabled", "max_bytes", cfg.Server.MaxBodySize)
|
logger.Info("request body size limit enabled", "max_bytes", cfg.Server.MaxBodySize)
|
||||||
|
|
||||||
|
// Security headers middleware — applies HSTS, X-Frame-Options,
|
||||||
|
// X-Content-Type-Options, Referrer-Policy, and a conservative CSP
|
||||||
|
// on every response. H-1 closure (cat-s11-missing_security_headers):
|
||||||
|
// pre-H-1 the server emitted zero security headers; an attacker
|
||||||
|
// could clickjack the dashboard, sniff MIME types on JSON/PEM
|
||||||
|
// responses, or load resources from arbitrary origins via inline
|
||||||
|
// scripts. Defaults are conservative — see internal/api/middleware/
|
||||||
|
// securityheaders.go::SecurityHeadersDefaults() for the rationale
|
||||||
|
// per header.
|
||||||
|
securityHeadersMiddleware := middleware.SecurityHeaders(middleware.SecurityHeadersDefaults())
|
||||||
|
|
||||||
// API audit log middleware — records every API call to the audit trail
|
// API audit log middleware — records every API call to the audit trail
|
||||||
auditAdapter := middleware.NewAuditServiceAdapter(
|
auditAdapter := middleware.NewAuditServiceAdapter(
|
||||||
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
|
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
|
||||||
@@ -690,16 +769,22 @@ func main() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
auditMiddleware := middleware.NewAuditLog(auditAdapter, middleware.AuditConfig{
|
auditMiddleware := middleware.NewAuditLog(auditAdapter, middleware.AuditConfig{
|
||||||
ExcludePaths: []string{"/health", "/ready"},
|
// /api/v1/version is excluded for the same reason /health and /ready
|
||||||
|
// are: rollout systems and blackbox probes hammer it on a tight
|
||||||
|
// interval, and the audit trail's value comes from rare,
|
||||||
|
// operator-authored mutations — not from sub-second readonly polls.
|
||||||
|
// U-3 ride-along (cat-u-no_version_endpoint, P2).
|
||||||
|
ExcludePaths: []string{"/health", "/ready", "/api/v1/version"},
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
})
|
})
|
||||||
logger.Info("API audit logging enabled (excluding /health, /ready)")
|
logger.Info("API audit logging enabled (excluding /health, /ready, /api/v1/version)")
|
||||||
|
|
||||||
middlewareStack := []func(http.Handler) http.Handler{
|
middlewareStack := []func(http.Handler) http.Handler{
|
||||||
middleware.RequestID,
|
middleware.RequestID,
|
||||||
structuredLogger,
|
structuredLogger,
|
||||||
middleware.Recovery,
|
middleware.Recovery,
|
||||||
bodyLimitMiddleware,
|
bodyLimitMiddleware,
|
||||||
|
securityHeadersMiddleware,
|
||||||
corsMiddleware,
|
corsMiddleware,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
auditMiddleware.Middleware,
|
auditMiddleware.Middleware,
|
||||||
@@ -746,13 +831,29 @@ func main() {
|
|||||||
if _, err := os.Stat(webDir + "/index.html"); err != nil {
|
if _, err := os.Stat(webDir + "/index.html"); err != nil {
|
||||||
webDir = "./web"
|
webDir = "./web"
|
||||||
}
|
}
|
||||||
// Health/ready routes bypass the full middleware stack (no auth required).
|
// Health/ready routes + EST/SCEP/PKI unauth surface bypass the full
|
||||||
// These are registered on the inner router without auth, but the outer
|
// middleware stack (no auth required). These are registered on the
|
||||||
// middleware chain wraps everything. Route them directly to the inner router.
|
// inner router without auth, but the outer middleware chain wraps
|
||||||
|
// everything. Route them directly to the inner router.
|
||||||
|
//
|
||||||
|
// H-1 closure (cat-s5-4936a1cf0118): pre-H-1 the noAuthHandler chain
|
||||||
|
// was RequestID → structuredLogger → Recovery only — missing
|
||||||
|
// bodyLimitMiddleware that the authed apiHandler chain has. The
|
||||||
|
// unauth surface includes EST simpleenroll/simplereenroll (RFC 7030),
|
||||||
|
// SCEP, PKI CRL/OCSP (/.well-known/pki/*), and /health|/ready —
|
||||||
|
// every one of which accepts a request body. Without a body-size
|
||||||
|
// cap, an unauthenticated client can send arbitrary-size payloads
|
||||||
|
// (CSRs, CRL/OCSP requests) and trigger memory pressure on the
|
||||||
|
// server before the handler ever rejects the input. Post-H-1 the
|
||||||
|
// same bodyLimitMiddleware that wraps the authed surface also wraps
|
||||||
|
// the unauth surface — same default cap (CERTCTL_MAX_BODY_SIZE,
|
||||||
|
// default 1MB), same 413 response on overflow.
|
||||||
noAuthHandler := middleware.Chain(apiRouter,
|
noAuthHandler := middleware.Chain(apiRouter,
|
||||||
middleware.RequestID,
|
middleware.RequestID,
|
||||||
structuredLogger,
|
structuredLogger,
|
||||||
middleware.Recovery,
|
middleware.Recovery,
|
||||||
|
bodyLimitMiddleware,
|
||||||
|
securityHeadersMiddleware,
|
||||||
)
|
)
|
||||||
|
|
||||||
dashboardEnabled := false
|
dashboardEnabled := false
|
||||||
@@ -889,6 +990,7 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro
|
|||||||
// Dispatch rules (M-001, audit 2026-04-19, option D):
|
// Dispatch rules (M-001, audit 2026-04-19, option D):
|
||||||
//
|
//
|
||||||
// - /health, /ready, /api/v1/auth/info → no-auth (probes + login detection)
|
// - /health, /ready, /api/v1/auth/info → no-auth (probes + login detection)
|
||||||
|
// - /api/v1/version → no-auth (U-3 ride-along: build identity for rollout/probes)
|
||||||
// - /.well-known/pki/* → no-auth (RFC 5280 CRL, RFC 6960 OCSP)
|
// - /.well-known/pki/* → no-auth (RFC 5280 CRL, RFC 6960 OCSP)
|
||||||
// - /.well-known/est/* → no-auth (RFC 7030 §3.2.3)
|
// - /.well-known/est/* → no-auth (RFC 7030 §3.2.3)
|
||||||
// - /scep, /scep/* → no-auth (RFC 8894 §3.2, CSR challengePassword)
|
// - /scep, /scep/* → no-auth (RFC 8894 §3.2, CSR challengePassword)
|
||||||
@@ -914,10 +1016,12 @@ func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, da
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
|
|
||||||
// Health/ready and auth/info bypass auth middleware.
|
// Health/ready, auth/info, and version bypass auth middleware.
|
||||||
// Health/ready: Docker/K8s health probes don't carry Bearer tokens.
|
// Health/ready: Docker/K8s health probes don't carry Bearer tokens.
|
||||||
// auth/info: React app calls this before login to detect auth mode.
|
// auth/info: React app calls this before login to detect auth mode.
|
||||||
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" {
|
// version: U-3 ride-along (cat-u-no_version_endpoint) — rollout
|
||||||
|
// systems and blackbox probes need build identity without a key.
|
||||||
|
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" || path == "/api/v1/version" {
|
||||||
noAuthHandler.ServeHTTP(w, r)
|
noAuthHandler.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,20 @@
|
|||||||
# To start fresh (wipe previous data):
|
# To start fresh (wipe previous data):
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.demo.yml down -v
|
# docker compose -f docker-compose.yml -f docker-compose.demo.yml down -v
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
|
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
|
||||||
|
#
|
||||||
|
# U-3 (P1, cat-u-seed_initdb_schema_drift): pre-U-3 this overlay mounted
|
||||||
|
# `seed_demo.sql` into postgres `/docker-entrypoint-initdb.d/`. That worked
|
||||||
|
# only because the production stack also mounted the migrations there, so
|
||||||
|
# the schema existed at initdb time. Once U-3 dropped the production
|
||||||
|
# initdb mounts (single source of truth: server runs RunMigrations + RunSeed
|
||||||
|
# at boot), the demo seed could no longer be applied at initdb time — the
|
||||||
|
# tables it references wouldn't exist yet.
|
||||||
|
#
|
||||||
|
# Post-U-3 the demo overlay just sets CERTCTL_DEMO_SEED=true; the server
|
||||||
|
# applies seed_demo.sql at boot via postgres.RunDemoSeed AFTER baseline
|
||||||
|
# migrations + seed.sql are in place. Same single source of truth, no
|
||||||
|
# initdb mounts, no schema-vs-seed drift.
|
||||||
services:
|
services:
|
||||||
postgres:
|
certctl-server:
|
||||||
volumes:
|
environment:
|
||||||
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/030_seed_demo.sql
|
CERTCTL_DEMO_SEED: "true"
|
||||||
|
|||||||
@@ -93,6 +93,17 @@ services:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Database
|
# Database
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# U-3 (P1, cat-u-seed_initdb_schema_drift, GitHub #10): the test stack used
|
||||||
|
# to mount a hand-curated subset of migrations + seed.sql + a never-checked-in
|
||||||
|
# seed_test.sql into postgres `/docker-entrypoint-initdb.d/`. Same hazard as
|
||||||
|
# the production compose — initdb crashed any time a new migration shipped
|
||||||
|
# that the seed depended on without the mount list being updated. Post-U-3
|
||||||
|
# the schema is built EXCLUSIVELY by the server at startup via
|
||||||
|
# internal/repository/postgres.RunMigrations + RunSeed. Postgres comes up
|
||||||
|
# empty and the server lands the full ladder + baseline seed in one shot.
|
||||||
|
# `start_period: 30s` matches the production compose and shields slow CI
|
||||||
|
# runners from healthcheck flap during initdb.
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: certctl-test-postgres
|
container_name: certctl-test-postgres
|
||||||
@@ -102,19 +113,6 @@ services:
|
|||||||
POSTGRES_PASSWORD: testpass
|
POSTGRES_PASSWORD: testpass
|
||||||
volumes:
|
volumes:
|
||||||
- test_postgres_data:/var/lib/postgresql/data
|
- test_postgres_data:/var/lib/postgresql/data
|
||||||
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
|
|
||||||
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
|
|
||||||
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
|
|
||||||
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
|
|
||||||
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
|
|
||||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
|
||||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
|
||||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
|
||||||
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
|
|
||||||
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
|
|
||||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
|
|
||||||
- ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/025_seed_test.sql
|
|
||||||
# No seed_demo.sql — start with a clean database for real testing
|
|
||||||
networks:
|
networks:
|
||||||
certctl-test:
|
certctl-test:
|
||||||
ipv4_address: 10.30.50.2
|
ipv4_address: 10.30.50.2
|
||||||
@@ -125,6 +123,7 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+29
-11
@@ -53,6 +53,29 @@ services:
|
|||||||
- certctl-network
|
- certctl-network
|
||||||
|
|
||||||
# PostgreSQL database
|
# PostgreSQL database
|
||||||
|
#
|
||||||
|
# U-3 (P1, cat-u-seed_initdb_schema_drift, GitHub #10):
|
||||||
|
# Pre-U-3 this stack mounted a hand-curated subset of `migrations/*.up.sql`
|
||||||
|
# plus `seed.sql` into `/docker-entrypoint-initdb.d/`, and postgres
|
||||||
|
# initdb-applied them on first boot. The mount list rotted every time a
|
||||||
|
# new migration shipped that the seed depended on (000013 added
|
||||||
|
# policy_rules.severity, 000017 renames retry_interval_minutes, etc.) —
|
||||||
|
# initdb crashed, the container reported `unhealthy` indefinitely, and
|
||||||
|
# `docker compose -f deploy/docker-compose.yml up -d --build` from a
|
||||||
|
# fresh clone of v2.0.50 hit it on the first try.
|
||||||
|
#
|
||||||
|
# Post-U-3 the schema is built EXCLUSIVELY by the server at startup via
|
||||||
|
# internal/repository/postgres.RunMigrations + RunSeed. Single source of
|
||||||
|
# truth, no list to keep in sync. Postgres comes up empty; the server
|
||||||
|
# waits for it healthy, then applies the full migration ladder + seed in
|
||||||
|
# one shot. Helm + the dev examples were already runtime-only (Path B)
|
||||||
|
# and worked through the same window.
|
||||||
|
#
|
||||||
|
# `start_period: 30s` gives postgres room to bootstrap on slow runners
|
||||||
|
# (CI macOS, low-spec laptops) before the healthcheck failure counter
|
||||||
|
# starts ticking. Pre-U-3 a slow first-init combined with the
|
||||||
|
# `unhealthy` flap to cascade into certctl-server's `service_healthy`
|
||||||
|
# depends_on, blocking the whole stack.
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: certctl-postgres
|
container_name: certctl-postgres
|
||||||
@@ -64,17 +87,6 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
|
|
||||||
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
|
|
||||||
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
|
|
||||||
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
|
|
||||||
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
|
|
||||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
|
||||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
|
||||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
|
||||||
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
|
|
||||||
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
|
|
||||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
|
|
||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -82,6 +94,7 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Certctl Server (API + scheduler)
|
# Certctl Server (API + scheduler)
|
||||||
@@ -127,6 +140,11 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
# U-3: server boot now does RunMigrations + RunSeed before listening on
|
||||||
|
# 8443. On a fresh clone the full migration ladder + seed application
|
||||||
|
# can take ~10s on a small VM; start_period prevents the first few
|
||||||
|
# healthcheck attempts from counting as failures while that work runs.
|
||||||
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ The agent runs two background loops: a heartbeat (every 60 seconds) to signal it
|
|||||||
|
|
||||||
Retired agents receive `410 Gone` on subsequent heartbeats (`service.ErrAgentRetired`). `cmd/agent` treats 410 as a terminal signal and exits cleanly so retired agents stop phoning home. Migration `000015` flipped `deployment_targets.agent_id` from `ON DELETE CASCADE` to `ON DELETE RESTRICT`, making the old hard-delete path a schema error and forcing all retirement through this contract.
|
Retired agents receive `410 Gone` on subsequent heartbeats (`service.ErrAgentRetired`). `cmd/agent` treats 410 as a terminal signal and exits cleanly so retired agents stop phoning home. Migration `000015` flipped `deployment_targets.agent_id` from `ON DELETE CASCADE` to `ON DELETE RESTRICT`, making the old hard-delete path a schema error and forcing all retirement through this contract.
|
||||||
|
|
||||||
|
**Registration is by-design pull-only (C-1 closure, cat-b-6177f36636fb).** Agents register themselves at first heartbeat via `install-agent.sh` + `cmd/agent/main.go` — never via the GUI. The `web/src/api/client.ts::registerAgent` client function is intentionally orphan in the dashboard for this reason. It's preserved in `client.ts` (rather than deleted) so future features that want to drive registration from the GUI — for example, a one-click "register proxy agent" panel for network-appliance topologies where the agent runs in a different network zone from the device it manages — can reach the endpoint without a `client.ts` edit. Operators looking to scale agent enrollment use `install-agent.sh` against a config-management system (Ansible, Salt, Puppet) or a baked-in cloud-init script, not the dashboard.
|
||||||
|
|
||||||
### Web Dashboard
|
### Web Dashboard
|
||||||
|
|
||||||
The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates).
|
The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates).
|
||||||
@@ -163,6 +165,10 @@ The dashboard includes an **ErrorBoundary component** for graceful error recover
|
|||||||
- Light content area with branded dark teal sidebar, Inter + JetBrains Mono typography
|
- Light content area with branded dark teal sidebar, Inter + JetBrains Mono typography
|
||||||
- SSE/WebSocket planned for real-time job status updates
|
- SSE/WebSocket planned for real-time job status updates
|
||||||
|
|
||||||
|
**Backend ↔ frontend round-trip rule (B-1 closure):** every backend CRUD operation must have at least one GUI consumer in `web/src/pages/`. Shipping a handler + repository method + OpenAPI operation + `client.ts` fetcher with no page that calls it leaves operators forced to `psql` directly — defeats the "every backend feature ships with its GUI surface" invariant and creates a destructive workflow when the missing path is `update*` (operators delete-and-recreate, losing FK history and audit-trail continuity). The CI guardrail in `.github/workflows/ci.yml` (`Forbidden orphan-CRUD client function regression guard (B-1)`) enforces this for the eight previously-orphan functions (`updateOwner`/`updateTeam`/`updateAgentGroup`/`updateIssuer`/`updateProfile` + `createRenewalPolicy`/`updateRenewalPolicy`/`deleteRenewalPolicy`); apply the same rule when adding any new write endpoint. If a fetcher is needed in `client.ts` before its consumer page exists, leave a TODO referencing this rule and ship them in the same commit.
|
||||||
|
|
||||||
|
**TS ↔ Go type contract rule (D-1 + D-2 closure):** every TypeScript interface in `web/src/api/types.ts` must field-match the Go-side `internal/domain/*.go` struct's JSON-emitted shape exactly. Phantom fields (declared on TS, never emitted by Go) silently render `'—'` and lull consumers into thinking a value will arrive that never does; missing fields (emitted by Go, absent from TS) force `(x as any).X` escapes that lose type-checking. Both failure modes are blocked by the CI guardrail in `.github/workflows/ci.yml` (`Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)`) which awk-windows each interface and grep-fails the build on phantom-field reintroduction — currently covers Certificate (D-1), Agent / Issuer / Notification (D-2). Apply the same rule when adding any new on-wire type: the Go-side json tag is the contract, the TS interface adapts to it, and a literal-construction Vitest in `web/src/api/types.test.ts` pins the post-add shape. Stricter side wins: when in doubt, the side that actually emits the field is the contract; never propose adding a phantom on Go to match a TS over-declaration.
|
||||||
|
|
||||||
### PostgreSQL Database
|
### PostgreSQL Database
|
||||||
|
|
||||||
All state is stored in PostgreSQL 16. The schema uses TEXT primary keys (not UUIDs) with human-readable prefixed IDs like `mc-api-prod`, `t-platform`, `o-alice`.
|
All state is stored in PostgreSQL 16. The schema uses TEXT primary keys (not UUIDs) with human-readable prefixed IDs like `mc-api-prod`, `t-platform`, `o-alice`.
|
||||||
@@ -353,7 +359,7 @@ The ER diagram above documents **database shape**, not REST-API wire shape. Seve
|
|||||||
- `agents.api_key_hash` — SHA-256 of the agent's plaintext API key, populated by `service.RegisterAgent` (`hashAPIKey(apiKey)` at `internal/service/agent.go`) and consumed by `repository.AgentRepository::GetByAPIKey` for the auth-lookup. **Not** exposed via the REST API, **not** echoed via CLI / MCP / agent registration response, **never** logged. Enforced by `internal/domain/connector.go::Agent.MarshalJSON` (G-2 audit closure, `cat-s5-apikey_leak`); the OpenAPI Agent schema explicitly excludes the field, the frontend `Agent` interface omits it, and a CI grep guardrail at `.github/workflows/ci.yml` blocks reintroduction.
|
- `agents.api_key_hash` — SHA-256 of the agent's plaintext API key, populated by `service.RegisterAgent` (`hashAPIKey(apiKey)` at `internal/service/agent.go`) and consumed by `repository.AgentRepository::GetByAPIKey` for the auth-lookup. **Not** exposed via the REST API, **not** echoed via CLI / MCP / agent registration response, **never** logged. Enforced by `internal/domain/connector.go::Agent.MarshalJSON` (G-2 audit closure, `cat-s5-apikey_leak`); the OpenAPI Agent schema explicitly excludes the field, the frontend `Agent` interface omits it, and a CI grep guardrail at `.github/workflows/ci.yml` blocks reintroduction.
|
||||||
- `issuers.config` / `deployment_targets.config` — plaintext jsonb shadow of the AES-GCM-encrypted on-disk blob; the encrypted form lives on `EncryptedConfig []byte` (Go-only field tagged `json:"-"`).
|
- `issuers.config` / `deployment_targets.config` — plaintext jsonb shadow of the AES-GCM-encrypted on-disk blob; the encrypted form lives on `EncryptedConfig []byte` (Go-only field tagged `json:"-"`).
|
||||||
|
|
||||||
Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times — important for Docker Compose where both initdb and the server may run the same SQL.
|
Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times. Pre-U-3 (`cat-u-seed_initdb_schema_drift`, GitHub #10) the deploy compose stack mounted both a hand-curated subset of `migrations/*.up.sql` and `seed.sql` into postgres `/docker-entrypoint-initdb.d/` so initdb applied them on first boot, *and* the server re-applied the same files via `RunMigrations` on every start. The dual source of truth was the bug: every time a migration shipped that the seed depended on (e.g., 000013 added `policy_rules.severity`), the mount list had to be updated by hand, and missing the update crashed initdb on first boot. Post-U-3 the server is the single source of truth: postgres comes up with an empty schema, `RunMigrations` applies the entire ladder, then `RunSeed` lands the baseline seed (and `RunDemoSeed` lands the demo overlay when `CERTCTL_DEMO_SEED=true`). Helm has used this pattern since day one (postgres-init `emptyDir`); the docker-compose deploy now matches.
|
||||||
|
|
||||||
## Data Flow: Certificate Lifecycle
|
## Data Flow: Certificate Lifecycle
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@ The full walkthrough — including profile-based issuer assignment, testing with
|
|||||||
|
|
||||||
## Beyond These Examples
|
## Beyond These Examples
|
||||||
|
|
||||||
These 5 scenarios cover the most common deployment patterns, but certctl supports 7 issuer backends and 10 target connectors. Once you have the basics running, you can mix and match:
|
These 5 scenarios cover the most common deployment patterns, but certctl supports a broader set of issuer and target backends — see `docs/features.md`'s Issuer Connectors and Target Connectors sections for the live catalogs (rebuild via `ls -d internal/connector/issuer/*/ | wc -l` and `ls -d internal/connector/target/*/ | wc -l`). Once you have the basics running, you can mix and match:
|
||||||
|
|
||||||
**Issuers:** ACME (Let's Encrypt, ZeroSSL, Buypass, Google Trust Services), Local CA (self-signed or sub-CA), step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA script, Sectigo (coming soon).
|
**Issuers:** ACME (Let's Encrypt, ZeroSSL, Buypass, Google Trust Services), Local CA (self-signed or sub-CA), step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA script, Sectigo (coming soon).
|
||||||
|
|
||||||
|
|||||||
+46
-27
@@ -8,17 +8,30 @@ Complete reference of every feature shipped in certctl through v2.1.0 (April 202
|
|||||||
|
|
||||||
| Metric | Count |
|
| Metric | Count |
|
||||||
|---|---|
|
|---|---|
|
||||||
| HTTP routes | 107 (103 under `/api/v1/` + 4 EST) |
|
<!--
|
||||||
| OpenAPI 3.1 operations | 97 |
|
S-1 master closure (cat-s1-9ce1cbe26876, cat-s1-features_md_issuer_count_contradiction):
|
||||||
| MCP tools | 80 |
|
every numeric count below is captured at the time of the last edit AND
|
||||||
| CLI commands | 12 |
|
paired with the source-of-truth grep command from CLAUDE.md. CLAUDE.md
|
||||||
| Issuer connectors | 9 (+ EST server) |
|
rule: "Numeric claims about current state rot the instant the next
|
||||||
| Target connectors | 14 |
|
release lands." Re-derive before each release; the CI guardrail at
|
||||||
| Notifier connectors | 6 channels |
|
.github/workflows/ci.yml::"Forbidden hardcoded source-count prose
|
||||||
| Database tables | 21 (across 10 migrations) |
|
regression guard (S-1)" fails the build on any new prose-only counts
|
||||||
| Background scheduler loops | 12 (8 always-on + 4 opt-in) |
|
without an adjacent rebuild command.
|
||||||
| Web dashboard pages | 24 |
|
-->
|
||||||
| Test functions | 1850+ |
|
| Surface | Count (rebuild command) |
|
||||||
|
|---|---|
|
||||||
|
| HTTP routes | rebuild via `grep -cE 'r\.Register\("[A-Z]' internal/api/router/router.go` |
|
||||||
|
| OpenAPI 3.1 operations | rebuild via `grep -cE '^\s+operationId:' api/openapi.yaml` |
|
||||||
|
| MCP tools | rebuild via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go` |
|
||||||
|
| CLI commands | rebuild via `grep -cE 'AddCommand|RootCmd\.Add' cmd/cli/*.go internal/cli/*.go` (intentionally narrow — see CLI Scope §) |
|
||||||
|
| Issuer connectors | rebuild via `ls -d internal/connector/issuer/*/ \| wc -l` (+ EST server) |
|
||||||
|
| Target connectors | rebuild via `ls -d internal/connector/target/*/ \| wc -l` (includes shared `certutil/`) |
|
||||||
|
| Notifier connectors | rebuild via `ls -d internal/connector/notifier/*/ \| wc -l` |
|
||||||
|
| Discovery connectors | rebuild via `ls -d internal/connector/discovery/*/ \| wc -l` |
|
||||||
|
| Database tables | rebuild via `grep -hE '^CREATE TABLE' migrations/*.up.sql \| sed -E 's/CREATE TABLE (IF NOT EXISTS )?([a-zA-Z_]+).*/\2/' \| sort -u \| wc -l` (across `ls migrations/*.up.sql \| wc -l` migrations) |
|
||||||
|
| Background scheduler loops | rebuild via `grep -cE '^func \(s \*Scheduler\) [a-zA-Z]+Loop' internal/scheduler/scheduler.go` |
|
||||||
|
| Web dashboard pages | rebuild via `ls web/src/pages/*.tsx \| grep -v '\.test\.' \| wc -l` |
|
||||||
|
| Test functions (Go backend) | rebuild via the `find` + `grep '^func Test'` recipe in CLAUDE.md::Current-state commands |
|
||||||
| Supported platforms | linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 |
|
| Supported platforms | linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -136,7 +149,7 @@ Every API call is recorded to the immutable audit trail. Best-effort (non-blocki
|
|||||||
|
|
||||||
<!-- Source: internal/scheduler/scheduler.go (renewalCheckLoop, 1-hour default interval) -->
|
<!-- Source: internal/scheduler/scheduler.go (renewalCheckLoop, 1-hour default interval) -->
|
||||||
|
|
||||||
The renewal scheduler runs every hour (configurable via `CERTCTL_RENEWAL_CHECK_INTERVAL`). For each certificate approaching expiration:
|
The renewal scheduler runs every hour (configurable via `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL`). For each certificate approaching expiration:
|
||||||
|
|
||||||
1. Checks ACME ARI (RFC 9773) if available — CA-directed renewal timing takes priority
|
1. Checks ACME ARI (RFC 9773) if available — CA-directed renewal timing takes priority
|
||||||
2. Falls back to threshold-based logic using per-policy `alert_thresholds_days` (default `[30, 14, 7, 0]`)
|
2. Falls back to threshold-based logic using per-policy `alert_thresholds_days` (default `[30, 14, 7, 0]`)
|
||||||
@@ -325,9 +338,9 @@ Policies can be scoped to agent groups via `agent_group_id` foreign key. Violati
|
|||||||
|
|
||||||
## Issuer Connectors
|
## Issuer Connectors
|
||||||
|
|
||||||
<!-- Source: internal/domain/connector.go (12 IssuerType constants), internal/connector/issuer/ -->
|
<!-- Source: internal/domain/connector.go (IssuerType constants), internal/connector/issuer/. Rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`. -->
|
||||||
|
|
||||||
12 issuer connectors implementing the `issuer.Connector` interface. All support `ValidateConfig`, `IssueCertificate`, `RenewCertificate`, `RevokeCertificate`, `GetOrderStatus`, `GenerateCRL`, `SignOCSPResponse`, `GetCACertPEM`, `GetRenewalInfo`.
|
The issuer connector catalog (rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`) implements the `issuer.Connector` interface. All support `ValidateConfig`, `IssueCertificate`, `RenewCertificate`, `RevokeCertificate`, `GetOrderStatus`, `GenerateCRL`, `SignOCSPResponse`, `GetCACertPEM`, `GetRenewalInfo`.
|
||||||
|
|
||||||
### Local CA
|
### Local CA
|
||||||
|
|
||||||
@@ -616,9 +629,9 @@ For Let's Encrypt 6-day `shortlived` certificates, ARI is the expected renewal p
|
|||||||
|
|
||||||
## Target Connectors
|
## Target Connectors
|
||||||
|
|
||||||
<!-- Source: internal/domain/connector.go (14 TargetType constants), internal/connector/target/ -->
|
<!-- Source: internal/domain/connector.go (TargetType constants), internal/connector/target/. Rebuild count via `ls -d internal/connector/target/*/ | wc -l` (includes shared `certutil/`). -->
|
||||||
|
|
||||||
14 target connector types implementing the `target.Connector` interface. All support `ValidateConfig`, `DeployCertificate`, `ValidateDeployment`.
|
The target connector catalog (rebuild count via `ls -d internal/connector/target/*/ | wc -l`) implements the `target.Connector` interface. All support `ValidateConfig`, `DeployCertificate`, `ValidateDeployment`.
|
||||||
|
|
||||||
### Deployment Model
|
### Deployment Model
|
||||||
|
|
||||||
@@ -1101,14 +1114,14 @@ Single SQL `UNION` query replaces the previous "fetch all, filter in Go" approac
|
|||||||
|
|
||||||
| Loop | Default Interval | Always-on | Env Var | Description |
|
| Loop | Default Interval | Always-on | Env Var | Description |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Renewal check | 1 hour | Yes | — | Check expiring certs, query ARI, create renewal jobs |
|
| Renewal check | 1 hour | Yes | `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | Check expiring certs, query ARI, create renewal jobs |
|
||||||
| Job processor | 30 seconds | Yes | — | Process pending jobs |
|
| Job processor | 30 seconds | Yes | `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | Process pending jobs |
|
||||||
| Job retry | 5 minutes | Yes | `CERTCTL_SCHEDULER_RETRY_INTERVAL` | Retry Failed jobs (I-001) |
|
| Job retry | 5 minutes | Yes | `CERTCTL_SCHEDULER_RETRY_INTERVAL` | Retry Failed jobs (I-001) |
|
||||||
| Job timeout reaper | 10 minutes | Yes | `CERTCTL_JOB_TIMEOUT_INTERVAL` | Fail AwaitingCSR/AwaitingApproval jobs past timeout (I-003) |
|
| Job timeout reaper | 10 minutes | Yes | `CERTCTL_JOB_TIMEOUT_INTERVAL` (per-state thresholds: `CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT`, `CERTCTL_JOB_AWAITING_CSR_TIMEOUT`) | Fail AwaitingCSR/AwaitingApproval jobs past timeout (I-003) |
|
||||||
| Agent health check | 2 minutes | Yes | — | Check agent heartbeat staleness |
|
| Agent health check | 2 minutes | Yes | `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | Check agent heartbeat staleness |
|
||||||
| Notification processor | 1 minute | Yes | — | Send queued notifications |
|
| Notification processor | 1 minute | Yes | `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | Send queued notifications |
|
||||||
| Notification retry | 2 minutes | Yes | `CERTCTL_NOTIFICATION_RETRY_INTERVAL` | Exponential backoff retry for failed notifications; promote to dead-letter after 5 attempts (I-005) |
|
| Notification retry | 2 minutes | Yes | `CERTCTL_NOTIFICATION_RETRY_INTERVAL` | Exponential backoff retry for failed notifications; promote to dead-letter after 5 attempts (I-005) |
|
||||||
| Short-lived expiry check | 30 seconds | Yes | — | Mark short-lived certs expired |
|
| Short-lived expiry check | 30 seconds | Yes | `CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL` | Mark short-lived certs expired (C-1: pre-C-1 the setter was unwired and this env var had no effect; post-C-1 it's read by `cmd/server/main.go::sched.SetShortLivedExpiryCheckInterval`) |
|
||||||
| Network scan | 6 hours | Opt-in | `CERTCTL_NETWORK_SCAN_ENABLED` | Run network discovery scans |
|
| Network scan | 6 hours | Opt-in | `CERTCTL_NETWORK_SCAN_ENABLED` | Run network discovery scans |
|
||||||
| Digest | 24 hours | Opt-in | `CERTCTL_DIGEST_INTERVAL` | Send certificate digest email (does not run on startup) |
|
| Digest | 24 hours | Opt-in | `CERTCTL_DIGEST_INTERVAL` | Send certificate digest email (does not run on startup) |
|
||||||
| Endpoint health | 60 seconds | Opt-in | `CERTCTL_HEALTH_CHECK_INTERVAL` | Continuous TLS health probes (M48) |
|
| Endpoint health | 60 seconds | Opt-in | `CERTCTL_HEALTH_CHECK_INTERVAL` | Continuous TLS health probes (M48) |
|
||||||
@@ -1124,7 +1137,7 @@ Single SQL `UNION` query replaces the previous "fetch all, filter in Go" approac
|
|||||||
|
|
||||||
GUI-driven issuer CRUD with AES-256-GCM encrypted config storage in PostgreSQL.
|
GUI-driven issuer CRUD with AES-256-GCM encrypted config storage in PostgreSQL.
|
||||||
|
|
||||||
- Per-type config schema validation for all 9 issuer types
|
- Per-type config schema validation for all issuer types (rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`)
|
||||||
- Test connection flow (instantiates throwaway connector, calls `ValidateConfig`)
|
- Test connection flow (instantiates throwaway connector, calls `ValidateConfig`)
|
||||||
- Dynamic `sync.RWMutex`-guarded `IssuerRegistry` — rebuilds without server restart
|
- Dynamic `sync.RWMutex`-guarded `IssuerRegistry` — rebuilds without server restart
|
||||||
- Env var backward compatibility: seeds DB on first boot if no DB config exists
|
- Env var backward compatibility: seeds DB on first boot if no DB config exists
|
||||||
@@ -1153,9 +1166,9 @@ Same pattern as issuer configuration:
|
|||||||
|
|
||||||
## Web Dashboard
|
## Web Dashboard
|
||||||
|
|
||||||
<!-- Source: web/src/main.tsx (25 Route elements, 24 pages), Vite + React 18 + TypeScript + TanStack Query + Recharts -->
|
<!-- Source: web/src/main.tsx (Route elements + page imports), Vite + React 18 + TypeScript + TanStack Query + Recharts. Rebuild page count via `ls web/src/pages/*.tsx | grep -v '\.test\.' | wc -l`. -->
|
||||||
|
|
||||||
24 pages wired to real API endpoints.
|
The dashboard surface (rebuild count via `ls web/src/pages/*.tsx | grep -v '\.test\.' | wc -l`) wires every page to real API endpoints.
|
||||||
|
|
||||||
### Pages
|
### Pages
|
||||||
|
|
||||||
@@ -1207,6 +1220,10 @@ Latching state prevents refetch-driven dismissal. `localStorage` dismissal key:
|
|||||||
|
|
||||||
`certctl-cli` — stdlib-only (`flag` + `text/tabwriter`), no Cobra dependency.
|
`certctl-cli` — stdlib-only (`flag` + `text/tabwriter`), no Cobra dependency.
|
||||||
|
|
||||||
|
### Scope (intentionally narrow)
|
||||||
|
|
||||||
|
The CLI focuses on **read-heavy operator triage** (list, get, status, version) and **bulk-action surface** (`certs bulk-revoke`, `import`). It deliberately omits admin CRUD for issuers, targets, owners, teams, agent groups, certificate profiles, renewal policies, policy rules, and notifications — those live in the GUI and the MCP server (rebuild count via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go` for the full operator surface). This split is intentional: CLI is the SSH-into-the-prod-host emergency console; GUI is the day-to-day operator console; MCP is the AI/automation surface. Closes audit finding `cat-i-7c8b28936e3d` — pre-this-doc the narrow scope was correct in code but confused readers who scanned `docs/features.md`'s "CLI commands" count and assumed the CLI was incomplete.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -1274,7 +1291,7 @@ certctl-cli certs bulk-revoke --issuer-id iss-letsencrypt --reason caCompromise
|
|||||||
|
|
||||||
Separate standalone binary (`cmd/mcp-server/`) using the official MCP Go SDK (`modelcontextprotocol/go-sdk`). Stdio transport for Claude, Cursor, and similar AI tool integrations.
|
Separate standalone binary (`cmd/mcp-server/`) using the official MCP Go SDK (`modelcontextprotocol/go-sdk`). Stdio transport for Claude, Cursor, and similar AI tool integrations.
|
||||||
|
|
||||||
- 80 MCP tools covering all API endpoints
|
- MCP tools covering all API endpoints (rebuild count via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go`)
|
||||||
- Stateless HTTP proxy — translates MCP tool calls to REST API calls
|
- Stateless HTTP proxy — translates MCP tool calls to REST API calls
|
||||||
- Typed input structs with `jsonschema` struct tags for automatic schema generation
|
- Typed input structs with `jsonschema` struct tags for automatic schema generation
|
||||||
- Binary response support (DER CRL, OCSP)
|
- Binary response support (DER CRL, OCSP)
|
||||||
@@ -1356,7 +1373,9 @@ Config via `values.yaml`. Secrets for API key, database password, SMTP password.
|
|||||||
|
|
||||||
<!-- Source: migrations/ -->
|
<!-- Source: migrations/ -->
|
||||||
|
|
||||||
21 tables across 10 numbered migrations. PostgreSQL 16. `database/sql` + `lib/pq` (no ORM). TEXT primary keys with human-readable prefixed IDs.
|
PostgreSQL 16, `database/sql` + `lib/pq` (no ORM). TEXT primary keys with human-readable prefixed IDs. The catalog of tables and migrations rebuilds via the commands in the "At a Glance" table at the top of this doc — re-derive at release time rather than reading hardcoded numbers from prose.
|
||||||
|
|
||||||
|
The migration runner reads SQL files from `./migrations/` by default; the path is configurable via `CERTCTL_DATABASE_MIGRATIONS_PATH` for operators running certctl out of a non-standard layout (e.g. a Helm chart that bind-mounts migrations into `/etc/certctl/migrations/`).
|
||||||
|
|
||||||
### Migrations
|
### Migrations
|
||||||
|
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ func TestRevokeCertificate_AlreadyRevoked(t *testing.T) {
|
|||||||
func TestRevokeCertificate_NotFound(t *testing.T) {
|
func TestRevokeCertificate_NotFound(t *testing.T) {
|
||||||
handler, mock := newCertHandlerWithMock()
|
handler, mock := newCertHandlerWithMock()
|
||||||
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
|
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
|
||||||
return fmt.Errorf("certificate not found")
|
return fmt.Errorf("certificate not found: %w", ErrMockNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-missing/revoke", strings.NewReader(`{"reason":"keyCompromise"}`))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-missing/revoke", strings.NewReader(`{"reason":"keyCompromise"}`))
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (m *MockAgentGroupService) GetAgentGroup(_ context.Context, id string) (*do
|
|||||||
if m.GetAgentGroupFn != nil {
|
if m.GetAgentGroupFn != nil {
|
||||||
return m.GetAgentGroupFn(id)
|
return m.GetAgentGroupFn(id)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("not found")
|
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAgentGroupService) CreateAgentGroup(_ context.Context, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
func (m *MockAgentGroupService) CreateAgentGroup(_ context.Context, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
"errors"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -160,7 +162,7 @@ func (h AgentGroupHandler) UpdateAgentGroup(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
updated, err := h.svc.UpdateAgentGroup(r.Context(), id, group)
|
updated, err := h.svc.UpdateAgentGroup(r.Context(), id, group)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -188,7 +190,7 @@ func (h AgentGroupHandler) DeleteAgentGroup(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.DeleteAgentGroup(r.Context(), id); err != nil {
|
if err := h.svc.DeleteAgentGroup(r.Context(), id); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -142,7 +141,9 @@ func TestRetireAgentHandler_Sentinel_403(t *testing.T) {
|
|||||||
func TestRetireAgentHandler_NotFound_404(t *testing.T) {
|
func TestRetireAgentHandler_NotFound_404(t *testing.T) {
|
||||||
mock, handler := agentRetireTestSetup()
|
mock, handler := agentRetireTestSetup()
|
||||||
mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
|
mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
|
||||||
return nil, errors.New("agent not found")
|
// S-2 closure (cat-s6-efc7f6f6bd50): wrap repository.ErrNotFound
|
||||||
|
// so the handler's errors.Is dispatch resolves to 404.
|
||||||
|
return nil, ErrMockNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agents/unknown-id", nil)
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agents/unknown-id", nil)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -211,7 +212,7 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
|||||||
ErrorWithRequestID(w, http.StatusGone, "Agent has been retired", requestID)
|
ErrorWithRequestID(w, http.StatusGone, "Agent has been retired", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -491,7 +492,7 @@ func (h AgentHandler) RetireAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
JSON(w, http.StatusConflict, body)
|
JSON(w, http.StatusConflict, body)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BulkReassignmentService defines the service interface for bulk
|
||||||
|
// owner-reassignment operations.
|
||||||
|
type BulkReassignmentService interface {
|
||||||
|
BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkReassignmentHandler handles HTTP requests for bulk reassignment
|
||||||
|
// operations.
|
||||||
|
type BulkReassignmentHandler struct {
|
||||||
|
svc BulkReassignmentService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBulkReassignmentHandler creates a new BulkReassignmentHandler.
|
||||||
|
func NewBulkReassignmentHandler(svc BulkReassignmentService) BulkReassignmentHandler {
|
||||||
|
return BulkReassignmentHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bulkReassignRequest is the JSON shape decoded from the request body.
|
||||||
|
type bulkReassignRequest struct {
|
||||||
|
CertificateIDs []string `json:"certificate_ids"`
|
||||||
|
OwnerID string `json:"owner_id"`
|
||||||
|
TeamID string `json:"team_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkReassign handles POST /api/v1/certificates/bulk-reassign
|
||||||
|
//
|
||||||
|
// L-2 closure (cat-l-8a1fb258a38a): pre-L-2 the GUI looped
|
||||||
|
// `await updateCertificate(id, { owner_id })`. Post-L-2 the GUI POSTs
|
||||||
|
// once and the server mutates owner_id (and optionally team_id) on N
|
||||||
|
// certs, returning per-cert success/skip/error counts.
|
||||||
|
//
|
||||||
|
// Narrower contract than bulk-renew: explicit IDs only, no criteria-mode.
|
||||||
|
// OwnerID is required; TeamID is optional and updates the team only when
|
||||||
|
// non-empty (matches the existing per-cert PUT contract).
|
||||||
|
//
|
||||||
|
// Auth: any authenticated caller can reassign certs they own/have
|
||||||
|
// access to. NOT admin-gated — operators reassign ownership during
|
||||||
|
// team transitions all the time and gating that on admin would block
|
||||||
|
// the common-case workflow.
|
||||||
|
//
|
||||||
|
// Validation order: empty body → 400; empty IDs → 400; missing
|
||||||
|
// owner_id → 400; non-existent owner_id → 400 via the
|
||||||
|
// ErrBulkReassignOwnerNotFound sentinel mapped here.
|
||||||
|
func (h BulkReassignmentHandler) BulkReassign(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 req bulkReassignRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
request := domain.BulkReassignmentRequest{
|
||||||
|
CertificateIDs: req.CertificateIDs,
|
||||||
|
OwnerID: req.OwnerID,
|
||||||
|
TeamID: req.TeamID,
|
||||||
|
}
|
||||||
|
if request.IsEmpty() {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest,
|
||||||
|
"At least one certificate_id is required",
|
||||||
|
requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.OwnerID == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "owner_id is required", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actor := resolveActor(r.Context())
|
||||||
|
|
||||||
|
result, err := h.svc.BulkReassign(r.Context(), request, actor)
|
||||||
|
if err != nil {
|
||||||
|
// Sentinel-error → 400 mapping. ErrBulkReassignOwnerNotFound
|
||||||
|
// means the operator picked an owner that doesn't exist; this
|
||||||
|
// is bad input (400), not a server error (500). Mirrors the
|
||||||
|
// post-M-1 errToStatus convention rather than substring-matching
|
||||||
|
// err.Error().
|
||||||
|
if errors.Is(err, service.ErrBulkReassignOwnerNotFound) {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Bulk reassignment failed: "+err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockBulkReassignmentService struct {
|
||||||
|
BulkReassignFn func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBulkReassignmentService) BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
|
||||||
|
if m.BulkReassignFn != nil {
|
||||||
|
return m.BulkReassignFn(ctx, request, actor)
|
||||||
|
}
|
||||||
|
return &domain.BulkReassignmentResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkReassign_Handler_HappyPath(t *testing.T) {
|
||||||
|
svc := &mockBulkReassignmentService{
|
||||||
|
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
|
||||||
|
if request.OwnerID != "o-bob" {
|
||||||
|
t.Errorf("owner_id = %q, want 'o-bob'", request.OwnerID)
|
||||||
|
}
|
||||||
|
return &domain.BulkReassignmentResult{
|
||||||
|
TotalMatched: 2, TotalReassigned: 2,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewBulkReassignmentHandler(svc)
|
||||||
|
|
||||||
|
body := `{"certificate_ids":["mc-1","mc-2"],"owner_id":"o-bob"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = req.WithContext(authedContext())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.BulkReassign(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var result domain.BulkReassignmentResult
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||||
|
t.Fatalf("decode failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.TotalReassigned != 2 {
|
||||||
|
t.Errorf("envelope drift: TotalReassigned=%d, want 2", result.TotalReassigned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkReassign_Handler_EmptyIDs_400(t *testing.T) {
|
||||||
|
svc := &mockBulkReassignmentService{}
|
||||||
|
h := NewBulkReassignmentHandler(svc)
|
||||||
|
|
||||||
|
body := `{"certificate_ids":[],"owner_id":"o-bob"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
|
||||||
|
req = req.WithContext(authedContext())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.BulkReassign(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkReassign_Handler_MissingOwnerID_400(t *testing.T) {
|
||||||
|
svc := &mockBulkReassignmentService{}
|
||||||
|
h := NewBulkReassignmentHandler(svc)
|
||||||
|
|
||||||
|
body := `{"certificate_ids":["mc-1"]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
|
||||||
|
req = req.WithContext(authedContext())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.BulkReassign(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "owner_id") {
|
||||||
|
t.Errorf("body should name owner_id; got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkReassign_Handler_OwnerNotFound_400 — sentinel-error → 400
|
||||||
|
// mapping. Operator picked an owner that doesn't exist; that's bad
|
||||||
|
// input, not a server error.
|
||||||
|
func TestBulkReassign_Handler_OwnerNotFound_400(t *testing.T) {
|
||||||
|
svc := &mockBulkReassignmentService{
|
||||||
|
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
|
||||||
|
return nil, fmt.Errorf("%w: %s", service.ErrBulkReassignOwnerNotFound, request.OwnerID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewBulkReassignmentHandler(svc)
|
||||||
|
|
||||||
|
body := `{"certificate_ids":["mc-1"],"owner_id":"o-ghost"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
|
||||||
|
req = req.WithContext(authedContext())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.BulkReassign(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400 (ErrBulkReassignOwnerNotFound → 400)", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "owner not found") {
|
||||||
|
t.Errorf("body should mention 'owner not found'; got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkReassign_Handler_WrongMethod_405(t *testing.T) {
|
||||||
|
svc := &mockBulkReassignmentService{}
|
||||||
|
h := NewBulkReassignmentHandler(svc)
|
||||||
|
|
||||||
|
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} {
|
||||||
|
req := httptest.NewRequest(method, "/api/v1/certificates/bulk-reassign", nil)
|
||||||
|
req = req.WithContext(authedContext())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.BulkReassign(w, req)
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("%s → %d, want 405", method, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkReassign_Handler_GenericError_500(t *testing.T) {
|
||||||
|
svc := &mockBulkReassignmentService{
|
||||||
|
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
|
||||||
|
return nil, errors.New("simulated outage")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewBulkReassignmentHandler(svc)
|
||||||
|
body := `{"certificate_ids":["mc-1"],"owner_id":"o-bob"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
|
||||||
|
req = req.WithContext(authedContext())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.BulkReassign(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("status = %d, want 500", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BulkRenewalService defines the service interface for bulk certificate
|
||||||
|
// renewal. Mirrors BulkRevocationService — handler doesn't import the
|
||||||
|
// concrete service struct so tests can inject a mock without pulling in
|
||||||
|
// the full service-layer dependency graph.
|
||||||
|
type BulkRenewalService interface {
|
||||||
|
BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkRenewalHandler handles HTTP requests for bulk renewal operations.
|
||||||
|
type BulkRenewalHandler struct {
|
||||||
|
svc BulkRenewalService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBulkRenewalHandler creates a new BulkRenewalHandler.
|
||||||
|
func NewBulkRenewalHandler(svc BulkRenewalService) BulkRenewalHandler {
|
||||||
|
return BulkRenewalHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bulkRenewRequest mirrors the BulkRenewalCriteria JSON shape (the
|
||||||
|
// handler decodes into this struct then hands a domain.BulkRenewalCriteria
|
||||||
|
// to the service — same indirection as bulkRevokeRequest in
|
||||||
|
// bulk_revocation.go).
|
||||||
|
type bulkRenewRequest struct {
|
||||||
|
ProfileID string `json:"profile_id,omitempty"`
|
||||||
|
OwnerID string `json:"owner_id,omitempty"`
|
||||||
|
AgentID string `json:"agent_id,omitempty"`
|
||||||
|
IssuerID string `json:"issuer_id,omitempty"`
|
||||||
|
TeamID string `json:"team_id,omitempty"`
|
||||||
|
CertificateIDs []string `json:"certificate_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkRenew handles POST /api/v1/certificates/bulk-renew
|
||||||
|
//
|
||||||
|
// L-1 closure (cat-l-fa0c1ac07ab5): pre-L-1 the GUI looped
|
||||||
|
// `await triggerRenewal(id)` over the selection. Post-L-1 it POSTs once
|
||||||
|
// and the server enqueues N renewal jobs server-side, returning a
|
||||||
|
// per-cert {certificate_id, job_id} envelope.
|
||||||
|
//
|
||||||
|
// Request shape mirrors BulkRevokeRequest (criteria-mode + IDs-mode);
|
||||||
|
// the "renew all certs of profile X before its CA changes" use case is
|
||||||
|
// why criteria-mode is supported in addition to explicit IDs.
|
||||||
|
//
|
||||||
|
// Auth: any authenticated caller can renew certs they have read-access
|
||||||
|
// to (matches POST /api/v1/certificates/{id}/renew). NOT admin-gated
|
||||||
|
// like bulk-revoke — bulk-renew is non-destructive (worst case it
|
||||||
|
// kicks off some redundant ACME orders) so we don't need the
|
||||||
|
// fleet-scale-destruction gate.
|
||||||
|
func (h BulkRenewalHandler) BulkRenew(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 req bulkRenewRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
criteria := domain.BulkRenewalCriteria{
|
||||||
|
ProfileID: req.ProfileID,
|
||||||
|
OwnerID: req.OwnerID,
|
||||||
|
AgentID: req.AgentID,
|
||||||
|
IssuerID: req.IssuerID,
|
||||||
|
TeamID: req.TeamID,
|
||||||
|
CertificateIDs: req.CertificateIDs,
|
||||||
|
}
|
||||||
|
if criteria.IsEmpty() {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest,
|
||||||
|
"At least one filter criterion is required (profile_id, owner_id, agent_id, issuer_id, team_id, or certificate_ids)",
|
||||||
|
requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actor := resolveActor(r.Context())
|
||||||
|
|
||||||
|
result, err := h.svc.BulkRenew(r.Context(), criteria, actor)
|
||||||
|
if err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Bulk renewal failed: "+err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockBulkRenewalService is a test implementation of BulkRenewalService.
|
||||||
|
type mockBulkRenewalService struct {
|
||||||
|
BulkRenewFn func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBulkRenewalService) BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
|
||||||
|
if m.BulkRenewFn != nil {
|
||||||
|
return m.BulkRenewFn(ctx, criteria, actor)
|
||||||
|
}
|
||||||
|
return &domain.BulkRenewalResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authedContext mirrors adminContext but without the admin flag —
|
||||||
|
// bulk-renew is NOT admin-gated, any authenticated caller can use it.
|
||||||
|
func authedContext() context.Context {
|
||||||
|
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-renew")
|
||||||
|
ctx = context.WithValue(ctx, middleware.UserKey{}, "alice")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkRenew_Handler_HappyPath(t *testing.T) {
|
||||||
|
svc := &mockBulkRenewalService{
|
||||||
|
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
|
||||||
|
if len(criteria.CertificateIDs) != 3 {
|
||||||
|
t.Errorf("expected 3 IDs, got %d", len(criteria.CertificateIDs))
|
||||||
|
}
|
||||||
|
if actor != "alice" {
|
||||||
|
t.Errorf("actor = %q, want 'alice' (resolved from middleware UserKey)", actor)
|
||||||
|
}
|
||||||
|
return &domain.BulkRenewalResult{
|
||||||
|
TotalMatched: 3,
|
||||||
|
TotalEnqueued: 3,
|
||||||
|
EnqueuedJobs: []domain.BulkEnqueuedJob{
|
||||||
|
{CertificateID: "mc-1", JobID: "job-a"},
|
||||||
|
{CertificateID: "mc-2", JobID: "job-b"},
|
||||||
|
{CertificateID: "mc-3", JobID: "job-c"},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewBulkRenewalHandler(svc)
|
||||||
|
|
||||||
|
body := `{"certificate_ids":["mc-1","mc-2","mc-3"]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = req.WithContext(authedContext())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.BulkRenew(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var result domain.BulkRenewalResult
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||||
|
t.Fatalf("decode failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.TotalEnqueued != 3 || len(result.EnqueuedJobs) != 3 {
|
||||||
|
t.Errorf("envelope drift: enqueued=%d jobs=%d, want 3/3",
|
||||||
|
result.TotalEnqueued, len(result.EnqueuedJobs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkRenew_Handler_EmptyBody_400(t *testing.T) {
|
||||||
|
svc := &mockBulkRenewalService{}
|
||||||
|
h := NewBulkRenewalHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(`{}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = req.WithContext(authedContext())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.BulkRenew(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400 (empty criteria must reject)", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "filter criterion") {
|
||||||
|
t.Errorf("body should name the criteria-required contract; got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkRenew_Handler_WrongMethod_405(t *testing.T) {
|
||||||
|
svc := &mockBulkRenewalService{}
|
||||||
|
h := NewBulkRenewalHandler(svc)
|
||||||
|
|
||||||
|
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} {
|
||||||
|
req := httptest.NewRequest(method, "/api/v1/certificates/bulk-renew", nil)
|
||||||
|
req = req.WithContext(authedContext())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.BulkRenew(w, req)
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("%s → status %d, want 405", method, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkRenew_Handler_ActorAttribution(t *testing.T) {
|
||||||
|
var capturedActor string
|
||||||
|
svc := &mockBulkRenewalService{
|
||||||
|
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
|
||||||
|
capturedActor = actor
|
||||||
|
return &domain.BulkRenewalResult{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewBulkRenewalHandler(svc)
|
||||||
|
|
||||||
|
body := `{"certificate_ids":["mc-1"]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
|
||||||
|
req = req.WithContext(authedContext())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.BulkRenew(w, req)
|
||||||
|
|
||||||
|
if capturedActor != "alice" {
|
||||||
|
t.Errorf("actor not threaded from middleware.UserKey: got %q, want 'alice'", capturedActor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkRenew_Handler_ServiceError_500(t *testing.T) {
|
||||||
|
svc := &mockBulkRenewalService{
|
||||||
|
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
|
||||||
|
return nil, errors.New("simulated DB failure")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewBulkRenewalHandler(svc)
|
||||||
|
body := `{"certificate_ids":["mc-1"]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
|
||||||
|
req = req.WithContext(authedContext())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.BulkRenew(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("status = %d, want 500", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -900,7 +900,7 @@ func TestRevokeCertificate_Handler_AlreadyRevoked(t *testing.T) {
|
|||||||
func TestRevokeCertificate_Handler_NotFound(t *testing.T) {
|
func TestRevokeCertificate_Handler_NotFound(t *testing.T) {
|
||||||
mock := &MockCertificateService{
|
mock := &MockCertificateService{
|
||||||
RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error {
|
RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error {
|
||||||
return fmt.Errorf("failed to fetch certificate: not found")
|
return fmt.Errorf("failed to fetch certificate: not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,7 +1033,7 @@ func TestGetDERCRL_Success(t *testing.T) {
|
|||||||
if issuerID == "iss-local" {
|
if issuerID == "iss-local" {
|
||||||
return derCRLData, nil
|
return derCRLData, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("issuer not found")
|
return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,7 +1061,7 @@ func TestGetDERCRL_Success(t *testing.T) {
|
|||||||
func TestGetDERCRL_IssuerNotFound(t *testing.T) {
|
func TestGetDERCRL_IssuerNotFound(t *testing.T) {
|
||||||
mock := &MockCertificateService{
|
mock := &MockCertificateService{
|
||||||
GenerateDERCRLFn: func(_ context.Context, issuerID string) ([]byte, error) {
|
GenerateDERCRLFn: func(_ context.Context, issuerID string) ([]byte, error) {
|
||||||
return nil, fmt.Errorf("issuer not found")
|
return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,7 +1118,7 @@ func TestHandleOCSP_Success(t *testing.T) {
|
|||||||
if issuerID == "iss-local" && serialHex == "12345" {
|
if issuerID == "iss-local" && serialHex == "12345" {
|
||||||
return ocspResponseBytes, nil
|
return ocspResponseBytes, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("certificate not found")
|
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1159,7 +1159,7 @@ func TestHandleOCSP_MissingSerial(t *testing.T) {
|
|||||||
func TestHandleOCSP_IssuerNotFound(t *testing.T) {
|
func TestHandleOCSP_IssuerNotFound(t *testing.T) {
|
||||||
mock := &MockCertificateService{
|
mock := &MockCertificateService{
|
||||||
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
|
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
|
||||||
return nil, fmt.Errorf("issuer not found")
|
return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,7 +1178,7 @@ func TestHandleOCSP_IssuerNotFound(t *testing.T) {
|
|||||||
func TestHandleOCSP_CertNotFound(t *testing.T) {
|
func TestHandleOCSP_CertNotFound(t *testing.T) {
|
||||||
mock := &MockCertificateService{
|
mock := &MockCertificateService{
|
||||||
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
|
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
|
||||||
return nil, fmt.Errorf("certificate not found")
|
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1529,7 +1529,7 @@ func TestGetCertificateDeployments_Success(t *testing.T) {
|
|||||||
func TestGetCertificateDeployments_NotFound(t *testing.T) {
|
func TestGetCertificateDeployments_NotFound(t *testing.T) {
|
||||||
mock := &MockCertificateService{
|
mock := &MockCertificateService{
|
||||||
GetCertificateDeploymentsFn: func(_ context.Context, certID string) ([]domain.DeploymentTarget, error) {
|
GetCertificateDeploymentsFn: func(_ context.Context, certID string) ([]domain.DeploymentTarget, error) {
|
||||||
return nil, fmt.Errorf("certificate not found")
|
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -298,7 +299,7 @@ func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Req
|
|||||||
|
|
||||||
updated, err := h.svc.UpdateCertificate(r.Context(), id, cert)
|
updated, err := h.svc.UpdateCertificate(r.Context(), id, cert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -327,7 +328,7 @@ func (h CertificateHandler) ArchiveCertificate(w http.ResponseWriter, r *http.Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.ArchiveCertificate(r.Context(), id); err != nil {
|
if err := h.svc.ArchiveCertificate(r.Context(), id); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -373,7 +374,7 @@ func (h CertificateHandler) GetCertificateVersions(w http.ResponseWriter, r *htt
|
|||||||
|
|
||||||
versions, total, err := h.svc.GetCertificateVersions(r.Context(), certID, page, perPage)
|
versions, total, err := h.svc.GetCertificateVersions(r.Context(), certID, page, perPage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ func TestGetDiscovered_Success(t *testing.T) {
|
|||||||
if id == "dcert-1" {
|
if id == "dcert-1" {
|
||||||
return cert, nil
|
return cert, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("not found")
|
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +331,7 @@ func TestGetDiscovered_Success(t *testing.T) {
|
|||||||
func TestGetDiscovered_NotFound(t *testing.T) {
|
func TestGetDiscovered_NotFound(t *testing.T) {
|
||||||
mock := &MockDiscoveryService{
|
mock := &MockDiscoveryService{
|
||||||
GetDiscoveredFn: func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
|
GetDiscoveredFn: func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
|
||||||
return nil, fmt.Errorf("not found")
|
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +412,7 @@ func TestClaimDiscovered_MissingManagedCertID(t *testing.T) {
|
|||||||
func TestClaimDiscovered_NotFound(t *testing.T) {
|
func TestClaimDiscovered_NotFound(t *testing.T) {
|
||||||
mock := &MockDiscoveryService{
|
mock := &MockDiscoveryService{
|
||||||
ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string, actor string) error {
|
ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string, actor string) error {
|
||||||
return fmt.Errorf("discovered certificate not found")
|
return fmt.Errorf("discovered certificate not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +442,7 @@ func TestDismissDiscovered_Success(t *testing.T) {
|
|||||||
if id == "dcert-1" {
|
if id == "dcert-1" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("not found")
|
return fmt.Errorf("not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
"errors"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -46,7 +48,7 @@ func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
result, err := h.svc.ExportPEM(r.Context(), id)
|
result, err := h.svc.ExportPEM(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -94,7 +96,7 @@ func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password)
|
pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func TestExportPEM_Download(t *testing.T) {
|
|||||||
func TestExportPEM_NotFound(t *testing.T) {
|
func TestExportPEM_NotFound(t *testing.T) {
|
||||||
mockSvc := &MockExportService{
|
mockSvc := &MockExportService{
|
||||||
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
||||||
return nil, fmt.Errorf("certificate not found")
|
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
h := NewExportHandler(mockSvc)
|
h := NewExportHandler(mockSvc)
|
||||||
@@ -216,7 +216,7 @@ func TestExportPKCS12_EmptyPassword(t *testing.T) {
|
|||||||
func TestExportPKCS12_NotFound(t *testing.T) {
|
func TestExportPKCS12_NotFound(t *testing.T) {
|
||||||
mockSvc := &MockExportService{
|
mockSvc := &MockExportService{
|
||||||
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
|
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
|
||||||
return nil, fmt.Errorf("certificate not found")
|
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
h := NewExportHandler(mockSvc)
|
h := NewExportHandler(mockSvc)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
"errors"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -210,9 +212,9 @@ func (h IssuerHandler) DeleteIssuer(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.DeleteIssuer(r.Context(), id); err != nil {
|
if err := h.svc.DeleteIssuer(r.Context(), id); err != nil {
|
||||||
if strings.Contains(err.Error(), "violates foreign key") || strings.Contains(err.Error(), "RESTRICT") {
|
if repository.IsForeignKeyError(err) {
|
||||||
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete issuer: certificates are still using this issuer", requestID)
|
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete issuer: certificates are still using this issuer", requestID)
|
||||||
} else if strings.Contains(err.Error(), "not found") {
|
} else if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID)
|
||||||
} else {
|
} else {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete issuer", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete issuer", requestID)
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ func TestApproveJob_Success(t *testing.T) {
|
|||||||
func TestApproveJob_NotFound(t *testing.T) {
|
func TestApproveJob_NotFound(t *testing.T) {
|
||||||
mock := &MockJobService{
|
mock := &MockJobService{
|
||||||
ApproveJobFn: func(id, actor string) error {
|
ApproveJobFn: func(id, actor string) error {
|
||||||
return fmt.Errorf("job not found: no rows")
|
return fmt.Errorf("job not found: no rows: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +527,7 @@ func TestRejectJob_NoReason(t *testing.T) {
|
|||||||
func TestRejectJob_NotFound(t *testing.T) {
|
func TestRejectJob_NotFound(t *testing.T) {
|
||||||
mock := &MockJobService{
|
mock := &MockJobService{
|
||||||
RejectJobFn: func(id, reason, actor string) error {
|
RejectJobFn: func(id, reason, actor string) error {
|
||||||
return fmt.Errorf("job not found: no rows")
|
return fmt.Errorf("job not found: no rows: %w", ErrMockNotFound)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -167,7 +168,7 @@ func (h JobHandler) ApproveJob(w http.ResponseWriter, r *http.Request) {
|
|||||||
requestID)
|
requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -213,7 +214,7 @@ func (h JobHandler) RejectJob(w http.ResponseWriter, r *http.Request) {
|
|||||||
actor := resolveActor(r.Context())
|
actor := resolveActor(r.Context())
|
||||||
|
|
||||||
if err := h.svc.RejectJob(r.Context(), jobID, body.Reason, actor); err != nil {
|
if err := h.svc.RejectJob(r.Context(), jobID, body.Reason, actor); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (m *mockNetworkScanService) GetTarget(ctx context.Context, id string) (*dom
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("not found: %s", id)
|
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockNetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
func (m *mockNetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
||||||
@@ -48,7 +48,7 @@ func (m *mockNetworkScanService) UpdateTarget(ctx context.Context, id string, ta
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("not found: %s", id)
|
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) error {
|
func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) error {
|
||||||
@@ -58,7 +58,7 @@ func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("not found: %s", id)
|
return fmt.Errorf("not found: %w", ErrMockNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) {
|
func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) {
|
||||||
@@ -71,7 +71,7 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("not found: %s", targetID)
|
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListNetworkScanTargets(t *testing.T) {
|
func TestListNetworkScanTargets(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
"errors"
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -170,7 +172,7 @@ func (h NotificationHandler) RequeueNotification(w http.ResponseWriter, r *http.
|
|||||||
notificationID := parts[0]
|
notificationID := parts[0]
|
||||||
|
|
||||||
if err := h.svc.RequeueNotification(r.Context(), notificationID); err != nil {
|
if err := h.svc.RequeueNotification(r.Context(), notificationID); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Notification not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Notification not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
"errors"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -184,9 +186,9 @@ func (h OwnerHandler) DeleteOwner(w http.ResponseWriter, r *http.Request) {
|
|||||||
id = parts[0]
|
id = parts[0]
|
||||||
|
|
||||||
if err := h.svc.DeleteOwner(r.Context(), id); err != nil {
|
if err := h.svc.DeleteOwner(r.Context(), id); err != nil {
|
||||||
if strings.Contains(err.Error(), "violates foreign key") || strings.Contains(err.Error(), "RESTRICT") {
|
if repository.IsForeignKeyError(err) {
|
||||||
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete owner: certificates are still assigned to this owner", requestID)
|
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete owner: certificates are still assigned to this owner", requestID)
|
||||||
} else if strings.Contains(err.Error(), "not found") {
|
} else if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Owner not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Owner not found", requestID)
|
||||||
} else {
|
} else {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete owner", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete owner", requestID)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
"errors"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -162,7 +164,7 @@ func (h ProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
updated, err := h.svc.UpdateProfile(r.Context(), id, profile)
|
updated, err := h.svc.UpdateProfile(r.Context(), id, profile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -195,7 +197,7 @@ func (h ProfileHandler) DeleteProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.DeleteProfile(r.Context(), id); err != nil {
|
if err := h.svc.DeleteProfile(r.Context(), id); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -26,14 +27,14 @@ type RenewalPolicyService interface {
|
|||||||
|
|
||||||
// RenewalPolicyHandler serves /api/v1/renewal-policies CRUD endpoints.
|
// RenewalPolicyHandler serves /api/v1/renewal-policies CRUD endpoints.
|
||||||
//
|
//
|
||||||
// G-1 design note: the service-level `ErrRenewalPolicyDuplicateName` /
|
// G-1 + S-2 design note: the service-level `ErrRenewalPolicyDuplicateName` /
|
||||||
// `ErrRenewalPolicyInUse` sentinels alias the repository sentinels (same var
|
// `ErrRenewalPolicyInUse` sentinels alias the repository sentinels (same var
|
||||||
// identity), so `errors.Is` walks transparently across layers. Delete/Update
|
// identity), so `errors.Is` walks transparently across layers. S-2 closure
|
||||||
// not-found detection intentionally uses a `strings.Contains(err.Error(),
|
// (cat-s6-efc7f6f6bd50) extends the same convention to not-found detection:
|
||||||
// "not found")` substring check — the repo wraps `sql.ErrNoRows` as
|
// repos now wrap `sql.ErrNoRows` via `fmt.Errorf("X not found: %w",
|
||||||
// `fmt.Errorf("renewal policy not found: %s", id)` which strips the sentinel,
|
// repository.ErrNotFound)`, handler dispatch uses
|
||||||
// and the handler red-tests' `ErrMockNotFound = errors.New("mock not found
|
// `errors.Is(err, repository.ErrNotFound)`, and `ErrMockNotFound` in
|
||||||
// error")` follows the same substring convention.
|
// test_utils.go wraps the same sentinel so the mocks still resolve to 404.
|
||||||
type RenewalPolicyHandler struct {
|
type RenewalPolicyHandler struct {
|
||||||
svc RenewalPolicyService
|
svc RenewalPolicyService
|
||||||
}
|
}
|
||||||
@@ -191,7 +192,7 @@ func (h RenewalPolicyHandler) UpdateRenewalPolicy(w http.ResponseWriter, r *http
|
|||||||
ErrorWithRequestID(w, http.StatusConflict, "A renewal policy with that name already exists", requestID)
|
ErrorWithRequestID(w, http.StatusConflict, "A renewal policy with that name already exists", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -231,7 +232,7 @@ func (h RenewalPolicyHandler) DeleteRenewalPolicy(w http.ResponseWriter, r *http
|
|||||||
ErrorWithRequestID(w, http.StatusConflict, "Renewal policy is still referenced by managed certificates", requestID)
|
ErrorWithRequestID(w, http.StatusConflict, "Renewal policy is still referenced by managed certificates", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
var (
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
// Mock errors for testing
|
)
|
||||||
ErrMockServiceFailed = errors.New("mock service error")
|
|
||||||
ErrMockNotFound = errors.New("mock not found error")
|
// Mock errors for testing.
|
||||||
ErrMockUnauthorized = errors.New("mock unauthorized error")
|
//
|
||||||
ErrMockConflict = errors.New("mock conflict error")
|
// S-2 closure (cat-s6-efc7f6f6bd50): ErrMockNotFound now wraps
|
||||||
|
// repository.ErrNotFound via fmt.Errorf("...: %w", ...) so the
|
||||||
|
// post-S-2 handler dispatch — which uses errors.Is(err,
|
||||||
|
// repository.ErrNotFound) instead of strings.Contains — still
|
||||||
|
// resolves the mock to a 404. The error message text is preserved
|
||||||
|
// for log inspection; only the wrapping changes.
|
||||||
|
var (
|
||||||
|
ErrMockServiceFailed = fmt.Errorf("mock service error")
|
||||||
|
ErrMockNotFound = fmt.Errorf("mock not found error: %w", repository.ErrNotFound)
|
||||||
|
ErrMockUnauthorized = fmt.Errorf("mock unauthorized error")
|
||||||
|
ErrMockConflict = fmt.Errorf("mock conflict error")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VersionHandler exposes the running server's build identity at
|
||||||
|
// /api/v1/version. U-3 ride-along (cat-u-no_version_endpoint, P2): pre-U-3
|
||||||
|
// there was no in-band way for an operator (or an automated rollout system)
|
||||||
|
// to ask "what version of certctl is this binary?" — they had to either read
|
||||||
|
// the container image tag externally or trust whatever the README said. The
|
||||||
|
// gap matters for the same operability story U-3 closes: when fresh-clone
|
||||||
|
// quickstarts fail, the very first question is "what code did I actually
|
||||||
|
// build", and the only honest answer needs to come from the binary itself.
|
||||||
|
//
|
||||||
|
// VersionInfo is populated from three sources, in priority order:
|
||||||
|
//
|
||||||
|
// 1. The Version field — typically supplied at build time via
|
||||||
|
// `-ldflags='-X github.com/shankar0123/certctl/internal/api/handler.Version=v2.0.50'`.
|
||||||
|
// Production releases set this from the git tag (see release.yml).
|
||||||
|
//
|
||||||
|
// 2. runtime/debug.ReadBuildInfo() — populated by Go 1.18+ for any binary
|
||||||
|
// built from a module. Provides the VCS commit SHA, dirty flag, and
|
||||||
|
// build timestamp. We read these fields directly so a `go build` from a
|
||||||
|
// working tree (no -ldflags incantation) still produces a useful
|
||||||
|
// /api/v1/version payload — the failure mode pre-U-3 was that everything
|
||||||
|
// looked like "dev" everywhere, which made "is the bug fixed in this
|
||||||
|
// binary" unanswerable.
|
||||||
|
//
|
||||||
|
// 3. Static fallbacks ("dev" / "unknown") — only reached when neither
|
||||||
|
// ldflags nor build-info are populated, which in practice means
|
||||||
|
// `go run` from a non-VCS-tracked workspace.
|
||||||
|
//
|
||||||
|
// The handler runs through the no-auth bypass dispatch in cmd/server/main.go
|
||||||
|
// so probes and rollout systems can query it without presenting Bearer
|
||||||
|
// credentials, mirroring how /health and /ready are reachable. Audit logging
|
||||||
|
// excludes /api/v1/version for the same reason — the path is hot under
|
||||||
|
// rollout polling and would otherwise dominate the audit trail.
|
||||||
|
type VersionHandler struct{}
|
||||||
|
|
||||||
|
// Version is overridden at build time via:
|
||||||
|
//
|
||||||
|
// -ldflags='-X github.com/shankar0123/certctl/internal/api/handler.Version=<tag>'
|
||||||
|
//
|
||||||
|
// release.yml does this for the server container and CLI/agent binaries.
|
||||||
|
// The empty default (rather than "dev") lets the Handler fall back to the
|
||||||
|
// runtime/debug VCS revision when ldflags wasn't supplied — preferable to
|
||||||
|
// returning a literal "dev" that masks the actual git SHA the binary was
|
||||||
|
// built from.
|
||||||
|
var Version = ""
|
||||||
|
|
||||||
|
// NewVersionHandler returns a value (not a pointer) to match the
|
||||||
|
// HealthHandler convention — the handler holds no mutable state and is
|
||||||
|
// safe to copy.
|
||||||
|
func NewVersionHandler() VersionHandler {
|
||||||
|
return VersionHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionInfo is the JSON shape returned by GET /api/v1/version.
|
||||||
|
//
|
||||||
|
// Field ordering and tag names are part of the contract — operator tooling
|
||||||
|
// (k8s rollout checks, CI smoke tests, /api/v1/version Prometheus blackbox
|
||||||
|
// probes) parses this payload and must continue to work across releases.
|
||||||
|
// Don't rename a field without an OpenAPI bump and a deprecation cycle.
|
||||||
|
type VersionInfo struct {
|
||||||
|
// Version is the human-readable release identifier (e.g. "v2.0.50").
|
||||||
|
// Falls back to the VCS revision when ldflags wasn't set, and to "dev"
|
||||||
|
// when the build wasn't VCS-tracked at all.
|
||||||
|
Version string `json:"version"`
|
||||||
|
|
||||||
|
// Commit is the git SHA of HEAD at build time, sourced from
|
||||||
|
// runtime/debug.BuildInfo.Settings["vcs.revision"]. Empty string when
|
||||||
|
// the binary was built outside a VCS-tracked workspace (rare —
|
||||||
|
// `go build` from a tarball does this).
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
|
||||||
|
// Modified reports whether the build had uncommitted changes
|
||||||
|
// (debug.BuildInfo.Settings["vcs.modified"]). True for developer
|
||||||
|
// builds, false for release builds out of CI.
|
||||||
|
Modified bool `json:"modified"`
|
||||||
|
|
||||||
|
// BuildTime is the RFC 3339 timestamp captured at build time
|
||||||
|
// (debug.BuildInfo.Settings["vcs.time"]). Empty when not VCS-tracked.
|
||||||
|
BuildTime string `json:"build_time"`
|
||||||
|
|
||||||
|
// GoVersion is the Go toolchain version that compiled the binary
|
||||||
|
// (runtime.Version, e.g. "go1.25.9"). Useful when triaging stdlib
|
||||||
|
// behavior differences ("the deploy that broke was on 1.24, this one
|
||||||
|
// is on 1.25").
|
||||||
|
GoVersion string `json:"go_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBuildInfo extracts the VCS settings from debug.BuildInfo and pairs
|
||||||
|
// them with the ldflags-supplied Version. Split out from ServeHTTP so the
|
||||||
|
// handler can be unit-tested by injecting synthetic BuildInfo (see
|
||||||
|
// version_handler_test.go) without depending on the test binary's actual
|
||||||
|
// debug info.
|
||||||
|
//
|
||||||
|
// debug.ReadBuildInfo returns ok=false when the binary was built without
|
||||||
|
// module info — extremely rare for a Go 1.18+ build, but we guard it so
|
||||||
|
// the handler degrades to "dev / unknown / runtime.Version()" instead of
|
||||||
|
// nil-deref panicking.
|
||||||
|
func readBuildInfo() VersionInfo {
|
||||||
|
info := VersionInfo{
|
||||||
|
Version: Version,
|
||||||
|
GoVersion: runtime.Version(),
|
||||||
|
}
|
||||||
|
|
||||||
|
bi, ok := debug.ReadBuildInfo()
|
||||||
|
if !ok {
|
||||||
|
// Pre-Go 1.18 binary or a stripped build with no buildinfo segment.
|
||||||
|
// Both are pathological in 2026 but worth the two-line guard.
|
||||||
|
if info.Version == "" {
|
||||||
|
info.Version = "dev"
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range bi.Settings {
|
||||||
|
switch s.Key {
|
||||||
|
case "vcs.revision":
|
||||||
|
info.Commit = s.Value
|
||||||
|
case "vcs.modified":
|
||||||
|
// debug.BuildInfo encodes this as the literal string "true" or
|
||||||
|
// "false"; comparing to "true" is the canonical pattern (mirrors
|
||||||
|
// how the standard library's own version sub-command parses it).
|
||||||
|
info.Modified = s.Value == "true"
|
||||||
|
case "vcs.time":
|
||||||
|
info.BuildTime = s.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback ladder for Version: ldflags > VCS commit > "dev". The git
|
||||||
|
// SHA is more useful than "dev" because it's at least groundable — an
|
||||||
|
// operator can `git show <sha>` to see what code is actually running.
|
||||||
|
if info.Version == "" {
|
||||||
|
if info.Commit != "" {
|
||||||
|
info.Version = info.Commit
|
||||||
|
} else {
|
||||||
|
info.Version = "dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler. Returns the VersionInfo payload as
|
||||||
|
// JSON with a 200 status. GET-only — any other method returns 405, matching
|
||||||
|
// the HealthHandler convention.
|
||||||
|
func (h VersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
JSON(w, http.StatusOK, readBuildInfo())
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestVersion_ReturnsBuildInfo is the regression for the U-3 ride-along
|
||||||
|
// cat-u-no_version_endpoint (P2). Three behaviors must hold for the
|
||||||
|
// endpoint to be useful in operator tooling:
|
||||||
|
//
|
||||||
|
// 1. GET /api/v1/version returns 200 with a JSON body that decodes into
|
||||||
|
// the documented VersionInfo shape — the wire contract that rollout
|
||||||
|
// systems and Prometheus blackbox probes parse.
|
||||||
|
// 2. The Go runtime version always populates (runtime.Version() can never
|
||||||
|
// return empty), so consumers can always answer "which Go did this
|
||||||
|
// binary compile with" even when ldflags / VCS info are missing.
|
||||||
|
// 3. The Version field is never empty — the fallback ladder
|
||||||
|
// (ldflags > VCS commit > "dev") guarantees a non-empty string so
|
||||||
|
// consumers don't have to special-case absent values.
|
||||||
|
//
|
||||||
|
// We don't pin the exact Version value because it depends on whether the
|
||||||
|
// test binary was built with -ldflags or under `go test`, both of which
|
||||||
|
// the handler must tolerate. The "no empty string" check is the
|
||||||
|
// behavioral contract.
|
||||||
|
func TestVersion_ReturnsBuildInfo(t *testing.T) {
|
||||||
|
h := NewVersionHandler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := rec.Header().Get("Content-Type")
|
||||||
|
if !strings.HasPrefix(contentType, "application/json") {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json prefix (operator tooling parses JSON)", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got VersionInfo
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("response body did not decode into VersionInfo: %v\nbody: %s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version must never be empty — the fallback ladder in readBuildInfo
|
||||||
|
// guarantees this. An empty Version would force every downstream
|
||||||
|
// consumer (k8s rollouts, Prometheus blackbox, the support tooling)
|
||||||
|
// to special-case the missing value, which defeats the point of
|
||||||
|
// /api/v1/version existing.
|
||||||
|
if got.Version == "" {
|
||||||
|
t.Error("Version is empty — the fallback ladder (ldflags > VCS commit > 'dev') must guarantee a non-empty value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoVersion must equal runtime.Version() — the handler reads it
|
||||||
|
// directly and cannot be subverted by ldflags or BuildInfo. This is
|
||||||
|
// the one field that should always be ground-truth.
|
||||||
|
if got.GoVersion != runtime.Version() {
|
||||||
|
t.Errorf("GoVersion = %q, want %q (must come straight from runtime.Version())",
|
||||||
|
got.GoVersion, runtime.Version())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVersion_RejectsNonGet pins the GET-only contract. /api/v1/version
|
||||||
|
// is read-only build identity; POST/PUT/DELETE etc. are nonsensical and
|
||||||
|
// should return 405 like the HealthHandler does. Operator tooling that
|
||||||
|
// fat-fingers the verb gets a clear error rather than a confusing 200
|
||||||
|
// from the wrong code path.
|
||||||
|
func TestVersion_RejectsNonGet(t *testing.T) {
|
||||||
|
h := NewVersionHandler()
|
||||||
|
|
||||||
|
for _, method := range []string{
|
||||||
|
http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch,
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(method, "/api/v1/version", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("%s /api/v1/version → status %d, want 405", method, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVersion_LdflagsOverride locks in the priority order: when the
|
||||||
|
// build-time Version variable is non-empty (e.g. "v2.0.50" injected by
|
||||||
|
// release.yml), readBuildInfo MUST surface that value verbatim and not
|
||||||
|
// silently substitute the VCS commit. The release-pipeline contract
|
||||||
|
// depends on this — a release tagged v2.0.50 should report "v2.0.50",
|
||||||
|
// not the underlying SHA.
|
||||||
|
//
|
||||||
|
// We achieve test isolation by save/restore on the package-level Version
|
||||||
|
// variable; t.Cleanup ensures parallel/subsequent tests see the original.
|
||||||
|
func TestVersion_LdflagsOverride(t *testing.T) {
|
||||||
|
original := Version
|
||||||
|
t.Cleanup(func() { Version = original })
|
||||||
|
|
||||||
|
Version = "v2.0.50-test"
|
||||||
|
got := readBuildInfo()
|
||||||
|
if got.Version != "v2.0.50-test" {
|
||||||
|
t.Errorf("Version = %q, want %q (ldflags-supplied Version must take priority over VCS fallback)",
|
||||||
|
got.Version, "v2.0.50-test")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecurityHeadersConfig configures the SecurityHeaders middleware.
|
||||||
|
//
|
||||||
|
// Each field is the literal value to send. An empty string means
|
||||||
|
// "do not send this header" — operators behind a customising reverse
|
||||||
|
// proxy can disable any header per-deployment without touching code.
|
||||||
|
// Defaults are applied via SecurityHeadersDefaults() which encodes
|
||||||
|
// the H-1 closure's recommended baseline for an HTTPS-only API+UI
|
||||||
|
// host: HSTS, deny-frame, no-MIME-sniff, conservative CSP, and a
|
||||||
|
// no-referrer-when-downgrade fallback.
|
||||||
|
//
|
||||||
|
// H-1 closure (cat-s11-missing_security_headers).
|
||||||
|
type SecurityHeadersConfig struct {
|
||||||
|
HSTS string // Strict-Transport-Security
|
||||||
|
FrameOptions string // X-Frame-Options
|
||||||
|
ContentTypeOptions string // X-Content-Type-Options
|
||||||
|
ReferrerPolicy string // Referrer-Policy
|
||||||
|
ContentSecurityPolicy string // Content-Security-Policy
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityHeadersDefaults returns a recommended baseline.
|
||||||
|
//
|
||||||
|
// CSP: default-src 'self' confines fetches to the same origin.
|
||||||
|
// img-src 'self' data: allows inline base64 images (used by the
|
||||||
|
// dashboard's certctl-logo and a few status icons).
|
||||||
|
// style-src 'self' 'unsafe-inline' is required because Tailwind
|
||||||
|
// (via Vite) injects per-component <style> blocks at build time;
|
||||||
|
// without 'unsafe-inline' the dashboard would render unstyled.
|
||||||
|
// 'unsafe-inline' is intentionally NOT in script-src — the
|
||||||
|
// front-end ships as a bundled JS file, no inline scripts.
|
||||||
|
//
|
||||||
|
// HSTS: 1-year max-age + includeSubDomains. No `preload` directive
|
||||||
|
// because preload submission requires explicit operator action and
|
||||||
|
// the deployment topology may not span all subdomains.
|
||||||
|
//
|
||||||
|
// X-Frame-Options: DENY — the dashboard does not need to be embedded
|
||||||
|
// anywhere, and DENY is more conservative than SAMEORIGIN against
|
||||||
|
// clickjacking via subdomain takeover.
|
||||||
|
//
|
||||||
|
// X-Content-Type-Options: nosniff — prevent MIME sniffing on
|
||||||
|
// JSON/PEM responses that browsers might otherwise interpret as HTML.
|
||||||
|
//
|
||||||
|
// Referrer-Policy: no-referrer-when-downgrade — preserves Referer
|
||||||
|
// for same-origin navigation (useful for support/diagnostics) but
|
||||||
|
// strips it on HTTPS→HTTP transitions.
|
||||||
|
func SecurityHeadersDefaults() SecurityHeadersConfig {
|
||||||
|
return SecurityHeadersConfig{
|
||||||
|
HSTS: "max-age=31536000; includeSubDomains",
|
||||||
|
FrameOptions: "DENY",
|
||||||
|
ContentTypeOptions: "nosniff",
|
||||||
|
ReferrerPolicy: "no-referrer-when-downgrade",
|
||||||
|
ContentSecurityPolicy: "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityHeaders returns a middleware that applies the configured
|
||||||
|
// HTTP response headers on every response. Headers configured to the
|
||||||
|
// empty string are omitted (operator opted out for that deployment).
|
||||||
|
//
|
||||||
|
// Apply BEFORE the audit middleware so headers reach 4xx/5xx responses
|
||||||
|
// — which is where header omissions matter most for the security
|
||||||
|
// posture (an attacker probing for misconfiguration sees the same
|
||||||
|
// headers on a 401 as on a 200).
|
||||||
|
func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler {
|
||||||
|
// Pre-trim each value once; the per-request hot path stays a
|
||||||
|
// straight set of map writes.
|
||||||
|
type headerEntry struct{ name, value string }
|
||||||
|
entries := make([]headerEntry, 0, 5)
|
||||||
|
add := func(name, value string) {
|
||||||
|
v := strings.TrimSpace(value)
|
||||||
|
if v != "" {
|
||||||
|
entries = append(entries, headerEntry{name, v})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("Strict-Transport-Security", cfg.HSTS)
|
||||||
|
add("X-Frame-Options", cfg.FrameOptions)
|
||||||
|
add("X-Content-Type-Options", cfg.ContentTypeOptions)
|
||||||
|
add("Referrer-Policy", cfg.ReferrerPolicy)
|
||||||
|
add("Content-Security-Policy", cfg.ContentSecurityPolicy)
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h := w.Header()
|
||||||
|
for _, e := range entries {
|
||||||
|
h.Set(e.name, e.value)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSecurityHeaders_DefaultsAllPresent asserts every default header
|
||||||
|
// arrives on a 200 response. H-1 closure (cat-s11-missing_security_headers).
|
||||||
|
func TestSecurityHeaders_DefaultsAllPresent(t *testing.T) {
|
||||||
|
mw := SecurityHeaders(SecurityHeadersDefaults())
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
for _, h := range []string{
|
||||||
|
"Strict-Transport-Security",
|
||||||
|
"X-Frame-Options",
|
||||||
|
"X-Content-Type-Options",
|
||||||
|
"Referrer-Policy",
|
||||||
|
"Content-Security-Policy",
|
||||||
|
} {
|
||||||
|
if got := rec.Header().Get(h); got == "" {
|
||||||
|
t.Errorf("expected header %q to be set, got empty", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||||
|
t.Errorf("X-Content-Type-Options: got %q, want %q", got, "nosniff")
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
|
||||||
|
t.Errorf("X-Frame-Options: got %q, want %q", got, "DENY")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecurityHeaders_EmptyValueDisablesHeader asserts an operator can
|
||||||
|
// disable a single header by setting its config field to empty without
|
||||||
|
// affecting the others.
|
||||||
|
func TestSecurityHeaders_EmptyValueDisablesHeader(t *testing.T) {
|
||||||
|
cfg := SecurityHeadersDefaults()
|
||||||
|
cfg.HSTS = "" // simulate operator override
|
||||||
|
mw := SecurityHeaders(cfg)
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
|
||||||
|
if got := rec.Header().Get("Strict-Transport-Security"); got != "" {
|
||||||
|
t.Errorf("HSTS should be omitted when config value is empty; got %q", got)
|
||||||
|
}
|
||||||
|
// Other headers still present
|
||||||
|
if got := rec.Header().Get("X-Frame-Options"); got == "" {
|
||||||
|
t.Errorf("X-Frame-Options should still be present (empty HSTS only disables HSTS)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecurityHeaders_OverrideValueApplied asserts a non-default value
|
||||||
|
// makes it through.
|
||||||
|
func TestSecurityHeaders_OverrideValueApplied(t *testing.T) {
|
||||||
|
cfg := SecurityHeadersDefaults()
|
||||||
|
cfg.FrameOptions = "SAMEORIGIN"
|
||||||
|
mw := SecurityHeaders(cfg)
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
|
||||||
|
if got := rec.Header().Get("X-Frame-Options"); got != "SAMEORIGIN" {
|
||||||
|
t.Errorf("X-Frame-Options: got %q, want %q", got, "SAMEORIGIN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecurityHeaders_AppliedOnErrorResponses asserts headers are
|
||||||
|
// present on 4xx/5xx as well as 2xx — this is critical for the
|
||||||
|
// security posture (an attacker probing for misconfiguration sees
|
||||||
|
// the same headers on a 401 as on a 200).
|
||||||
|
func TestSecurityHeaders_AppliedOnErrorResponses(t *testing.T) {
|
||||||
|
mw := SecurityHeaders(SecurityHeadersDefaults())
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Strict-Transport-Security"); got == "" {
|
||||||
|
t.Errorf("HSTS missing on 401 response (must be on every response)")
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Security-Policy"); got == "" {
|
||||||
|
t.Errorf("CSP missing on 401 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,18 @@ type HandlerRegistry struct {
|
|||||||
Digest handler.DigestHandler
|
Digest handler.DigestHandler
|
||||||
HealthChecks *handler.HealthCheckHandler
|
HealthChecks *handler.HealthCheckHandler
|
||||||
BulkRevocation handler.BulkRevocationHandler
|
BulkRevocation handler.BulkRevocationHandler
|
||||||
RenewalPolicies handler.RenewalPolicyHandler
|
// L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
|
||||||
|
// server-side bulk endpoints replace pre-L-1 client-side N×HTTP
|
||||||
|
// loops in CertificatesPage.tsx. See handler/bulk_renewal.go and
|
||||||
|
// handler/bulk_reassignment.go.
|
||||||
|
BulkRenewal handler.BulkRenewalHandler
|
||||||
|
BulkReassignment handler.BulkReassignmentHandler
|
||||||
|
RenewalPolicies handler.RenewalPolicyHandler
|
||||||
|
// Version handles GET /api/v1/version (U-3 ride-along,
|
||||||
|
// cat-u-no_version_endpoint). Wired through the no-auth dispatch in
|
||||||
|
// cmd/server/main.go so probes and rollout systems can read build
|
||||||
|
// identity without Bearer credentials. See handler/version.go.
|
||||||
|
Version handler.VersionHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterHandlers sets up all API routes with their handlers.
|
// RegisterHandlers sets up all API routes with their handlers.
|
||||||
@@ -89,12 +100,32 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
|||||||
middleware.CORS,
|
middleware.CORS,
|
||||||
middleware.ContentType,
|
middleware.ContentType,
|
||||||
))
|
))
|
||||||
|
// Version endpoint (no auth middleware — used by rollout probes that
|
||||||
|
// don't carry Bearer tokens; the dispatch layer in cmd/server/main.go
|
||||||
|
// also routes /api/v1/version through the no-auth chain). U-3 ride-along
|
||||||
|
// (cat-u-no_version_endpoint, P2). The handler reads
|
||||||
|
// runtime/debug.BuildInfo for VCS attribution; ldflags-supplied Version
|
||||||
|
// is preferred when present.
|
||||||
|
r.mux.Handle("GET /api/v1/version", middleware.Chain(
|
||||||
|
reg.Version,
|
||||||
|
middleware.CORS,
|
||||||
|
middleware.ContentType,
|
||||||
|
))
|
||||||
// Auth check endpoint (uses full middleware chain via r.Register)
|
// Auth check endpoint (uses full middleware chain via r.Register)
|
||||||
r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck))
|
r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck))
|
||||||
|
|
||||||
// Certificates routes: /api/v1/certificates
|
// Certificates routes: /api/v1/certificates
|
||||||
// Bulk revoke must be registered before {id} routes to avoid path conflict
|
// Bulk operations MUST register before {id} routes — Go 1.22 ServeMux
|
||||||
|
// gives literal segments precedence over pattern-var segments, but
|
||||||
|
// listing the bulk paths first makes the precedence operator-visible
|
||||||
|
// and prevents a future refactor from accidentally inverting it. All
|
||||||
|
// three bulk endpoints share the same envelope shape (criteria/IDs
|
||||||
|
// in, {total_matched, total_<verb>, total_skipped, total_failed,
|
||||||
|
// errors[]} out). L-1 master added bulk-renew + bulk-reassign
|
||||||
|
// alongside the pre-existing bulk-revoke.
|
||||||
r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke))
|
r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke))
|
||||||
|
r.Register("POST /api/v1/certificates/bulk-renew", http.HandlerFunc(reg.BulkRenewal.BulkRenew))
|
||||||
|
r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign))
|
||||||
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates))
|
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates))
|
||||||
r.Register("POST /api/v1/certificates", http.HandlerFunc(reg.Certificates.CreateCertificate))
|
r.Register("POST /api/v1/certificates", http.HandlerFunc(reg.Certificates.CreateCertificate))
|
||||||
r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.GetCertificate))
|
r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.GetCertificate))
|
||||||
|
|||||||
@@ -709,6 +709,16 @@ type DatabaseConfig struct {
|
|||||||
URL string
|
URL string
|
||||||
MaxConnections int
|
MaxConnections int
|
||||||
MigrationsPath string
|
MigrationsPath string
|
||||||
|
|
||||||
|
// DemoSeed, when true, makes the server apply
|
||||||
|
// `<MigrationsPath>/seed_demo.sql` after the baseline `seed.sql`. Set
|
||||||
|
// via CERTCTL_DEMO_SEED. The compose demo overlay
|
||||||
|
// (deploy/docker-compose.demo.yml) sets this to keep the demo path
|
||||||
|
// alive after U-3 dropped initdb-mounted seed files. The seed file
|
||||||
|
// uses ON CONFLICT (id) DO NOTHING so re-running on a populated
|
||||||
|
// database is safe; missing-file is a no-op (returns nil) so a
|
||||||
|
// minimal-image deploy that strips seed_demo.sql still boots cleanly.
|
||||||
|
DemoSeed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// SchedulerConfig contains scheduler timing configuration.
|
// SchedulerConfig contains scheduler timing configuration.
|
||||||
@@ -774,6 +784,18 @@ type SchedulerConfig struct {
|
|||||||
// second.
|
// second.
|
||||||
// Setting: CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT environment variable.
|
// Setting: CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT environment variable.
|
||||||
AwaitingApprovalTimeout time.Duration
|
AwaitingApprovalTimeout time.Duration
|
||||||
|
|
||||||
|
// ShortLivedExpiryCheckInterval is how often the scheduler scans
|
||||||
|
// short-lived certificates and marks expired rows as Expired. Default:
|
||||||
|
// 30 seconds (matches the in-memory default in scheduler.NewScheduler).
|
||||||
|
// C-1 closure (cat-g-7e38f9708e20 + diff-10xmain-2bf4a0a60388):
|
||||||
|
// pre-C-1 the setter scheduler.SetShortLivedExpiryCheckInterval was
|
||||||
|
// defined + tested but never called from cmd/server/main.go, so the
|
||||||
|
// 30-second default was effectively hardcoded. Operators who needed
|
||||||
|
// to tune the cadence (e.g. a high-churn short-lived cert tenant)
|
||||||
|
// had no path. Post-C-1 main.go wires this knob.
|
||||||
|
// Setting: CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL environment variable.
|
||||||
|
ShortLivedExpiryCheckInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogConfig contains logging configuration.
|
// LogConfig contains logging configuration.
|
||||||
@@ -921,6 +943,7 @@ func Load() (*Config, error) {
|
|||||||
URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"),
|
URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"),
|
||||||
MaxConnections: getEnvInt("CERTCTL_DATABASE_MAX_CONNS", 25),
|
MaxConnections: getEnvInt("CERTCTL_DATABASE_MAX_CONNS", 25),
|
||||||
MigrationsPath: getEnv("CERTCTL_DATABASE_MIGRATIONS_PATH", "./migrations"),
|
MigrationsPath: getEnv("CERTCTL_DATABASE_MIGRATIONS_PATH", "./migrations"),
|
||||||
|
DemoSeed: getEnvBool("CERTCTL_DEMO_SEED", false),
|
||||||
},
|
},
|
||||||
Scheduler: SchedulerConfig{
|
Scheduler: SchedulerConfig{
|
||||||
RenewalCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL", 1*time.Hour),
|
RenewalCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL", 1*time.Hour),
|
||||||
@@ -937,6 +960,9 @@ func Load() (*Config, error) {
|
|||||||
JobTimeoutInterval: getEnvDuration("CERTCTL_JOB_TIMEOUT_INTERVAL", 10*time.Minute),
|
JobTimeoutInterval: getEnvDuration("CERTCTL_JOB_TIMEOUT_INTERVAL", 10*time.Minute),
|
||||||
AwaitingCSRTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_CSR_TIMEOUT", 24*time.Hour),
|
AwaitingCSRTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_CSR_TIMEOUT", 24*time.Hour),
|
||||||
AwaitingApprovalTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT", 168*time.Hour),
|
AwaitingApprovalTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT", 168*time.Hour),
|
||||||
|
// C-1 closure: matches the in-memory default at
|
||||||
|
// internal/scheduler/scheduler.go:145 (30 * time.Second).
|
||||||
|
ShortLivedExpiryCheckInterval: getEnvDuration("CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL", 30*time.Second),
|
||||||
},
|
},
|
||||||
Log: LogConfig{
|
Log: LogConfig{
|
||||||
Level: getEnv("CERTCTL_LOG_LEVEL", "info"),
|
Level: getEnv("CERTCTL_LOG_LEVEL", "info"),
|
||||||
@@ -1165,6 +1191,26 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("server TLS cert/key pair invalid (cert=%q key=%q): %w — refuse to start (HTTPS-only; see docs/tls.md)", c.Server.TLS.CertPath, c.Server.TLS.KeyPath, err)
|
return fmt.Errorf("server TLS cert/key pair invalid (cert=%q key=%q): %w — refuse to start (HTTPS-only; see docs/tls.md)", c.Server.TLS.CertPath, c.Server.TLS.KeyPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// H-1 closure (cat-r-encryption_key_no_length_validation): if
|
||||||
|
// CERTCTL_CONFIG_ENCRYPTION_KEY is set, enforce a minimum length of
|
||||||
|
// 32 bytes. Pre-H-1 the field was accepted with any non-empty value
|
||||||
|
// — including a single character — and PBKDF2-SHA256 (100k rounds)
|
||||||
|
// alone does not compensate for low-entropy passphrases at scale
|
||||||
|
// (CWE-916 Use of Password Hash With Insufficient Computational
|
||||||
|
// Effort + CWE-329 Generation of Predictable IV with CBC Mode).
|
||||||
|
// 32 bytes ≈ 256 bits when generated via `openssl rand -base64 32`,
|
||||||
|
// matching the AES-256-GCM key size the passphrase derives. An
|
||||||
|
// empty key remains accepted — the fail-closed sentinel
|
||||||
|
// crypto.ErrEncryptionKeyRequired triggers downstream when an
|
||||||
|
// empty key is asked to encrypt or decrypt sensitive config.
|
||||||
|
const minEncryptionKeyLength = 32
|
||||||
|
if c.Encryption.ConfigEncryptionKey != "" && len(c.Encryption.ConfigEncryptionKey) < minEncryptionKeyLength {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"CERTCTL_CONFIG_ENCRYPTION_KEY too short (%d bytes; minimum %d). Generate with: openssl rand -base64 32",
|
||||||
|
len(c.Encryption.ConfigEncryptionKey), minEncryptionKeyLength,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Validate database configuration
|
// Validate database configuration
|
||||||
if c.Database.URL == "" {
|
if c.Database.URL == "" {
|
||||||
return fmt.Errorf("database URL is required")
|
return fmt.Errorf("database URL is required")
|
||||||
|
|||||||
@@ -1209,3 +1209,84 @@ func TestConfig_Scheduler_JobTimeoutValidation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// H-1 closure (cat-r-encryption_key_no_length_validation): validate
|
||||||
|
// CERTCTL_CONFIG_ENCRYPTION_KEY length. Pre-H-1 the field was accepted
|
||||||
|
// with any non-empty value (including a single character); post-H-1 a
|
||||||
|
// minimum 32-byte length is enforced. Empty stays accepted because the
|
||||||
|
// downstream fail-closed sentinel crypto.ErrEncryptionKeyRequired
|
||||||
|
// handles the missing-key case for the encrypt/decrypt paths.
|
||||||
|
|
||||||
|
func validBaseConfigForEncryption(t *testing.T) *Config {
|
||||||
|
t.Helper()
|
||||||
|
return &Config{
|
||||||
|
Server: validServerConfig(t),
|
||||||
|
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
|
||||||
|
Log: LogConfig{Level: "info", Format: "json"},
|
||||||
|
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
|
||||||
|
Keygen: KeygenConfig{Mode: "agent"},
|
||||||
|
Scheduler: SchedulerConfig{
|
||||||
|
RenewalCheckInterval: 1 * time.Hour,
|
||||||
|
JobProcessorInterval: 30 * time.Second,
|
||||||
|
AgentHealthCheckInterval: 2 * time.Minute,
|
||||||
|
NotificationProcessInterval: 1 * time.Minute,
|
||||||
|
NotificationRetryInterval: 2 * time.Minute,
|
||||||
|
RetryInterval: 5 * time.Minute,
|
||||||
|
JobTimeoutInterval: 10 * time.Minute,
|
||||||
|
AwaitingCSRTimeout: 24 * time.Hour,
|
||||||
|
AwaitingApprovalTimeout: 168 * time.Hour,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_EncryptionKey_EmptyAccepted(t *testing.T) {
|
||||||
|
cfg := validBaseConfigForEncryption(t)
|
||||||
|
cfg.Encryption.ConfigEncryptionKey = ""
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Errorf("Validate() returned error for empty key: %v (empty must be accepted; fail-closed sentinel handles it downstream)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_EncryptionKey_TooShortRejected(t *testing.T) {
|
||||||
|
cfg := validBaseConfigForEncryption(t)
|
||||||
|
cfg.Encryption.ConfigEncryptionKey = "x" // 1 byte
|
||||||
|
err := cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Validate() = nil, want error for 1-byte key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "too short") {
|
||||||
|
t.Errorf("Validate() error = %q, want to contain %q", err.Error(), "too short")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "openssl rand -base64 32") {
|
||||||
|
t.Errorf("Validate() error = %q, must include the canonical generation command", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_EncryptionKey_BoundaryRejected(t *testing.T) {
|
||||||
|
cfg := validBaseConfigForEncryption(t)
|
||||||
|
cfg.Encryption.ConfigEncryptionKey = "12345678901234567890123456789012"[:31] // 31 bytes — one short
|
||||||
|
err := cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Validate() = nil, want error for 31-byte key (boundary -1)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "too short") {
|
||||||
|
t.Errorf("Validate() error = %q, want 'too short'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_EncryptionKey_MinLengthAccepted(t *testing.T) {
|
||||||
|
cfg := validBaseConfigForEncryption(t)
|
||||||
|
cfg.Encryption.ConfigEncryptionKey = "12345678901234567890123456789012" // exactly 32 bytes
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Errorf("Validate() returned error for 32-byte key: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_EncryptionKey_LongAccepted(t *testing.T) {
|
||||||
|
cfg := validBaseConfigForEncryption(t)
|
||||||
|
// Realistic operator key from `openssl rand -base64 32` — 44 characters.
|
||||||
|
cfg.Encryption.ConfigEncryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Errorf("Validate() returned error for 44-byte key: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// BulkReassignmentRequest is the input to POST /api/v1/certificates/bulk-reassign.
|
||||||
|
//
|
||||||
|
// L-2 closure (cat-l-8a1fb258a38a): the GUI used to loop
|
||||||
|
// `await updateCertificate(id, { owner_id: ownerId })` over the selection
|
||||||
|
// at `web/src/pages/CertificatesPage.tsx::handleReassign`. Post-L-2 it
|
||||||
|
// POSTs once.
|
||||||
|
//
|
||||||
|
// Narrower than BulkRenewalCriteria — the operator workflow is "I have N
|
||||||
|
// certs selected and I want them all owned by Alice now". Criteria-mode
|
||||||
|
// reassignment doesn't have a strong use case (operators query first,
|
||||||
|
// then reassign by ID), so the request is IDs-only. OwnerID is required;
|
||||||
|
// TeamID is optional and the cert's team_id is updated only when TeamID
|
||||||
|
// is non-empty (matches the existing per-cert PUT behaviour where empty
|
||||||
|
// fields leave the existing value unchanged).
|
||||||
|
type BulkReassignmentRequest struct {
|
||||||
|
CertificateIDs []string `json:"certificate_ids"`
|
||||||
|
OwnerID string `json:"owner_id"`
|
||||||
|
TeamID string `json:"team_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true if no IDs are provided. The service layer rejects
|
||||||
|
// empty IDs with a 400 — explicit-IDs is the only selection mode for
|
||||||
|
// reassignment (no criteria-mode). Naming mirrors BulkRevocationCriteria
|
||||||
|
// + BulkRenewalCriteria.IsEmpty so the validate-and-reject pattern is
|
||||||
|
// the same across all three bulk endpoints.
|
||||||
|
func (r BulkReassignmentRequest) IsEmpty() bool {
|
||||||
|
return len(r.CertificateIDs) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkReassignmentResult mirrors BulkRevocationResult / BulkRenewalResult
|
||||||
|
// envelope shape so the frontend's bulk-result rendering is one helper.
|
||||||
|
//
|
||||||
|
// Counters semantics:
|
||||||
|
// - TotalMatched: number of certs resolved from CertificateIDs
|
||||||
|
// - TotalReassigned: number where owner_id (and optionally team_id)
|
||||||
|
// was actually mutated
|
||||||
|
// - TotalSkipped: certs already owned by the target OwnerID — no-op
|
||||||
|
// skip rather than a fake "succeeded" count, so operators see "5 of
|
||||||
|
// your 10 selections were no-ops" without triaging fake errors
|
||||||
|
// - TotalFailed: certs where the per-cert update returned an error
|
||||||
|
// (e.g., the cert no longer exists, the repo update failed)
|
||||||
|
// - Errors: per-cert error details for the failure path
|
||||||
|
type BulkReassignmentResult struct {
|
||||||
|
TotalMatched int `json:"total_matched"`
|
||||||
|
TotalReassigned int `json:"total_reassigned"`
|
||||||
|
TotalSkipped int `json:"total_skipped"`
|
||||||
|
TotalFailed int `json:"total_failed"`
|
||||||
|
Errors []BulkOperationError `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBulkReassignmentRequest_IsEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
r BulkReassignmentRequest
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"all-zero", BulkReassignmentRequest{}, true},
|
||||||
|
{"empty-ids-slice", BulkReassignmentRequest{CertificateIDs: []string{}}, true},
|
||||||
|
{"ids-set-but-no-owner", BulkReassignmentRequest{CertificateIDs: []string{"mc-1"}}, false},
|
||||||
|
// IsEmpty is a pure ID-presence check; OwnerID/TeamID are
|
||||||
|
// validated separately in the service layer (OwnerID required;
|
||||||
|
// TeamID optional). This split mirrors how BulkRevocationCriteria
|
||||||
|
// + reason are validated in two distinct steps.
|
||||||
|
{"owner-set-but-no-ids", BulkReassignmentRequest{OwnerID: "o-alice"}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.r.IsEmpty(); got != tt.want {
|
||||||
|
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkReassignmentResult_JSONShape(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := &BulkReassignmentResult{
|
||||||
|
TotalMatched: 10,
|
||||||
|
TotalReassigned: 7,
|
||||||
|
TotalSkipped: 3, // already-owned-by-target — silent no-op
|
||||||
|
TotalFailed: 0,
|
||||||
|
Errors: nil,
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
var round map[string]interface{}
|
||||||
|
if err := json.Unmarshal(b, &round); err != nil {
|
||||||
|
t.Fatalf("Unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
for _, k := range []string{"total_matched", "total_reassigned", "total_skipped", "total_failed"} {
|
||||||
|
if _, ok := round[k]; !ok {
|
||||||
|
t.Errorf("missing JSON field %q in %s", k, string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := round["errors"]; ok {
|
||||||
|
t.Errorf("nil Errors should be omitempty; got: %s", string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkOperationError_JSONShape(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
e := BulkOperationError{
|
||||||
|
CertificateID: "mc-1",
|
||||||
|
Error: "renewal already in progress",
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
want := `{"certificate_id":"mc-1","error":"renewal already in progress"}`
|
||||||
|
if string(b) != want {
|
||||||
|
t.Errorf("JSON shape drift:\n got: %s\n want: %s", string(b), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// BulkRenewalCriteria selects a set of managed certificates to renew. At
|
||||||
|
// least one selector must be non-empty (IsEmpty() guards this in the
|
||||||
|
// service layer; same shape and rule as BulkRevocationCriteria so
|
||||||
|
// operators who already know the bulk-revoke contract have zero new
|
||||||
|
// surface to learn).
|
||||||
|
//
|
||||||
|
// L-1 master closure (cat-l-fa0c1ac07ab5): the GUI used to loop
|
||||||
|
// `await triggerRenewal(id)` over the selection at
|
||||||
|
// `web/src/pages/CertificatesPage.tsx::handleBulkRenewal`. 100 certs =
|
||||||
|
// 100 sequential HTTP round-trips × Auth → audit → handler → service →
|
||||||
|
// repo → DB → audit. Post-L-1 the GUI POSTs once; the server resolves
|
||||||
|
// the criteria, applies status filters, and enqueues N renewal jobs.
|
||||||
|
//
|
||||||
|
// The "renew all certs of profile X before its CA changes" use case is
|
||||||
|
// the canonical reason to support criteria-mode in addition to explicit
|
||||||
|
// IDs. Mirrors `BulkRevocationCriteria` field-for-field.
|
||||||
|
type BulkRenewalCriteria struct {
|
||||||
|
ProfileID string `json:"profile_id,omitempty"`
|
||||||
|
OwnerID string `json:"owner_id,omitempty"`
|
||||||
|
AgentID string `json:"agent_id,omitempty"`
|
||||||
|
IssuerID string `json:"issuer_id,omitempty"`
|
||||||
|
TeamID string `json:"team_id,omitempty"`
|
||||||
|
CertificateIDs []string `json:"certificate_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true if no filter criteria are set. The service layer
|
||||||
|
// rejects empty criteria with a 400 (mirrors BulkRevocationCriteria.IsEmpty).
|
||||||
|
func (c BulkRenewalCriteria) IsEmpty() bool {
|
||||||
|
return c.ProfileID == "" && c.OwnerID == "" && c.AgentID == "" &&
|
||||||
|
c.IssuerID == "" && c.TeamID == "" && len(c.CertificateIDs) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkRenewalResult is the envelope returned to the caller. Distinct
|
||||||
|
// from BulkRevocationResult because the action verb differs: renewal
|
||||||
|
// ENQUEUES a job per matched cert (asynchronous) rather than performing
|
||||||
|
// the mutation synchronously like revocation. The EnqueuedJobs slice
|
||||||
|
// gives the caller the job IDs so the GUI can poll
|
||||||
|
// /api/v1/jobs?status=Running for progress without re-querying the
|
||||||
|
// certificate list.
|
||||||
|
//
|
||||||
|
// Counters semantics (mirror BulkRevocationResult conventions):
|
||||||
|
// - TotalMatched: number of certs the criteria/IDs resolved to
|
||||||
|
// - TotalEnqueued: number of renewal jobs successfully created
|
||||||
|
// - TotalSkipped: certs in a status that disallows renewal (already
|
||||||
|
// RenewalInProgress, Revoked, or Archived); silent no-op, NOT an error
|
||||||
|
// - TotalFailed: certs where the enqueue path returned an error
|
||||||
|
// - EnqueuedJobs: per-cert {certificate_id, job_id} pairs for the
|
||||||
|
// successful enqueue path (omitempty so an all-skipped batch
|
||||||
|
// produces a clean response)
|
||||||
|
// - Errors: per-cert error details for the failure path
|
||||||
|
type BulkRenewalResult struct {
|
||||||
|
TotalMatched int `json:"total_matched"`
|
||||||
|
TotalEnqueued int `json:"total_enqueued"`
|
||||||
|
TotalSkipped int `json:"total_skipped"`
|
||||||
|
TotalFailed int `json:"total_failed"`
|
||||||
|
EnqueuedJobs []BulkEnqueuedJob `json:"enqueued_jobs,omitempty"`
|
||||||
|
Errors []BulkOperationError `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkEnqueuedJob pairs a certificate ID with the renewal job ID that was
|
||||||
|
// just created for it. Lets the GUI link directly into the job-detail
|
||||||
|
// page without an extra round-trip to query "what job did this cert
|
||||||
|
// just get assigned?".
|
||||||
|
type BulkEnqueuedJob struct {
|
||||||
|
CertificateID string `json:"certificate_id"`
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkOperationError records a per-certificate failure for any bulk
|
||||||
|
// operation (renew, reassign — and revoke, which uses the older
|
||||||
|
// BulkRevocationError shape kept for backwards compatibility on the
|
||||||
|
// /bulk-revoke wire format). Same shape as BulkRevocationError so the
|
||||||
|
// frontend's bulk-result rendering is one helper.
|
||||||
|
type BulkOperationError struct {
|
||||||
|
CertificateID string `json:"certificate_id"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBulkRenewalCriteria_IsEmpty pins the validate-and-reject contract:
|
||||||
|
// empty criteria → service rejects with 400. Mirrors
|
||||||
|
// TestBulkRevocationCriteria_IsEmpty exactly so the cross-bulk-endpoint
|
||||||
|
// behaviour is uniform.
|
||||||
|
func TestBulkRenewalCriteria_IsEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
c BulkRenewalCriteria
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"all-zero", BulkRenewalCriteria{}, true},
|
||||||
|
{"profile-id-set", BulkRenewalCriteria{ProfileID: "cp-x"}, false},
|
||||||
|
{"owner-id-set", BulkRenewalCriteria{OwnerID: "o-alice"}, false},
|
||||||
|
{"agent-id-set", BulkRenewalCriteria{AgentID: "ag-1"}, false},
|
||||||
|
{"issuer-id-set", BulkRenewalCriteria{IssuerID: "iss-x"}, false},
|
||||||
|
{"team-id-set", BulkRenewalCriteria{TeamID: "t-x"}, false},
|
||||||
|
{"ids-set", BulkRenewalCriteria{CertificateIDs: []string{"mc-1"}}, false},
|
||||||
|
{"ids-empty-slice", BulkRenewalCriteria{CertificateIDs: []string{}}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.c.IsEmpty(); got != tt.want {
|
||||||
|
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkRenewalResult_JSONShape pins the wire contract. Operator
|
||||||
|
// tooling (k8s rollouts, blackbox probes, the `certctl-cli bulk-renew`
|
||||||
|
// JSON consumer) parses these field names; renaming any of them is a
|
||||||
|
// breaking change.
|
||||||
|
func TestBulkRenewalResult_JSONShape(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := &BulkRenewalResult{
|
||||||
|
TotalMatched: 5,
|
||||||
|
TotalEnqueued: 4,
|
||||||
|
TotalSkipped: 1,
|
||||||
|
TotalFailed: 0,
|
||||||
|
EnqueuedJobs: []BulkEnqueuedJob{
|
||||||
|
{CertificateID: "mc-1", JobID: "job-a"},
|
||||||
|
},
|
||||||
|
Errors: nil,
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
var round map[string]interface{}
|
||||||
|
if err := json.Unmarshal(b, &round); err != nil {
|
||||||
|
t.Fatalf("Unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range []string{"total_matched", "total_enqueued", "total_skipped", "total_failed", "enqueued_jobs"} {
|
||||||
|
if _, ok := round[k]; !ok {
|
||||||
|
t.Errorf("missing JSON field %q in %s", k, string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// errors omitempty when nil — must NOT appear
|
||||||
|
if _, ok := round["errors"]; ok {
|
||||||
|
t.Errorf("nil Errors should be omitempty; got: %s", string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnqueuedJobs nested shape
|
||||||
|
jobs := round["enqueued_jobs"].([]interface{})
|
||||||
|
if len(jobs) != 1 {
|
||||||
|
t.Fatalf("enqueued_jobs len = %d, want 1", len(jobs))
|
||||||
|
}
|
||||||
|
first := jobs[0].(map[string]interface{})
|
||||||
|
if first["certificate_id"] != "mc-1" || first["job_id"] != "job-a" {
|
||||||
|
t.Errorf("BulkEnqueuedJob field names drifted: %v", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Package domain — error sentinels.
|
||||||
|
//
|
||||||
|
// S-2 closure (cat-s6-efc7f6f6bd50): pre-S-2 every handler-side
|
||||||
|
// validation-failure dispatch was a `strings.Contains(err.Error(),
|
||||||
|
// "invalid")` or `"required"` site, brittle to any domain-layer
|
||||||
|
// message change. Post-S-2 domain validators that surface a
|
||||||
|
// 400 Bad Request wrap their per-field errors via fmt.Errorf("...: %w",
|
||||||
|
// domain.ErrValidation) so handlers can dispatch via errors.Is.
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrValidation is the canonical sentinel for input-validation
|
||||||
|
// failures surfaced by domain-layer Validate() methods. Handlers that
|
||||||
|
// surface a 400 Bad Request should `errors.Is(err, domain.ErrValidation)`.
|
||||||
|
// Per-field error messages are still preserved via fmt.Errorf wrapping
|
||||||
|
// so the response body retains the actionable detail; the sentinel
|
||||||
|
// only drives the HTTP status code dispatch.
|
||||||
|
var ErrValidation = errors.New("domain: validation failed")
|
||||||
@@ -626,7 +626,10 @@ func (m *mockJobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
|||||||
func (m *mockJobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
func (m *mockJobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
||||||
job, ok := m.jobs[id]
|
job, ok := m.jobs[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("job not found")
|
// S-2 closure: wrap repository.ErrNotFound so the handler's
|
||||||
|
// errors.Is dispatch resolves to 404 (matches the Postgres
|
||||||
|
// repo's post-S-2 wrapping).
|
||||||
|
return nil, fmt.Errorf("job not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return job, nil
|
return job, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,61 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
|
|||||||
}
|
}
|
||||||
return textResult(data)
|
return textResult(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// L-1 master closure (cat-l-fa0c1ac07ab5): bulk-renew MCP tool.
|
||||||
|
// Mirrors certctl_bulk_revoke_certificates shape sans the Reason
|
||||||
|
// field. Server returns total_matched / total_enqueued /
|
||||||
|
// total_skipped / total_failed plus per-cert {certificate_id,
|
||||||
|
// job_id} pairs in enqueued_jobs.
|
||||||
|
gomcp.AddTool(s, &gomcp.Tool{
|
||||||
|
Name: "certctl_bulk_renew_certificates",
|
||||||
|
Description: "Bulk renew certificates matching filter criteria (profile_id, owner_id, agent_id, issuer_id, team_id) or an explicit certificate_ids list. At least one selector required. Returns counts of matched, enqueued, skipped, and failed certificates plus per-cert {certificate_id, job_id} pairs.",
|
||||||
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input BulkRenewCertificatesInput) (*gomcp.CallToolResult, any, error) {
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
if input.ProfileID != "" {
|
||||||
|
body["profile_id"] = input.ProfileID
|
||||||
|
}
|
||||||
|
if input.OwnerID != "" {
|
||||||
|
body["owner_id"] = input.OwnerID
|
||||||
|
}
|
||||||
|
if input.AgentID != "" {
|
||||||
|
body["agent_id"] = input.AgentID
|
||||||
|
}
|
||||||
|
if input.IssuerID != "" {
|
||||||
|
body["issuer_id"] = input.IssuerID
|
||||||
|
}
|
||||||
|
if input.TeamID != "" {
|
||||||
|
body["team_id"] = input.TeamID
|
||||||
|
}
|
||||||
|
if len(input.CertificateIDs) > 0 {
|
||||||
|
body["certificate_ids"] = input.CertificateIDs
|
||||||
|
}
|
||||||
|
data, err := c.Post("/api/v1/certificates/bulk-renew", body)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err)
|
||||||
|
}
|
||||||
|
return textResult(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// L-2 closure (cat-l-8a1fb258a38a): bulk-reassign MCP tool.
|
||||||
|
// Narrower than bulk-renew/revoke — IDs-only, no criteria-mode.
|
||||||
|
gomcp.AddTool(s, &gomcp.Tool{
|
||||||
|
Name: "certctl_bulk_reassign_certificates",
|
||||||
|
Description: "Bulk reassign owner (and optionally team) for a set of certificates. owner_id is required. team_id is optional and updates only when non-empty. Returns counts of matched, reassigned, skipped (already-owned-by-target), and failed certificates.",
|
||||||
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input BulkReassignCertificatesInput) (*gomcp.CallToolResult, any, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"certificate_ids": input.CertificateIDs,
|
||||||
|
"owner_id": input.OwnerID,
|
||||||
|
}
|
||||||
|
if input.TeamID != "" {
|
||||||
|
body["team_id"] = input.TeamID
|
||||||
|
}
|
||||||
|
data, err := c.Post("/api/v1/certificates/bulk-reassign", body)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err)
|
||||||
|
}
|
||||||
|
return textResult(data)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CRL & OCSP ──────────────────────────────────────────────────────
|
// ── CRL & OCSP ──────────────────────────────────────────────────────
|
||||||
@@ -1183,4 +1238,36 @@ func registerHealthTools(s *gomcp.Server, c *Client) {
|
|||||||
}
|
}
|
||||||
return textResult(data)
|
return textResult(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// I-2 closure (cat-i-b0924b6675f8): pre-I-2 the README claimed "all
|
||||||
|
// API endpoints are exposed via MCP" but the discovered-certificate
|
||||||
|
// lifecycle (claim + dismiss) was never wrapped — operators using
|
||||||
|
// MCP clients (Claude, Cursor, etc.) had no path to bring an
|
||||||
|
// out-of-band cert under management or to mark a benign discovery
|
||||||
|
// as not-of-interest without dropping to the REST API directly.
|
||||||
|
// These two tools wrap the existing HTTP handlers
|
||||||
|
// (DiscoveryHandler.ClaimDiscovered + DismissDiscovered).
|
||||||
|
|
||||||
|
gomcp.AddTool(s, &gomcp.Tool{
|
||||||
|
Name: "certctl_claim_discovered_certificate",
|
||||||
|
Description: "Link a discovered certificate (dc-*) to an existing managed certificate (mc-*) via POST /api/v1/discovered-certificates/{id}/claim. Use this to bring an out-of-band cert (e.g. one found by an agent filesystem scan or a network scan) under certctl management without re-issuing — the discovered row is marked Managed and its managed_certificate_id is set so subsequent renewals/revocations on the managed cert update both rows.",
|
||||||
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ClaimDiscoveredCertificateInput) (*gomcp.CallToolResult, any, error) {
|
||||||
|
body := map[string]string{"managed_certificate_id": input.ManagedCertificateID}
|
||||||
|
data, err := c.Post("/api/v1/discovered-certificates/"+input.ID+"/claim", body)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err)
|
||||||
|
}
|
||||||
|
return textResult(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
gomcp.AddTool(s, &gomcp.Tool{
|
||||||
|
Name: "certctl_dismiss_discovered_certificate",
|
||||||
|
Description: "Dismiss a discovered certificate (POST /api/v1/discovered-certificates/{id}/dismiss). Use this to mark a discovery as not-of-interest (e.g. expired self-signed test certs found by a network scan) — the row stops appearing in the unmanaged-list view but is preserved in the DB for audit history.",
|
||||||
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input DismissDiscoveredCertificateInput) (*gomcp.CallToolResult, any, error) {
|
||||||
|
data, err := c.Post("/api/v1/discovered-certificates/"+input.ID+"/dismiss", nil)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err)
|
||||||
|
}
|
||||||
|
return textResult(data)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,26 @@ type BulkRevokeCertificatesInput struct {
|
|||||||
CertificateIDs []string `json:"certificate_ids,omitempty" jsonschema:"Explicit list of certificate IDs to revoke"`
|
CertificateIDs []string `json:"certificate_ids,omitempty" jsonschema:"Explicit list of certificate IDs to revoke"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BulkRenewCertificatesInput is the MCP tool input for bulk-renew (L-1
|
||||||
|
// master closure, cat-l-fa0c1ac07ab5). Mirrors BulkRevokeCertificatesInput
|
||||||
|
// field-for-field minus Reason.
|
||||||
|
type BulkRenewCertificatesInput struct {
|
||||||
|
ProfileID string `json:"profile_id,omitempty" jsonschema:"Renew all certs matching this profile ID"`
|
||||||
|
OwnerID string `json:"owner_id,omitempty" jsonschema:"Renew all certs owned by this owner"`
|
||||||
|
AgentID string `json:"agent_id,omitempty" jsonschema:"Renew all certs deployed via this agent"`
|
||||||
|
IssuerID string `json:"issuer_id,omitempty" jsonschema:"Renew all certs issued by this issuer"`
|
||||||
|
TeamID string `json:"team_id,omitempty" jsonschema:"Renew all certs owned by members of this team"`
|
||||||
|
CertificateIDs []string `json:"certificate_ids,omitempty" jsonschema:"Explicit list of certificate IDs to renew"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkReassignCertificatesInput is the MCP tool input for bulk-reassign
|
||||||
|
// (L-2 closure, cat-l-8a1fb258a38a). IDs-only — no criteria-mode.
|
||||||
|
type BulkReassignCertificatesInput struct {
|
||||||
|
CertificateIDs []string `json:"certificate_ids" jsonschema:"Explicit list of certificate IDs to reassign"`
|
||||||
|
OwnerID string `json:"owner_id" jsonschema:"Required. New owner_id for every cert in certificate_ids"`
|
||||||
|
TeamID string `json:"team_id,omitempty" jsonschema:"Optional. When non-empty, also updates team_id on every cert"`
|
||||||
|
}
|
||||||
|
|
||||||
type ListVersionsInput struct {
|
type ListVersionsInput struct {
|
||||||
ID string `json:"id" jsonschema:"Certificate ID"`
|
ID string `json:"id" jsonschema:"Certificate ID"`
|
||||||
ListParams
|
ListParams
|
||||||
@@ -303,6 +323,29 @@ type TimelineInput struct {
|
|||||||
Days int `json:"days,omitempty" jsonschema:"Number of days to look back (default 30, max 365)"`
|
Days int `json:"days,omitempty" jsonschema:"Number of days to look back (default 30, max 365)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Discovered Certificates (I-2 closure) ──────────────────────────
|
||||||
|
|
||||||
|
// ClaimDiscoveredCertificateInput is the MCP tool input for claiming a
|
||||||
|
// discovered certificate (POST /api/v1/discovered-certificates/{id}/claim).
|
||||||
|
// I-2 closure (cat-i-b0924b6675f8). The HTTP handler at
|
||||||
|
// internal/api/handler/discovery.go::ClaimDiscovered links the discovered
|
||||||
|
// row (DC-*) to a managed certificate (mc-*); operators use this to
|
||||||
|
// bring an out-of-band cert under management without re-issuing.
|
||||||
|
type ClaimDiscoveredCertificateInput struct {
|
||||||
|
ID string `json:"id" jsonschema:"Discovered certificate ID (dc-*)"`
|
||||||
|
ManagedCertificateID string `json:"managed_certificate_id" jsonschema:"Existing managed certificate ID (mc-*) to link to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissDiscoveredCertificateInput is the MCP tool input for dismissing
|
||||||
|
// a discovered certificate (POST /api/v1/discovered-certificates/{id}/dismiss).
|
||||||
|
// I-2 closure (cat-i-b0924b6675f8). Marks the row as not-of-interest
|
||||||
|
// (e.g. expired self-signed test certs found by a network scan); the row
|
||||||
|
// stops appearing in the unmanaged-list view but is preserved in the DB
|
||||||
|
// for audit history.
|
||||||
|
type DismissDiscoveredCertificateInput struct {
|
||||||
|
ID string `json:"id" jsonschema:"Discovered certificate ID (dc-*)"`
|
||||||
|
}
|
||||||
|
|
||||||
// ── Empty ───────────────────────────────────────────────────────────
|
// ── Empty ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type EmptyInput struct{}
|
type EmptyInput struct{}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Package repository defines the repository-layer error sentinels that
|
||||||
|
// handlers map to HTTP status codes via errors.Is.
|
||||||
|
//
|
||||||
|
// S-2 closure (cat-s6-efc7f6f6bd50): pre-S-2 every handler-side
|
||||||
|
// not-found dispatch was a `strings.Contains(err.Error(), "not found")`
|
||||||
|
// site (30+ across internal/api/handler/*.go), brittle to any
|
||||||
|
// repository-layer message change and untyped against the actual
|
||||||
|
// failure mode. Post-S-2 the dispatch is type-checked: repositories
|
||||||
|
// wrap sql.ErrNoRows via fmt.Errorf("...: %w", repository.ErrNotFound)
|
||||||
|
// and FK constraint violations via repository.ErrForeignKeyConstraint;
|
||||||
|
// handlers consume via errors.Is. The substring matching is preserved
|
||||||
|
// at the lib/pq boundary inside `errors.go::isFKError` because the
|
||||||
|
// PostgreSQL driver returns un-typed *pq.Error values whose codes are
|
||||||
|
// the canonical signal — but it's confined to one helper rather than
|
||||||
|
// scattered across every handler file. See unified-audit.md
|
||||||
|
// cat-s6-efc7f6f6bd50 for the closure rationale.
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound is the canonical sentinel for repository methods that
|
||||||
|
// return after sql.ErrNoRows (or its wrapped form). Handlers that
|
||||||
|
// surface a 404 should `errors.Is(err, repository.ErrNotFound)`
|
||||||
|
// rather than substring-match.
|
||||||
|
var ErrNotFound = errors.New("repository: row not found")
|
||||||
|
|
||||||
|
// ErrForeignKeyConstraint is the canonical sentinel for PostgreSQL
|
||||||
|
// FK / RESTRICT violations bubbling up from a DELETE or UPDATE.
|
||||||
|
// Handlers that surface a 409 Conflict should
|
||||||
|
// `errors.Is(err, repository.ErrForeignKeyConstraint)`.
|
||||||
|
//
|
||||||
|
// The B-1 closure introduced ErrRenewalPolicyInUse as the per-entity
|
||||||
|
// FK sentinel for renewal_policies; future per-entity FK sentinels
|
||||||
|
// (ErrIssuerInUse, ErrTeamInUse, ErrOwnerInUse) can wrap this generic
|
||||||
|
// one via fmt.Errorf("...: %w", ErrForeignKeyConstraint) so handlers
|
||||||
|
// can choose between generic-409 and entity-specific 409 dispatch.
|
||||||
|
var ErrForeignKeyConstraint = errors.New("repository: foreign key constraint violation")
|
||||||
|
|
||||||
|
// IsForeignKeyError detects PostgreSQL FK violation errors from the
|
||||||
|
// lib/pq driver via the canonical error-text patterns it emits. The
|
||||||
|
// substring matching is intentionally confined to this helper —
|
||||||
|
// callers should use this once at the repo layer to wrap into the
|
||||||
|
// typed ErrForeignKeyConstraint sentinel, then handlers consume via
|
||||||
|
// errors.Is.
|
||||||
|
//
|
||||||
|
// Patterns recognised:
|
||||||
|
// - "violates foreign key constraint" (the standard PG message)
|
||||||
|
// - "violates restrict" / "RESTRICT" (DELETE blocked by ON DELETE RESTRICT)
|
||||||
|
//
|
||||||
|
// Returns false for nil err so callers can defensively chain it.
|
||||||
|
func IsForeignKeyError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "violates foreign key") ||
|
||||||
|
strings.Contains(msg, "RESTRICT") ||
|
||||||
|
strings.Contains(msg, "violates restrict")
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -73,7 +74,7 @@ func (r *AgentRepository) Get(ctx context.Context, id string) (*domain.Agent, er
|
|||||||
agent, err := scanAgent(row)
|
agent, err := scanAgent(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("agent not found")
|
return nil, fmt.Errorf("agent not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to query agent: %w", err)
|
return nil, fmt.Errorf("failed to query agent: %w", err)
|
||||||
}
|
}
|
||||||
@@ -170,7 +171,7 @@ func (r *AgentRepository) Update(ctx context.Context, agent *domain.Agent) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("agent not found")
|
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -190,7 +191,7 @@ func (r *AgentRepository) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("agent not found")
|
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -237,7 +238,7 @@ func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string, metada
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("agent not found")
|
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -259,7 +260,7 @@ func (r *AgentRepository) GetByAPIKey(ctx context.Context, keyHash string) (*dom
|
|||||||
agent, err := scanAgent(row)
|
agent, err := scanAgent(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("agent not found")
|
return nil, fmt.Errorf("agent not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to query agent: %w", err)
|
return nil, fmt.Errorf("failed to query agent: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -50,7 +51,7 @@ func (r *AgentGroupRepository) Get(ctx context.Context, id string) (*domain.Agen
|
|||||||
err := row.Scan(&g.ID, &g.Name, &g.Description, &g.MatchOS, &g.MatchArchitecture,
|
err := row.Scan(&g.ID, &g.Name, &g.Description, &g.MatchOS, &g.MatchArchitecture,
|
||||||
&g.MatchIPCIDR, &g.MatchVersion, &g.Enabled, &g.CreatedAt, &g.UpdatedAt)
|
&g.MatchIPCIDR, &g.MatchVersion, &g.Enabled, &g.CreatedAt, &g.UpdatedAt)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("agent group not found: %s", id)
|
return nil, fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get agent group: %w", err)
|
return nil, fmt.Errorf("failed to get agent group: %w", err)
|
||||||
@@ -84,7 +85,7 @@ func (r *AgentGroupRepository) Update(ctx context.Context, group *domain.AgentGr
|
|||||||
}
|
}
|
||||||
rows, _ := result.RowsAffected()
|
rows, _ := result.RowsAffected()
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("agent group not found: %s", group.ID)
|
return fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,7 @@ func (r *AgentGroupRepository) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
rows, _ := result.RowsAffected()
|
rows, _ := result.RowsAffected()
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("agent group not found: %s", id)
|
return fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ func (r *CertificateRepository) Get(ctx context.Context, id string) (*domain.Man
|
|||||||
cert, err := r.scanCertificate(ctx, row)
|
cert, err := r.scanCertificate(ctx, row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("certificate not found")
|
return nil, fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to query certificate: %w", err)
|
return nil, fmt.Errorf("failed to query certificate: %w", err)
|
||||||
}
|
}
|
||||||
@@ -397,7 +397,7 @@ func (r *CertificateRepository) Update(ctx context.Context, cert *domain.Managed
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("certificate not found")
|
return fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -419,7 +419,7 @@ func (r *CertificateRepository) Archive(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("certificate not found")
|
return fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -131,3 +131,111 @@ func RunMigrations(db *sql.DB, migrationsPath string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunSeed reads and executes the baseline seed SQL file from the migrations
|
||||||
|
// directory. Designed to run AFTER RunMigrations so every column referenced by
|
||||||
|
// the seed is already in place.
|
||||||
|
//
|
||||||
|
// U-3 (P1, cat-u-seed_initdb_schema_drift): pre-U-3 the deploy compose stack
|
||||||
|
// mounted both a hand-curated subset of `migrations/*.up.sql` and `seed.sql`
|
||||||
|
// into postgres `/docker-entrypoint-initdb.d/`. Postgres applied them at
|
||||||
|
// initdb time. When `seed.sql` was updated to reference columns added by
|
||||||
|
// migrations *after* the mounted cutoff (e.g., `policy_rules.severity` from
|
||||||
|
// `000013_policy_rule_severity.up.sql`), initdb crashed during the seed step
|
||||||
|
// and the container was reported `unhealthy` indefinitely — bare
|
||||||
|
// `docker compose -f deploy/docker-compose.yml up -d --build` from a fresh
|
||||||
|
// clone of v2.0.50 hit this on the first try (GitHub #10 reopened by
|
||||||
|
// mikeakasully). Helm and the example compose files were already runtime-
|
||||||
|
// only (Path B) and worked through the same window.
|
||||||
|
//
|
||||||
|
// Post-U-3 the compose stack drops all initdb mounts; postgres comes up with
|
||||||
|
// an empty schema; the server applies all migrations via RunMigrations and
|
||||||
|
// then this function applies the seed. Single source of truth, removes the
|
||||||
|
// drift hazard architecturally.
|
||||||
|
//
|
||||||
|
// The seed file is expected at `<migrationsPath>/seed.sql`. Missing-file is
|
||||||
|
// treated as a no-op (returns nil) so deployments that explicitly remove the
|
||||||
|
// seed (custom packaging, cert-manager managed schemas) don't break.
|
||||||
|
//
|
||||||
|
// Idempotency: every INSERT in the shipped seed.sql uses
|
||||||
|
// `ON CONFLICT (id) DO NOTHING`, so re-running on a populated DB is safe.
|
||||||
|
// This function is invoked on every server start, so the contract MUST hold.
|
||||||
|
//
|
||||||
|
// Demo seed: `seed_demo.sql` is applied separately by RunDemoSeed below
|
||||||
|
// when CERTCTL_DEMO_SEED=true (see internal/config/config.go::DemoSeed).
|
||||||
|
// Splitting demo from baseline keeps a default deploy from accidentally
|
||||||
|
// landing 90-days-of-fake-history into a real customer database, while
|
||||||
|
// still giving the demo overlay a single source of truth (no more initdb
|
||||||
|
// mounts). The demo seed itself uses ON CONFLICT (id) DO NOTHING so it's
|
||||||
|
// idempotent; missing-file is also tolerated (custom packaging may strip
|
||||||
|
// seed_demo.sql to shrink the image).
|
||||||
|
func RunSeed(db *sql.DB, migrationsPath string) error {
|
||||||
|
if _, err := os.Stat(migrationsPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("migrations directory not found: %s", migrationsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
seedPath := filepath.Join(migrationsPath, "seed.sql")
|
||||||
|
content, err := os.ReadFile(seedPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// Missing seed.sql is acceptable — operators may have removed it
|
||||||
|
// for custom-packaging reasons. Return nil rather than fail-loud.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to read seed file %s: %w", seedPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec(string(content)); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute seed file %s: %w", seedPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunDemoSeed applies the demo overlay seed file
|
||||||
|
// (`<migrationsPath>/seed_demo.sql`) on top of the baseline seed.
|
||||||
|
//
|
||||||
|
// U-3 follow-on: pre-U-3 the demo overlay mounted `seed_demo.sql` into
|
||||||
|
// postgres `/docker-entrypoint-initdb.d/` and relied on initdb to apply it
|
||||||
|
// alongside the schema. Once U-3 dropped the initdb migration mounts, that
|
||||||
|
// path stopped working — postgres comes up empty, and the demo seed
|
||||||
|
// references tables (issuers, certificates, etc.) that wouldn't exist yet
|
||||||
|
// at initdb time. RunDemoSeed restores the demo capability through the
|
||||||
|
// same runtime path RunSeed uses, gated by CERTCTL_DEMO_SEED so production
|
||||||
|
// deploys never accidentally land the fake-history rows.
|
||||||
|
//
|
||||||
|
// Order contract: must run AFTER RunSeed so foreign-key references from
|
||||||
|
// demo rows to baseline rows (e.g., demo certificates referencing
|
||||||
|
// `rp-default` from baseline) resolve cleanly. The caller in
|
||||||
|
// cmd/server/main.go enforces this order.
|
||||||
|
//
|
||||||
|
// Missing-file is acceptable (returns nil) — operators packaging a
|
||||||
|
// production-only image often strip seed_demo.sql to shrink the artifact,
|
||||||
|
// and that should not break boot when CERTCTL_DEMO_SEED happens to be set.
|
||||||
|
//
|
||||||
|
// Idempotency: every INSERT in seed_demo.sql uses
|
||||||
|
// `ON CONFLICT (id) DO NOTHING`, so re-running on a populated DB is safe.
|
||||||
|
// Server restarts in demo mode therefore re-apply the file harmlessly.
|
||||||
|
func RunDemoSeed(db *sql.DB, migrationsPath string) error {
|
||||||
|
if _, err := os.Stat(migrationsPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("migrations directory not found: %s", migrationsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
seedPath := filepath.Join(migrationsPath, "seed_demo.sql")
|
||||||
|
content, err := os.ReadFile(seedPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// Custom production packaging frequently strips this file.
|
||||||
|
// Fail-soft to preserve the U-3 contract: a missing seed file
|
||||||
|
// must not gate server boot.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to read demo seed file %s: %w", seedPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec(string(content)); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute demo seed file %s: %w", seedPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func (r *DiscoveryRepository) GetScan(ctx context.Context, id string) (*domain.D
|
|||||||
&scan.ScanDurationMs, &scan.StartedAt, &scan.CompletedAt,
|
&scan.ScanDurationMs, &scan.StartedAt, &scan.CompletedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("discovery scan not found: %s", id)
|
return nil, fmt.Errorf("discovery scan not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get discovery scan: %w", err)
|
return nil, fmt.Errorf("failed to get discovery scan: %w", err)
|
||||||
@@ -190,7 +190,7 @@ func (r *DiscoveryRepository) GetDiscovered(ctx context.Context, id string) (*do
|
|||||||
&cert.CreatedAt, &cert.UpdatedAt,
|
&cert.CreatedAt, &cert.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("discovered certificate not found: %s", id)
|
return nil, fmt.Errorf("discovered certificate not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get discovered certificate: %w", err)
|
return nil, fmt.Errorf("failed to get discovered certificate: %w", err)
|
||||||
@@ -317,7 +317,7 @@ func (r *DiscoveryRepository) UpdateDiscoveredStatus(ctx context.Context, id str
|
|||||||
}
|
}
|
||||||
rowsAffected, _ := result.RowsAffected()
|
rowsAffected, _ := result.RowsAffected()
|
||||||
if rowsAffected == 0 {
|
if rowsAffected == 0 {
|
||||||
return fmt.Errorf("discovered certificate not found: %s", id)
|
return fmt.Errorf("discovered certificate not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func (r *HealthCheckRepository) Get(ctx context.Context, id string) (*domain.End
|
|||||||
&check.CreatedAt, &check.UpdatedAt,
|
&check.CreatedAt, &check.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("health check not found: %s", id)
|
return nil, fmt.Errorf("health check not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get health check: %w", err)
|
return nil, fmt.Errorf("get health check: %w", err)
|
||||||
@@ -299,7 +299,7 @@ func (r *HealthCheckRepository) GetByEndpoint(ctx context.Context, endpoint stri
|
|||||||
&check.CreatedAt, &check.UpdatedAt,
|
&check.CreatedAt, &check.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("health check not found for endpoint: %s", endpoint)
|
return nil, fmt.Errorf("health check not found for endpoint: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get health check by endpoint: %w", err)
|
return nil, fmt.Errorf("get health check by endpoint: %w", err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -69,7 +70,7 @@ func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer,
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("issuer not found")
|
return nil, fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to query issuer: %w", err)
|
return nil, fmt.Errorf("failed to query issuer: %w", err)
|
||||||
}
|
}
|
||||||
@@ -169,7 +170,7 @@ func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("issuer not found")
|
return fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -189,7 +190,7 @@ func (r *IssuerRepository) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("issuer not found")
|
return fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -62,7 +63,7 @@ func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error)
|
|||||||
job, err := scanJob(row)
|
job, err := scanJob(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("job not found")
|
return nil, fmt.Errorf("job not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to query job: %w", err)
|
return nil, fmt.Errorf("failed to query job: %w", err)
|
||||||
}
|
}
|
||||||
@@ -123,7 +124,7 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("job not found")
|
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -143,7 +144,7 @@ func (r *JobRepository) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("job not found")
|
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -232,7 +233,7 @@ func (r *JobRepository) UpdateStatus(ctx context.Context, id string, status doma
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("job not found")
|
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -68,7 +69,7 @@ func (r *NetworkScanRepository) Get(ctx context.Context, id string) (*domain.Net
|
|||||||
&target.CreatedAt, &target.UpdatedAt,
|
&target.CreatedAt, &target.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("network scan target not found: %s", id)
|
return nil, fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get network scan target: %w", err)
|
return nil, fmt.Errorf("get network scan target: %w", err)
|
||||||
@@ -117,7 +118,7 @@ func (r *NetworkScanRepository) Update(ctx context.Context, target *domain.Netwo
|
|||||||
}
|
}
|
||||||
rows, _ := result.RowsAffected()
|
rows, _ := result.RowsAffected()
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("network scan target not found: %s", target.ID)
|
return fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -130,7 +131,7 @@ func (r *NetworkScanRepository) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
rows, _ := result.RowsAffected()
|
rows, _ := result.RowsAffected()
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("network scan target not found: %s", id)
|
return fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,19 +22,37 @@ func NewNotificationRepository(db *sql.DB) *NotificationRepository {
|
|||||||
return &NotificationRepository{db: db}
|
return &NotificationRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create stores a new notification
|
// Create stores a new notification.
|
||||||
|
//
|
||||||
|
// U-3 ride-along (cat-o-notification_created_at_dead_field, P2): the
|
||||||
|
// `created_at` column is added to notification_events by migration 000017.
|
||||||
|
// Pre-U-3 the Go domain.NotificationEvent had a CreatedAt field but the
|
||||||
|
// INSERT path never set it AND no DB column existed — the JSON API
|
||||||
|
// serialised the field as `0001-01-01T00:00:00Z`, breaking timestamp
|
||||||
|
// ordering on operator dashboards and any consumer that filtered by age.
|
||||||
|
// Post-U-3 the column exists with a NOT NULL DEFAULT NOW() backstop, and
|
||||||
|
// this INSERT explicitly sets it from the domain field. If the caller
|
||||||
|
// hasn't populated CreatedAt (zero-value time.Time) we substitute
|
||||||
|
// time.Now() so the row never carries the placeholder zero-time forward
|
||||||
|
// — the DEFAULT would handle this too, but emitting the value explicitly
|
||||||
|
// keeps the wire-level JSON consistent with what the row will hold once
|
||||||
|
// scanNotification reads it back, and prevents a clock-skew gap between
|
||||||
|
// "Go computed CreatedAt" and "DB applied DEFAULT NOW()" on the read path.
|
||||||
func (r *NotificationRepository) Create(ctx context.Context, notif *domain.NotificationEvent) error {
|
func (r *NotificationRepository) Create(ctx context.Context, notif *domain.NotificationEvent) error {
|
||||||
if notif.ID == "" {
|
if notif.ID == "" {
|
||||||
notif.ID = uuid.New().String()
|
notif.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
if notif.CreatedAt.IsZero() {
|
||||||
|
notif.CreatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO notification_events (
|
INSERT INTO notification_events (
|
||||||
id, type, certificate_id, channel, recipient, message, sent_at, status, error
|
id, type, certificate_id, channel, recipient, message, sent_at, status, error, created_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, notif.ID, notif.Type, notif.CertificateID, notif.Channel, notif.Recipient,
|
`, notif.ID, notif.Type, notif.CertificateID, notif.Channel, notif.Recipient,
|
||||||
notif.Message, notif.SentAt, notif.Status, notif.Error).Scan(¬if.ID)
|
notif.Message, notif.SentAt, notif.Status, notif.Error, notif.CreatedAt).Scan(¬if.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create notification: %w", err)
|
return fmt.Errorf("failed to create notification: %w", err)
|
||||||
@@ -102,12 +120,14 @@ func (r *NotificationRepository) List(ctx context.Context, filter *repository.No
|
|||||||
|
|
||||||
// Get paginated results. I-005 extends the SELECT with the three retry
|
// Get paginated results. I-005 extends the SELECT with the three retry
|
||||||
// columns (retry_count / next_retry_at / last_error) so scanNotification
|
// columns (retry_count / next_retry_at / last_error) so scanNotification
|
||||||
// can populate the new fields on domain.NotificationEvent. The column
|
// can populate the new fields on domain.NotificationEvent. U-3 extends
|
||||||
// order here MUST stay in lockstep with scanNotification below.
|
// it once more with `created_at` (column added by migration 000017) so
|
||||||
|
// the field is no longer serialized as 0001-01-01. The column order
|
||||||
|
// here MUST stay in lockstep with scanNotification below.
|
||||||
offset := (filter.Page - 1) * filter.PerPage
|
offset := (filter.Page - 1) * filter.PerPage
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error,
|
SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error,
|
||||||
retry_count, next_retry_at, last_error
|
retry_count, next_retry_at, last_error, created_at
|
||||||
FROM notification_events
|
FROM notification_events
|
||||||
%s
|
%s
|
||||||
ORDER BY sent_at DESC NULLS LAST
|
ORDER BY sent_at DESC NULLS LAST
|
||||||
@@ -154,7 +174,7 @@ func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, st
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("notification not found")
|
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -162,8 +182,14 @@ func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, st
|
|||||||
|
|
||||||
// scanNotification scans a notification from a row or rows.
|
// scanNotification scans a notification from a row or rows.
|
||||||
//
|
//
|
||||||
// I-005 extends the scan list from 9 → 12 columns (adds retry_count,
|
// I-005 extended the scan list from 9 → 12 columns (adds retry_count,
|
||||||
// next_retry_at, last_error). Every caller — List and the four new retry
|
// next_retry_at, last_error). U-3 extends it once more to 13 columns by
|
||||||
|
// appending `created_at` (column added by migration 000017,
|
||||||
|
// cat-o-notification_created_at_dead_field). CreatedAt scans into a
|
||||||
|
// non-pointer time.Time because the migration declares the column
|
||||||
|
// NOT NULL with DEFAULT NOW().
|
||||||
|
//
|
||||||
|
// Every caller — List, ListRetryEligible, and the four other I-005 retry
|
||||||
// methods below — funnels rows through this helper, so the SELECT column
|
// methods below — funnels rows through this helper, so the SELECT column
|
||||||
// order in every query must match the Scan order here exactly. RetryCount
|
// order in every query must match the Scan order here exactly. RetryCount
|
||||||
// scans into an `int` (migration 000016 declares the column NOT NULL with
|
// scans into an `int` (migration 000016 declares the column NOT NULL with
|
||||||
@@ -176,7 +202,7 @@ func scanNotification(scanner interface {
|
|||||||
var notif domain.NotificationEvent
|
var notif domain.NotificationEvent
|
||||||
err := scanner.Scan(¬if.ID, ¬if.Type, ¬if.CertificateID, ¬if.Channel,
|
err := scanner.Scan(¬if.ID, ¬if.Type, ¬if.CertificateID, ¬if.Channel,
|
||||||
¬if.Recipient, ¬if.Message, ¬if.SentAt, ¬if.Status, ¬if.Error,
|
¬if.Recipient, ¬if.Message, ¬if.SentAt, ¬if.Status, ¬if.Error,
|
||||||
¬if.RetryCount, ¬if.NextRetryAt, ¬if.LastError)
|
¬if.RetryCount, ¬if.NextRetryAt, ¬if.LastError, ¬if.CreatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan notification: %w", err)
|
return nil, fmt.Errorf("failed to scan notification: %w", err)
|
||||||
@@ -248,7 +274,7 @@ func (r *NotificationRepository) ListRetryEligible(ctx context.Context, now time
|
|||||||
|
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error,
|
SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error,
|
||||||
retry_count, next_retry_at, last_error
|
retry_count, next_retry_at, last_error, created_at
|
||||||
FROM notification_events
|
FROM notification_events
|
||||||
WHERE status = 'failed'
|
WHERE status = 'failed'
|
||||||
AND next_retry_at IS NOT NULL
|
AND next_retry_at IS NOT NULL
|
||||||
@@ -310,7 +336,7 @@ func (r *NotificationRepository) RecordFailedAttempt(ctx context.Context, id str
|
|||||||
// Same "not found" error shape as UpdateStatus above. The scheduler
|
// Same "not found" error shape as UpdateStatus above. The scheduler
|
||||||
// logs-and-continues on this so a concurrently-deleted row doesn't
|
// logs-and-continues on this so a concurrently-deleted row doesn't
|
||||||
// break the sweep.
|
// break the sweep.
|
||||||
return fmt.Errorf("notification not found")
|
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -342,7 +368,7 @@ func (r *NotificationRepository) MarkAsDead(ctx context.Context, id string, last
|
|||||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||||
}
|
}
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("notification not found")
|
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -379,7 +405,7 @@ func (r *NotificationRepository) Requeue(ctx context.Context, id string) error {
|
|||||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||||
}
|
}
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("notification not found")
|
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,6 +339,95 @@ func TestNotificationRepository_Requeue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNotificationRepository_CreatedAt_IsPersisted is the U-3 ride-along
|
||||||
|
// regression for cat-o-notification_created_at_dead_field. Pre-U-3 the
|
||||||
|
// Go domain.NotificationEvent had a CreatedAt field but the DB had no
|
||||||
|
// column — JSON serialisation produced 0001-01-01T00:00:00Z, breaking
|
||||||
|
// timestamp ordering on operator dashboards. Post-U-3 migration 000017
|
||||||
|
// adds the column NOT NULL DEFAULT NOW(), Create populates it, and
|
||||||
|
// scanNotification reads it back.
|
||||||
|
//
|
||||||
|
// The contract under test is round-trip equivalence: the timestamp the
|
||||||
|
// caller sets goes into the DB and comes back out unchanged (modulo
|
||||||
|
// PostgreSQL's microsecond precision). Truncate to microseconds before
|
||||||
|
// comparing because TIMESTAMPTZ rounds nanoseconds away.
|
||||||
|
func TestNotificationRepository_CreatedAt_IsPersisted(t *testing.T) {
|
||||||
|
tdb := getTestDB(t)
|
||||||
|
db := tdb.freshSchema(t)
|
||||||
|
repo := postgres.NewNotificationRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// A specific, recognisable timestamp. Truncated to microseconds so
|
||||||
|
// the post-roundtrip equality assertion isn't tripped up by Postgres
|
||||||
|
// dropping the nanosecond tail.
|
||||||
|
want := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Microsecond)
|
||||||
|
|
||||||
|
notif := &domain.NotificationEvent{
|
||||||
|
Type: domain.NotificationTypeExpirationWarning,
|
||||||
|
Channel: domain.NotificationChannelWebhook,
|
||||||
|
Recipient: "https://hooks.example.com/u3",
|
||||||
|
Message: "U-3 round-trip witness",
|
||||||
|
Status: string(domain.NotificationStatusPending),
|
||||||
|
CreatedAt: want,
|
||||||
|
}
|
||||||
|
if err := repo.Create(ctx, notif); err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read via List (which goes through scanNotification) so we're
|
||||||
|
// testing both the INSERT and SELECT halves of the U-3 plumbing.
|
||||||
|
got, err := repo.List(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("List returned %d rows, want 1", len(got))
|
||||||
|
}
|
||||||
|
if !got[0].CreatedAt.Equal(want) {
|
||||||
|
t.Errorf("CreatedAt round-trip mismatch:\n set: %v\n got: %v\n"+
|
||||||
|
"Pre-U-3 this would have come back as 0001-01-01 because the column didn't exist.",
|
||||||
|
want, got[0].CreatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNotificationRepository_CreatedAt_DefaultsToNow verifies the helper
|
||||||
|
// behavior in Create: when the caller hands over an event with the
|
||||||
|
// zero-value CreatedAt, Create substitutes time.Now() rather than
|
||||||
|
// trusting the DB DEFAULT. This keeps wire-level JSON consistent with
|
||||||
|
// what the row will hold once it's read back, and avoids a clock-skew
|
||||||
|
// gap between "Go computed the timestamp" and "DB applied DEFAULT NOW()".
|
||||||
|
func TestNotificationRepository_CreatedAt_DefaultsToNow(t *testing.T) {
|
||||||
|
tdb := getTestDB(t)
|
||||||
|
db := tdb.freshSchema(t)
|
||||||
|
repo := postgres.NewNotificationRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
before := time.Now().UTC().Add(-time.Second)
|
||||||
|
|
||||||
|
notif := &domain.NotificationEvent{
|
||||||
|
Type: domain.NotificationTypeExpirationWarning,
|
||||||
|
Channel: domain.NotificationChannelWebhook,
|
||||||
|
Recipient: "https://hooks.example.com/zerotime",
|
||||||
|
Message: "U-3 zero-time fallback",
|
||||||
|
Status: string(domain.NotificationStatusPending),
|
||||||
|
// CreatedAt left zero on purpose — the contract is that Create
|
||||||
|
// fills it in from time.Now() when it's unset.
|
||||||
|
}
|
||||||
|
if err := repo.Create(ctx, notif); err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := time.Now().UTC().Add(time.Second)
|
||||||
|
|
||||||
|
if notif.CreatedAt.IsZero() {
|
||||||
|
t.Fatalf("CreatedAt is still zero after Create — the fallback in NotificationRepository.Create did not fire")
|
||||||
|
}
|
||||||
|
if notif.CreatedAt.Before(before) || notif.CreatedAt.After(after) {
|
||||||
|
t.Errorf("CreatedAt = %v is outside the [%v, %v] window — the substituted time.Now() should fall inside the test's wall-clock bracket",
|
||||||
|
notif.CreatedAt, before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// past returns a stable "5 minutes ago" time for fixture seeding. Truncated
|
// past returns a stable "5 minutes ago" time for fixture seeding. Truncated
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -61,7 +62,7 @@ func (r *OwnerRepository) Get(ctx context.Context, id string) (*domain.Owner, er
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("owner not found")
|
return nil, fmt.Errorf("owner not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to query owner: %w", err)
|
return nil, fmt.Errorf("failed to query owner: %w", err)
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@ func (r *OwnerRepository) Update(ctx context.Context, owner *domain.Owner) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("owner not found")
|
return fmt.Errorf("owner not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -130,7 +131,7 @@ func (r *OwnerRepository) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("owner not found")
|
return fmt.Errorf("owner not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (r *PolicyRepository) GetRule(ctx context.Context, id string) (*domain.Poli
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("policy rule not found")
|
return nil, fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to query policy rule: %w", err)
|
return nil, fmt.Errorf("failed to query policy rule: %w", err)
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ func (r *PolicyRepository) UpdateRule(ctx context.Context, rule *domain.PolicyRu
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("policy rule not found")
|
return fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -134,7 +134,7 @@ func (r *PolicyRepository) DeleteRule(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("policy rule not found")
|
return fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -64,7 +65,7 @@ func (r *ProfileRepository) Get(ctx context.Context, id string) (*domain.Certifi
|
|||||||
p, err := scanProfile(row)
|
p, err := scanProfile(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("profile not found")
|
return nil, fmt.Errorf("profile not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to query profile: %w", err)
|
return nil, fmt.Errorf("failed to query profile: %w", err)
|
||||||
}
|
}
|
||||||
@@ -159,7 +160,7 @@ func (r *ProfileRepository) Update(ctx context.Context, profile *domain.Certific
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("profile not found")
|
return fmt.Errorf("profile not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -178,7 +179,7 @@ func (r *ProfileRepository) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("profile not found")
|
return fmt.Errorf("profile not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func NewRenewalPolicyRepository(db *sql.DB) *RenewalPolicyRepository {
|
|||||||
// and require domain-layer churn we're not taking on in this change.
|
// and require domain-layer churn we're not taking on in this change.
|
||||||
const renewalPolicyColumns = `
|
const renewalPolicyColumns = `
|
||||||
id, name, renewal_window_days, auto_renew, max_retries,
|
id, name, renewal_window_days, auto_renew, max_retries,
|
||||||
retry_interval_minutes, alert_thresholds_days, created_at, updated_at
|
retry_interval_seconds, alert_thresholds_days, created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// scanRenewalPolicy decodes one renewal_policies row from a Row or Rows
|
// scanRenewalPolicy decodes one renewal_policies row from a Row or Rows
|
||||||
@@ -72,7 +72,7 @@ func (r *RenewalPolicyRepository) Get(ctx context.Context, id string) (*domain.R
|
|||||||
policy, err := scanRenewalPolicy(row)
|
policy, err := scanRenewalPolicy(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, fmt.Errorf("renewal policy not found: %s", id)
|
return nil, fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to query renewal policy: %w", err)
|
return nil, fmt.Errorf("failed to query renewal policy: %w", err)
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@ func (r *RenewalPolicyRepository) Create(ctx context.Context, policy *domain.Ren
|
|||||||
insertSQL := `
|
insertSQL := `
|
||||||
INSERT INTO renewal_policies (
|
INSERT INTO renewal_policies (
|
||||||
id, name, renewal_window_days, auto_renew, max_retries,
|
id, name, renewal_window_days, auto_renew, max_retries,
|
||||||
retry_interval_minutes, alert_thresholds_days, created_at, updated_at
|
retry_interval_seconds, alert_thresholds_days, created_at, updated_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||||
RETURNING ` + renewalPolicyColumns
|
RETURNING ` + renewalPolicyColumns
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ func (r *RenewalPolicyRepository) Update(ctx context.Context, id string, policy
|
|||||||
renewal_window_days = $3,
|
renewal_window_days = $3,
|
||||||
auto_renew = $4,
|
auto_renew = $4,
|
||||||
max_retries = $5,
|
max_retries = $5,
|
||||||
retry_interval_minutes = $6,
|
retry_interval_seconds = $6,
|
||||||
alert_thresholds_days = $7,
|
alert_thresholds_days = $7,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@@ -252,7 +252,7 @@ func (r *RenewalPolicyRepository) Update(ctx context.Context, id string, policy
|
|||||||
updated, err := scanRenewalPolicy(row)
|
updated, err := scanRenewalPolicy(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return fmt.Errorf("renewal policy not found: %s", id)
|
return fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
if isUniqueViolation(err) {
|
if isUniqueViolation(err) {
|
||||||
return repository.ErrRenewalPolicyDuplicateName
|
return repository.ErrRenewalPolicyDuplicateName
|
||||||
@@ -283,7 +283,7 @@ func (r *RenewalPolicyRepository) Delete(ctx context.Context, id string) error {
|
|||||||
return fmt.Errorf("failed to read RowsAffected for delete: %w", err)
|
return fmt.Errorf("failed to read RowsAffected for delete: %w", err)
|
||||||
}
|
}
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("renewal policy not found: %s", id)
|
return fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestRenewalPolicyRepository_CRUD(t *testing.T) {
|
|||||||
RenewalWindowDays: 30,
|
RenewalWindowDays: 30,
|
||||||
AutoRenew: true,
|
AutoRenew: true,
|
||||||
MaxRetries: 5,
|
MaxRetries: 5,
|
||||||
RetryInterval: 3600, // stored in retry_interval_minutes column; passthrough
|
RetryInterval: 3600, // stored as seconds in retry_interval_seconds column (renamed in 000017_db_coupling_cleanup, U-3)
|
||||||
AlertThresholdsDays: []int{30, 14, 7, 0},
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func insertCertPrereqsRaw(t *testing.T, db *sql.DB, ctx context.Context, suffix
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create renewal policy
|
// Create renewal policy
|
||||||
_, err = db.ExecContext(ctx, `INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_minutes, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
_, err = db.ExecContext(ctx, `INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_seconds, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
policyID, "Policy "+suffix, 30, true, 3, 60, now, now)
|
policyID, "Policy "+suffix, 30, true, 3, 60, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("insertCertPrereqs: create renewal_policy failed: %v", err)
|
t.Fatalf("insertCertPrereqs: create renewal_policy failed: %v", err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -136,7 +137,7 @@ func (r *RevocationRepository) MarkIssuerNotified(ctx context.Context, id string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("revocation not found")
|
return fmt.Errorf("revocation not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// Integration tests for the U-3 schema-vs-seed coupling fix.
|
||||||
|
//
|
||||||
|
// Pre-U-3 the deploy compose stack mounted both a hand-curated subset of
|
||||||
|
// `migrations/*.up.sql` and `seed.sql` into postgres
|
||||||
|
// `/docker-entrypoint-initdb.d/`. Postgres applied them at initdb time.
|
||||||
|
// When `seed.sql` was updated to reference columns added by migrations
|
||||||
|
// *after* the mounted cutoff (e.g., `policy_rules.severity` from
|
||||||
|
// `000013_policy_rule_severity.up.sql`), initdb crashed during the seed
|
||||||
|
// step and the container was reported `unhealthy` indefinitely.
|
||||||
|
//
|
||||||
|
// Post-U-3 the schema is built EXCLUSIVELY by the server at startup via
|
||||||
|
// internal/repository/postgres.RunMigrations + RunSeed. These tests pin
|
||||||
|
// that contract: RunSeed must complete without error against a freshly
|
||||||
|
// migrated database, and re-application must be idempotent so server
|
||||||
|
// restarts don't double-insert.
|
||||||
|
//
|
||||||
|
// Skipped under -short to keep CI fast lanes green; the integration lane
|
||||||
|
// runs them via the testcontainers harness.
|
||||||
|
package postgres_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/repository/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunSeed_AppliesIdempotently verifies the U-3 contract that RunSeed
|
||||||
|
// can be called repeatedly against a populated database without error and
|
||||||
|
// without producing duplicate rows. The server invokes RunSeed on EVERY
|
||||||
|
// boot (it has no migration-state table to skip from), so any non-
|
||||||
|
// idempotent INSERT in seed.sql would crash the container loop on the
|
||||||
|
// second start.
|
||||||
|
//
|
||||||
|
// The assertion uses renewal_policies.id='rp-default' as a witness — that
|
||||||
|
// row is the most-referenced FK target in the seed (it's the default
|
||||||
|
// renewal policy attached to every certificate that doesn't override).
|
||||||
|
// If the seed double-inserted, we'd see SQLSTATE 23505 from the second
|
||||||
|
// RunSeed call. If the seed silently ON CONFLICT-DO-NOTHING'd as
|
||||||
|
// designed, the row count stays at exactly 1.
|
||||||
|
func TestRunSeed_AppliesIdempotently(t *testing.T) {
|
||||||
|
tdb := getTestDB(t)
|
||||||
|
db := tdb.freshSchema(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrationsPath := findMigrationsDir()
|
||||||
|
|
||||||
|
// Apply the seed twice — second call simulates a server restart on a
|
||||||
|
// populated database. Both must succeed; pre-U-3 the second call
|
||||||
|
// would fail with 23505 if any INSERT lacked ON CONFLICT.
|
||||||
|
if err := postgres.RunSeed(db, migrationsPath); err != nil {
|
||||||
|
t.Fatalf("RunSeed (first call) returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := postgres.RunSeed(db, migrationsPath); err != nil {
|
||||||
|
t.Fatalf("RunSeed (second call — idempotency check) returned error: %v\n"+
|
||||||
|
"This means the seed produced a duplicate row; every INSERT in seed.sql "+
|
||||||
|
"must use ON CONFLICT (id) DO NOTHING because the server applies the "+
|
||||||
|
"seed on EVERY start.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Witness check: rp-default is the renewal policy every cert defaults
|
||||||
|
// to. Exactly one row must exist after two seed applications.
|
||||||
|
var count int
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM renewal_policies WHERE id = 'rp-default'`,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("witness query failed: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("renewal_policies WHERE id='rp-default' returned %d rows after two RunSeed calls; want exactly 1 (ON CONFLICT idempotency contract)", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunSeed_MissingFileIsNoOp verifies the fail-soft contract documented
|
||||||
|
// on RunSeed: an operator who deletes seed.sql for custom packaging (CI
|
||||||
|
// pipelines that bake their own seeds, cert-manager managed deployments)
|
||||||
|
// must still get a healthy server boot. RunSeed returning nil for a
|
||||||
|
// missing file is the only way to hold this contract — returning an error
|
||||||
|
// would force every minimal-image deployment to ship the seed file just
|
||||||
|
// to satisfy a no-op load.
|
||||||
|
//
|
||||||
|
// We point at a directory that exists (empty temp dir) but contains no
|
||||||
|
// seed.sql. RunSeed must return nil silently.
|
||||||
|
func TestRunSeed_MissingFileIsNoOp(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a brand-new empty directory so seed.sql is unambiguously absent.
|
||||||
|
emptyDir := t.TempDir()
|
||||||
|
|
||||||
|
// Pass a nil *sql.DB on purpose — RunSeed must short-circuit on the
|
||||||
|
// missing file BEFORE touching the DB. If the implementation ever
|
||||||
|
// regresses and tries to db.Exec(string(content)) with nil content,
|
||||||
|
// this will surface as a nil-deref instead of a silent corruption.
|
||||||
|
var db *sql.DB
|
||||||
|
if err := postgres.RunSeed(db, emptyDir); err != nil {
|
||||||
|
t.Fatalf("RunSeed against an empty directory should return nil; got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunDemoSeed_AppliesIdempotently mirrors the RunSeed idempotency
|
||||||
|
// contract for the demo overlay. The compose demo stack
|
||||||
|
// (deploy/docker-compose.demo.yml) sets CERTCTL_DEMO_SEED=true; the
|
||||||
|
// server applies seed_demo.sql at every boot. Same constraint as the
|
||||||
|
// baseline seed: if any INSERT lacks ON CONFLICT, the server will
|
||||||
|
// crash-loop on restart.
|
||||||
|
//
|
||||||
|
// Witness: seed_demo.sql inserts t-platform into the teams table at line
|
||||||
|
// 11. That row is referenced by every demo-team-owned certificate, so
|
||||||
|
// duplicate-insertion would block the entire demo on restart.
|
||||||
|
func TestRunDemoSeed_AppliesIdempotently(t *testing.T) {
|
||||||
|
tdb := getTestDB(t)
|
||||||
|
db := tdb.freshSchema(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrationsPath := findMigrationsDir()
|
||||||
|
|
||||||
|
// Order matters — RunSeed must run first so the FK targets the demo
|
||||||
|
// seed depends on (rp-* renewal policies, etc.) exist before the
|
||||||
|
// demo INSERTs run. This mirrors the order in cmd/server/main.go.
|
||||||
|
if err := postgres.RunSeed(db, migrationsPath); err != nil {
|
||||||
|
t.Fatalf("RunSeed prerequisite failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := postgres.RunDemoSeed(db, migrationsPath); err != nil {
|
||||||
|
t.Fatalf("RunDemoSeed (first call) returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := postgres.RunDemoSeed(db, migrationsPath); err != nil {
|
||||||
|
t.Fatalf("RunDemoSeed (second call — idempotency check) returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM teams WHERE id = 't-platform'`,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("witness query failed: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("teams WHERE id='t-platform' returned %d rows after two RunDemoSeed calls; want exactly 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigration000017_RetryIntervalRename verifies the U-3 ride-along
|
||||||
|
// column rename: renewal_policies.retry_interval_minutes →
|
||||||
|
// retry_interval_seconds (cat-o-retry_interval_unit_mismatch). The unit
|
||||||
|
// was always seconds in practice — the column name lied. Migration 000017
|
||||||
|
// renames the column with a DO $$ guard so re-application is safe.
|
||||||
|
//
|
||||||
|
// After all migrations have been applied (which the test harness does in
|
||||||
|
// freshSchema), the new column must exist and the old column must NOT.
|
||||||
|
// information_schema.columns is the source of truth for both checks.
|
||||||
|
func TestMigration000017_RetryIntervalRename(t *testing.T) {
|
||||||
|
tdb := getTestDB(t)
|
||||||
|
db := tdb.freshSchema(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Helper — true iff the named column exists on renewal_policies.
|
||||||
|
hasColumn := func(name string) bool {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_name = 'renewal_policies' AND column_name = $1
|
||||||
|
`, name).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("information_schema query for column %q failed: %v", name, err)
|
||||||
|
}
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasColumn("retry_interval_seconds") {
|
||||||
|
t.Error("renewal_policies.retry_interval_seconds is missing — migration 000017 did not apply, or it was applied before the rename block")
|
||||||
|
}
|
||||||
|
if hasColumn("retry_interval_minutes") {
|
||||||
|
t.Error("renewal_policies.retry_interval_minutes still exists — the rename in migration 000017 must drop the old name (cat-o-retry_interval_unit_mismatch)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigration000017_NotificationCreatedAt verifies the U-3 ride-along
|
||||||
|
// column add: notification_events.created_at NOT NULL DEFAULT NOW()
|
||||||
|
// (cat-o-notification_created_at_dead_field). Pre-U-3 the Go domain had
|
||||||
|
// the field but the DB lacked the column, so the JSON API serialised
|
||||||
|
// 0001-01-01.
|
||||||
|
func TestMigration000017_NotificationCreatedAt(t *testing.T) {
|
||||||
|
tdb := getTestDB(t)
|
||||||
|
db := tdb.freshSchema(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var dataType, isNullable, columnDefault sql.NullString
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT data_type, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'notification_events' AND column_name = 'created_at'
|
||||||
|
`).Scan(&dataType, &isNullable, &columnDefault)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("information_schema query for created_at failed: %v\n"+
|
||||||
|
"Migration 000017 should have added notification_events.created_at TIMESTAMPTZ NOT NULL DEFAULT NOW().", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dataType.String != "timestamp with time zone" {
|
||||||
|
t.Errorf("notification_events.created_at data_type = %q, want %q",
|
||||||
|
dataType.String, "timestamp with time zone")
|
||||||
|
}
|
||||||
|
if isNullable.String != "NO" {
|
||||||
|
t.Errorf("notification_events.created_at is_nullable = %q, want NO (the column must be NOT NULL so legacy rows get the DEFAULT)",
|
||||||
|
isNullable.String)
|
||||||
|
}
|
||||||
|
if columnDefault.String == "" {
|
||||||
|
t.Error("notification_events.created_at has no DEFAULT — legacy rows added before migration 000017 would fail the NOT NULL gate without one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigration000017_HealthCheckOrphansDropped verifies the U-3
|
||||||
|
// ride-along column drop: network_scan_targets lost the orphan
|
||||||
|
// health_check_enabled / health_check_interval_seconds columns
|
||||||
|
// (cat-o-health_check_column_orphans). These were declared by an early
|
||||||
|
// migration but never wired into Go code — schema noise that confused
|
||||||
|
// operators reading raw SQL. Migration 000017 drops them.
|
||||||
|
func TestMigration000017_HealthCheckOrphansDropped(t *testing.T) {
|
||||||
|
tdb := getTestDB(t)
|
||||||
|
db := tdb.freshSchema(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
hasColumn := func(name string) bool {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_name = 'network_scan_targets' AND column_name = $1
|
||||||
|
`, name).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("information_schema query for column %q failed: %v", name, err)
|
||||||
|
}
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, col := range []string{"health_check_enabled", "health_check_interval_seconds"} {
|
||||||
|
if hasColumn(col) {
|
||||||
|
t.Errorf("network_scan_targets.%s still exists — migration 000017 must drop it (cat-o-health_check_column_orphans)", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -89,7 +90,7 @@ func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.Deployme
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("target not found")
|
return nil, fmt.Errorf("target not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to query target: %w", err)
|
return nil, fmt.Errorf("failed to query target: %w", err)
|
||||||
}
|
}
|
||||||
@@ -174,7 +175,7 @@ func (r *TargetRepository) Update(ctx context.Context, target *domain.Deployment
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("target not found")
|
return fmt.Errorf("target not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -194,7 +195,7 @@ func (r *TargetRepository) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("target not found")
|
return fmt.Errorf("target not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -61,7 +62,7 @@ func (r *TeamRepository) Get(ctx context.Context, id string) (*domain.Team, erro
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("team not found")
|
return nil, fmt.Errorf("team not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to query team: %w", err)
|
return nil, fmt.Errorf("failed to query team: %w", err)
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,7 @@ func (r *TeamRepository) Update(ctx context.Context, team *domain.Team) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("team not found")
|
return fmt.Errorf("team not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -128,7 +129,7 @@ func (r *TeamRepository) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("team not found")
|
return fmt.Errorf("team not found: %w", repository.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrBulkReassignOwnerNotFound is the typed sentinel for a non-existent
|
||||||
|
// target OwnerID. The handler maps it to 400 (bad input — the operator
|
||||||
|
// picked an owner that doesn't exist) rather than 500 (server error).
|
||||||
|
// Sentinel-error rather than substring-error matches the project's
|
||||||
|
// post-M-1 error-mapping convention.
|
||||||
|
var ErrBulkReassignOwnerNotFound = errors.New("owner not found")
|
||||||
|
|
||||||
|
// BulkReassignmentService coordinates bulk owner-reassignment of
|
||||||
|
// certificates.
|
||||||
|
//
|
||||||
|
// L-2 closure (cat-l-8a1fb258a38a): the GUI used to loop
|
||||||
|
// `await updateCertificate(id, { owner_id })` over the selection at
|
||||||
|
// `web/src/pages/CertificatesPage.tsx::handleReassign`. Post-L-2 the
|
||||||
|
// GUI POSTs once. Narrower than BulkRenewal: explicit IDs only, no
|
||||||
|
// criteria-mode (criteria-mode reassignment doesn't have a strong use
|
||||||
|
// case — operators query first then reassign by ID).
|
||||||
|
//
|
||||||
|
// Validation order: empty IDs → 400, missing OwnerID → 400, OwnerID
|
||||||
|
// not in owners table → 400 (ErrBulkReassignOwnerNotFound). Resolving
|
||||||
|
// the owner upfront means we fail-fast without mutating any cert if
|
||||||
|
// the operator typo'd the owner ID.
|
||||||
|
type BulkReassignmentService struct {
|
||||||
|
certRepo repository.CertificateRepository
|
||||||
|
ownerRepo repository.OwnerRepository
|
||||||
|
auditService *AuditService
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBulkReassignmentService creates a new BulkReassignmentService.
|
||||||
|
func NewBulkReassignmentService(
|
||||||
|
certRepo repository.CertificateRepository,
|
||||||
|
ownerRepo repository.OwnerRepository,
|
||||||
|
auditService *AuditService,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) *BulkReassignmentService {
|
||||||
|
return &BulkReassignmentService{
|
||||||
|
certRepo: certRepo,
|
||||||
|
ownerRepo: ownerRepo,
|
||||||
|
auditService: auditService,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkReassign updates owner_id (and optionally team_id) on every cert
|
||||||
|
// in request.CertificateIDs. Skips certs whose owner_id already equals
|
||||||
|
// the target (silent no-op — surfaced as TotalSkipped++, not as a fake
|
||||||
|
// "succeeded" count, so operators see "5 of your 10 selections were
|
||||||
|
// no-ops because Alice already owned them" without triaging fake
|
||||||
|
// errors).
|
||||||
|
//
|
||||||
|
// Partial failures don't abort the batch — the failing cert lands in
|
||||||
|
// Errors[]; the loop continues. Mirrors BulkRevocationService and
|
||||||
|
// BulkRenewalService partial-failure semantics.
|
||||||
|
//
|
||||||
|
// Audit: a single audit event is emitted at the end with the criteria
|
||||||
|
// + counts. NOT N events.
|
||||||
|
func (s *BulkReassignmentService) BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
|
||||||
|
if request.IsEmpty() {
|
||||||
|
return nil, fmt.Errorf("at least one certificate_id is required")
|
||||||
|
}
|
||||||
|
if request.OwnerID == "" {
|
||||||
|
return nil, fmt.Errorf("owner_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the target owner exists BEFORE touching any cert. This
|
||||||
|
// fail-fast pattern means an operator who typo'd 'o-alic' (missing
|
||||||
|
// 'e') doesn't half-reassign 50 certs before the 51st surfaces the
|
||||||
|
// FK violation.
|
||||||
|
if _, err := s.ownerRepo.Get(ctx, request.OwnerID); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrBulkReassignOwnerNotFound, request.OwnerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &domain.BulkReassignmentResult{}
|
||||||
|
|
||||||
|
for _, id := range request.CertificateIDs {
|
||||||
|
cert, err := s.certRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
result.TotalFailed++
|
||||||
|
result.Errors = append(result.Errors, domain.BulkOperationError{
|
||||||
|
CertificateID: id,
|
||||||
|
Error: fmt.Sprintf("failed to fetch certificate: %v", err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.TotalMatched++
|
||||||
|
|
||||||
|
// No-op skip: cert already owned by the target. team_id may
|
||||||
|
// still differ — we still skip if owner matches AND
|
||||||
|
// team_id-update is a no-op (team unchanged or team_id field
|
||||||
|
// not set on the request). This prevents fake "reassigned"
|
||||||
|
// counts when nothing actually changed.
|
||||||
|
ownerUnchanged := cert.OwnerID == request.OwnerID
|
||||||
|
teamUnchanged := request.TeamID == "" || cert.TeamID == request.TeamID
|
||||||
|
if ownerUnchanged && teamUnchanged {
|
||||||
|
result.TotalSkipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cert.OwnerID = request.OwnerID
|
||||||
|
if request.TeamID != "" {
|
||||||
|
cert.TeamID = request.TeamID
|
||||||
|
}
|
||||||
|
if err := s.certRepo.Update(ctx, cert); err != nil {
|
||||||
|
result.TotalFailed++
|
||||||
|
result.Errors = append(result.Errors, domain.BulkOperationError{
|
||||||
|
CertificateID: id,
|
||||||
|
Error: fmt.Sprintf("failed to update certificate: %v", err),
|
||||||
|
})
|
||||||
|
s.logger.Warn("bulk reassignment: update failed",
|
||||||
|
"certificate_id", id, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.TotalReassigned++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single bulk audit event at the end.
|
||||||
|
auditDetails := map[string]interface{}{
|
||||||
|
"owner_id": request.OwnerID,
|
||||||
|
"certificate_ids": strings.Join(request.CertificateIDs, ","),
|
||||||
|
"total_matched": result.TotalMatched,
|
||||||
|
"total_reassigned": result.TotalReassigned,
|
||||||
|
"total_skipped": result.TotalSkipped,
|
||||||
|
"total_failed": result.TotalFailed,
|
||||||
|
}
|
||||||
|
if request.TeamID != "" {
|
||||||
|
auditDetails["team_id"] = request.TeamID
|
||||||
|
}
|
||||||
|
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||||
|
"bulk_reassignment_initiated", "certificate", "bulk",
|
||||||
|
auditDetails); err != nil {
|
||||||
|
s.logger.Error("failed to record bulk reassignment audit event", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBulkReassignmentTestService() (*BulkReassignmentService, *mockCertRepo, *mockOwnerRepo, *mockAuditRepo) {
|
||||||
|
certRepo := newMockCertificateRepository()
|
||||||
|
ownerRepo := newMockOwnerRepository()
|
||||||
|
auditRepo := newMockAuditRepository()
|
||||||
|
auditService := NewAuditService(auditRepo)
|
||||||
|
svc := NewBulkReassignmentService(certRepo, ownerRepo, auditService, slog.Default())
|
||||||
|
return svc, certRepo, ownerRepo, auditRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
// addOwnedCert seeds a cert with a specific owner+team for reassignment.
|
||||||
|
func addOwnedCert(repo *mockCertRepo, id, ownerID, teamID string) {
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: id, CommonName: id, Status: domain.CertificateStatusActive,
|
||||||
|
OwnerID: ownerID, TeamID: teamID,
|
||||||
|
ExpiresAt: time.Now().AddDate(0, 1, 0),
|
||||||
|
}
|
||||||
|
repo.AddCert(cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addOwner(repo *mockOwnerRepo, id string) {
|
||||||
|
repo.owners[id] = &domain.Owner{ID: id, Name: id}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkReassign_HappyPath — N certs all reassigned successfully.
|
||||||
|
func TestBulkReassign_HappyPath(t *testing.T) {
|
||||||
|
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
|
||||||
|
addOwner(ownerRepo, "o-bob")
|
||||||
|
addOwnedCert(certRepo, "mc-1", "o-alice", "")
|
||||||
|
addOwnedCert(certRepo, "mc-2", "o-alice", "")
|
||||||
|
addOwnedCert(certRepo, "mc-3", "o-alice", "")
|
||||||
|
|
||||||
|
res, err := svc.BulkReassign(context.Background(),
|
||||||
|
domain.BulkReassignmentRequest{
|
||||||
|
CertificateIDs: []string{"mc-1", "mc-2", "mc-3"},
|
||||||
|
OwnerID: "o-bob",
|
||||||
|
}, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkReassign failed: %v", err)
|
||||||
|
}
|
||||||
|
if res.TotalReassigned != 3 || res.TotalSkipped != 0 || res.TotalFailed != 0 {
|
||||||
|
t.Errorf("counts = reassigned:%d skipped:%d failed:%d, want 3/0/0",
|
||||||
|
res.TotalReassigned, res.TotalSkipped, res.TotalFailed)
|
||||||
|
}
|
||||||
|
for _, id := range []string{"mc-1", "mc-2", "mc-3"} {
|
||||||
|
if certRepo.Certs[id].OwnerID != "o-bob" {
|
||||||
|
t.Errorf("cert %s: owner_id = %s, want o-bob", id, certRepo.Certs[id].OwnerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkReassign_SkipsAlreadyOwned — certs already owned by the
|
||||||
|
// target are no-op-skipped (not counted as reassigned, not surfaced as
|
||||||
|
// errors). Operator sees "5 of your 10 selections were no-ops because
|
||||||
|
// Bob already owned them" without triaging fake errors.
|
||||||
|
func TestBulkReassign_SkipsAlreadyOwned(t *testing.T) {
|
||||||
|
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
|
||||||
|
addOwner(ownerRepo, "o-bob")
|
||||||
|
addOwnedCert(certRepo, "mc-1", "o-bob", "") // already owned by target
|
||||||
|
addOwnedCert(certRepo, "mc-2", "o-alice", "") // needs reassign
|
||||||
|
|
||||||
|
res, err := svc.BulkReassign(context.Background(),
|
||||||
|
domain.BulkReassignmentRequest{
|
||||||
|
CertificateIDs: []string{"mc-1", "mc-2"},
|
||||||
|
OwnerID: "o-bob",
|
||||||
|
}, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkReassign failed: %v", err)
|
||||||
|
}
|
||||||
|
if res.TotalReassigned != 1 || res.TotalSkipped != 1 {
|
||||||
|
t.Errorf("counts = reassigned:%d skipped:%d, want 1/1", res.TotalReassigned, res.TotalSkipped)
|
||||||
|
}
|
||||||
|
if len(res.Errors) != 0 {
|
||||||
|
t.Errorf("already-owned skip should NOT populate Errors; got %v", res.Errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkReassign_OwnerIDRequired_Error — empty owner_id rejected.
|
||||||
|
func TestBulkReassign_OwnerIDRequired_Error(t *testing.T) {
|
||||||
|
svc, certRepo, _, _ := newBulkReassignmentTestService()
|
||||||
|
addOwnedCert(certRepo, "mc-1", "o-alice", "")
|
||||||
|
_, err := svc.BulkReassign(context.Background(),
|
||||||
|
domain.BulkReassignmentRequest{CertificateIDs: []string{"mc-1"}, OwnerID: ""}, "admin")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty owner_id, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkReassign_EmptyIDs_Error — empty IDs rejected.
|
||||||
|
func TestBulkReassign_EmptyIDs_Error(t *testing.T) {
|
||||||
|
svc, _, ownerRepo, _ := newBulkReassignmentTestService()
|
||||||
|
addOwner(ownerRepo, "o-bob")
|
||||||
|
_, err := svc.BulkReassign(context.Background(),
|
||||||
|
domain.BulkReassignmentRequest{CertificateIDs: []string{}, OwnerID: "o-bob"}, "admin")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty IDs, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkReassign_OwnerNotFound_TypedSentinel — non-existent OwnerID
|
||||||
|
// returns ErrBulkReassignOwnerNotFound. Handler maps this to 400 (the
|
||||||
|
// operator picked an owner that doesn't exist) rather than 500 (server
|
||||||
|
// error). Sentinel-error rather than substring-error matches the
|
||||||
|
// project's post-M-1 error-mapping convention.
|
||||||
|
func TestBulkReassign_OwnerNotFound_TypedSentinel(t *testing.T) {
|
||||||
|
svc, certRepo, _, _ := newBulkReassignmentTestService()
|
||||||
|
addOwnedCert(certRepo, "mc-1", "o-alice", "")
|
||||||
|
_, err := svc.BulkReassign(context.Background(),
|
||||||
|
domain.BulkReassignmentRequest{CertificateIDs: []string{"mc-1"}, OwnerID: "o-ghost"}, "admin")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected ErrBulkReassignOwnerNotFound, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrBulkReassignOwnerNotFound) {
|
||||||
|
t.Errorf("err is not ErrBulkReassignOwnerNotFound; got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkReassign_TeamIDOptional — happy path WITHOUT team_id leaves
|
||||||
|
// team_id unchanged. Empty team_id in request must not zero out the
|
||||||
|
// existing team_id on the cert.
|
||||||
|
func TestBulkReassign_TeamIDOptional(t *testing.T) {
|
||||||
|
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
|
||||||
|
addOwner(ownerRepo, "o-bob")
|
||||||
|
addOwnedCert(certRepo, "mc-1", "o-alice", "t-platform")
|
||||||
|
|
||||||
|
_, err := svc.BulkReassign(context.Background(),
|
||||||
|
domain.BulkReassignmentRequest{
|
||||||
|
CertificateIDs: []string{"mc-1"},
|
||||||
|
OwnerID: "o-bob",
|
||||||
|
// TeamID intentionally omitted
|
||||||
|
}, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkReassign failed: %v", err)
|
||||||
|
}
|
||||||
|
if certRepo.Certs["mc-1"].TeamID != "t-platform" {
|
||||||
|
t.Errorf("team_id was zeroed out; want unchanged 't-platform', got %q", certRepo.Certs["mc-1"].TeamID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkReassign_TeamIDProvided_Updates — when TeamID is non-empty in
|
||||||
|
// the request, both owner_id and team_id update.
|
||||||
|
func TestBulkReassign_TeamIDProvided_Updates(t *testing.T) {
|
||||||
|
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
|
||||||
|
addOwner(ownerRepo, "o-bob")
|
||||||
|
addOwnedCert(certRepo, "mc-1", "o-alice", "t-platform")
|
||||||
|
|
||||||
|
_, err := svc.BulkReassign(context.Background(),
|
||||||
|
domain.BulkReassignmentRequest{
|
||||||
|
CertificateIDs: []string{"mc-1"},
|
||||||
|
OwnerID: "o-bob",
|
||||||
|
TeamID: "t-security",
|
||||||
|
}, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkReassign failed: %v", err)
|
||||||
|
}
|
||||||
|
if certRepo.Certs["mc-1"].TeamID != "t-security" {
|
||||||
|
t.Errorf("team_id = %q, want t-security", certRepo.Certs["mc-1"].TeamID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkReassign_PartialFailure — N=3, one cert mid-batch hits an
|
||||||
|
// Update error. Rest of the batch continues; failure surfaced in
|
||||||
|
// Errors.
|
||||||
|
func TestBulkReassign_PartialFailure(t *testing.T) {
|
||||||
|
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
|
||||||
|
addOwner(ownerRepo, "o-bob")
|
||||||
|
addOwnedCert(certRepo, "mc-1", "o-alice", "")
|
||||||
|
addOwnedCert(certRepo, "mc-2", "o-alice", "")
|
||||||
|
addOwnedCert(certRepo, "mc-3", "o-alice", "")
|
||||||
|
|
||||||
|
// Force the next Update to fail uniformly. Mirrors how
|
||||||
|
// TestBulkRevoke_PartialFailure injects a downstream failure.
|
||||||
|
certRepo.UpdateErr = errors.New("simulated DB outage")
|
||||||
|
|
||||||
|
res, err := svc.BulkReassign(context.Background(),
|
||||||
|
domain.BulkReassignmentRequest{
|
||||||
|
CertificateIDs: []string{"mc-1", "mc-2", "mc-3"},
|
||||||
|
OwnerID: "o-bob",
|
||||||
|
}, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkReassign should not propagate per-cert errors; got: %v", err)
|
||||||
|
}
|
||||||
|
if res.TotalFailed != 3 || res.TotalReassigned != 0 {
|
||||||
|
t.Errorf("counts = failed:%d reassigned:%d, want 3/0", res.TotalFailed, res.TotalReassigned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkReassign_AuditEventEmitted — single bulk audit event.
|
||||||
|
func TestBulkReassign_AuditEventEmitted(t *testing.T) {
|
||||||
|
svc, certRepo, ownerRepo, auditRepo := newBulkReassignmentTestService()
|
||||||
|
addOwner(ownerRepo, "o-bob")
|
||||||
|
addOwnedCert(certRepo, "mc-1", "o-alice", "")
|
||||||
|
addOwnedCert(certRepo, "mc-2", "o-alice", "")
|
||||||
|
|
||||||
|
_, err := svc.BulkReassign(context.Background(),
|
||||||
|
domain.BulkReassignmentRequest{
|
||||||
|
CertificateIDs: []string{"mc-1", "mc-2"},
|
||||||
|
OwnerID: "o-bob",
|
||||||
|
}, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkReassign failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(auditRepo.Events) != 1 {
|
||||||
|
t.Errorf("audit events count = %d, want exactly 1 (one bulk event, NOT N per-cert events)", len(auditRepo.Events))
|
||||||
|
}
|
||||||
|
if len(auditRepo.Events) > 0 && auditRepo.Events[0].Action != "bulk_reassignment_initiated" {
|
||||||
|
t.Errorf("audit action = %q, want 'bulk_reassignment_initiated'", auditRepo.Events[0].Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BulkRenewalService coordinates bulk certificate renewal operations.
|
||||||
|
// Mirrors BulkRevocationService in shape: resolve criteria → status filter →
|
||||||
|
// per-cert action loop → aggregate result + emit one bulk audit event.
|
||||||
|
//
|
||||||
|
// L-1 master closure (cat-l-fa0c1ac07ab5): the GUI used to loop
|
||||||
|
// `await triggerRenewal(id)` over the selection at
|
||||||
|
// `web/src/pages/CertificatesPage.tsx::handleBulkRenewal` (~line 411).
|
||||||
|
// 100 certs = 100 sequential HTTP round-trips. Post-L-1 the GUI POSTs
|
||||||
|
// once; this service does the loop server-side and returns a single
|
||||||
|
// envelope with per-cert {certificate_id, job_id} pairs in
|
||||||
|
// EnqueuedJobs and per-cert errors in Errors.
|
||||||
|
//
|
||||||
|
// Action verb is sync-enqueue (not sync-issue): for each matched cert
|
||||||
|
// flip status to RenewalInProgress and create a Job row. The
|
||||||
|
// scheduler's job processor picks up the jobs asynchronously. Sync-
|
||||||
|
// issue would block the HTTP request for minutes against a slow ACME
|
||||||
|
// issuer, which defeats the bulk-endpoint latency improvement.
|
||||||
|
type BulkRenewalService struct {
|
||||||
|
certRepo repository.CertificateRepository
|
||||||
|
jobRepo repository.JobRepository
|
||||||
|
auditService *AuditService
|
||||||
|
logger *slog.Logger
|
||||||
|
keygenMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBulkRenewalService creates a new BulkRenewalService.
|
||||||
|
//
|
||||||
|
// keygenMode mirrors CertificateService.keygenMode — agent-mode jobs
|
||||||
|
// start as AwaitingCSR (the agent generates the key + submits a CSR);
|
||||||
|
// server-mode jobs start as Pending. The bulk path must produce jobs in
|
||||||
|
// the SAME initial status the single-cert path does, otherwise the
|
||||||
|
// scheduler routes them differently.
|
||||||
|
func NewBulkRenewalService(
|
||||||
|
certRepo repository.CertificateRepository,
|
||||||
|
jobRepo repository.JobRepository,
|
||||||
|
auditService *AuditService,
|
||||||
|
logger *slog.Logger,
|
||||||
|
keygenMode string,
|
||||||
|
) *BulkRenewalService {
|
||||||
|
return &BulkRenewalService{
|
||||||
|
certRepo: certRepo,
|
||||||
|
jobRepo: jobRepo,
|
||||||
|
auditService: auditService,
|
||||||
|
logger: logger,
|
||||||
|
keygenMode: keygenMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkRenew enqueues a renewal job for every certificate matching the
|
||||||
|
// criteria (or in the explicit IDs list). Status filter:
|
||||||
|
// - Archived / Expired / Revoked → silent skip (TotalSkipped++)
|
||||||
|
// - RenewalInProgress → silent skip (avoid double-enqueue)
|
||||||
|
// - everything else → flip to RenewalInProgress + create job
|
||||||
|
//
|
||||||
|
// Partial failures don't abort the batch — the failing cert lands in
|
||||||
|
// Errors[] with the error string, and the loop continues. Mirrors
|
||||||
|
// BulkRevocationService.BulkRevoke's partial-failure semantics.
|
||||||
|
//
|
||||||
|
// Audit: a single audit event is emitted at the end with the criteria
|
||||||
|
// + counts (NOT N events). The single-cert TriggerRenewal path emits
|
||||||
|
// per-cert audit events; the bulk path uses one bulk envelope to keep
|
||||||
|
// audit_events from growing 100x for one operator click.
|
||||||
|
func (s *BulkRenewalService) BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
|
||||||
|
if criteria.IsEmpty() {
|
||||||
|
return nil, fmt.Errorf("at least one filter criterion is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
certs, err := s.resolveCertificates(ctx, criteria)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve certificates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &domain.BulkRenewalResult{
|
||||||
|
TotalMatched: len(certs),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cert := range certs {
|
||||||
|
// Status-filter the cert before mutating. Mirrors the
|
||||||
|
// eligibility checks in CertificateService.TriggerRenewal so a
|
||||||
|
// bulk caller can't bypass them. Each illegal status maps to a
|
||||||
|
// silent TotalSkipped++ rather than an Error so the operator
|
||||||
|
// sees "5 of your 10 selections were no-ops" without triaging
|
||||||
|
// fake errors.
|
||||||
|
if cert.Status == domain.CertificateStatusArchived ||
|
||||||
|
cert.Status == domain.CertificateStatusRevoked ||
|
||||||
|
cert.Status == domain.CertificateStatusExpired ||
|
||||||
|
cert.Status == domain.CertificateStatusRenewalInProgress {
|
||||||
|
result.TotalSkipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip status + create job. Bug-for-bug match with
|
||||||
|
// CertificateService.TriggerRenewal so the scheduler routing
|
||||||
|
// stays identical between the single-cert and bulk paths.
|
||||||
|
cert.Status = domain.CertificateStatusRenewalInProgress
|
||||||
|
if err := s.certRepo.Update(ctx, cert); err != nil {
|
||||||
|
result.TotalFailed++
|
||||||
|
result.Errors = append(result.Errors, domain.BulkOperationError{
|
||||||
|
CertificateID: cert.ID,
|
||||||
|
Error: fmt.Sprintf("failed to update certificate status: %v", err),
|
||||||
|
})
|
||||||
|
s.logger.Warn("bulk renewal: status update failed",
|
||||||
|
"certificate_id", cert.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jobStatus := domain.JobStatusPending
|
||||||
|
if s.keygenMode == "agent" {
|
||||||
|
jobStatus = domain.JobStatusAwaitingCSR
|
||||||
|
}
|
||||||
|
jobType := domain.JobTypeRenewal
|
||||||
|
if cert.ExpiresAt.IsZero() || cert.ExpiresAt.Year() < 2000 {
|
||||||
|
jobType = domain.JobTypeIssuance
|
||||||
|
}
|
||||||
|
job := &domain.Job{
|
||||||
|
ID: generateID("job"),
|
||||||
|
CertificateID: cert.ID,
|
||||||
|
Type: jobType,
|
||||||
|
Status: jobStatus,
|
||||||
|
MaxAttempts: 3,
|
||||||
|
ScheduledAt: time.Now(),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := s.jobRepo.Create(ctx, job); err != nil {
|
||||||
|
result.TotalFailed++
|
||||||
|
result.Errors = append(result.Errors, domain.BulkOperationError{
|
||||||
|
CertificateID: cert.ID,
|
||||||
|
Error: fmt.Sprintf("failed to create renewal job: %v", err),
|
||||||
|
})
|
||||||
|
s.logger.Warn("bulk renewal: job creation failed",
|
||||||
|
"certificate_id", cert.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TotalEnqueued++
|
||||||
|
result.EnqueuedJobs = append(result.EnqueuedJobs, domain.BulkEnqueuedJob{
|
||||||
|
CertificateID: cert.ID,
|
||||||
|
JobID: job.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single bulk audit event at the end. Mirrors
|
||||||
|
// BulkRevocationService.BulkRevoke shape so the audit dashboard's
|
||||||
|
// rendering of bulk events is uniform across {revoke, renew, reassign}.
|
||||||
|
criteriaDetails := s.buildAuditDetails(criteria)
|
||||||
|
criteriaDetails["total_matched"] = result.TotalMatched
|
||||||
|
criteriaDetails["total_enqueued"] = result.TotalEnqueued
|
||||||
|
criteriaDetails["total_skipped"] = result.TotalSkipped
|
||||||
|
criteriaDetails["total_failed"] = result.TotalFailed
|
||||||
|
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||||
|
"bulk_renewal_initiated", "certificate", "bulk",
|
||||||
|
criteriaDetails); err != nil {
|
||||||
|
s.logger.Error("failed to record bulk renewal audit event", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCertificates fetches the set of certificates matching the bulk
|
||||||
|
// renewal criteria. Mirrors BulkRevocationService.resolveCertificates
|
||||||
|
// behaviour exactly: explicit IDs alone → fetch each by ID; filter
|
||||||
|
// criteria → repo.List with high per_page; both → intersect.
|
||||||
|
func (s *BulkRenewalService) resolveCertificates(ctx context.Context, criteria domain.BulkRenewalCriteria) ([]*domain.ManagedCertificate, error) {
|
||||||
|
hasFilterCriteria := criteria.ProfileID != "" || criteria.OwnerID != "" ||
|
||||||
|
criteria.AgentID != "" || criteria.IssuerID != "" || criteria.TeamID != ""
|
||||||
|
hasExplicitIDs := len(criteria.CertificateIDs) > 0
|
||||||
|
|
||||||
|
if hasExplicitIDs && !hasFilterCriteria {
|
||||||
|
var certs []*domain.ManagedCertificate
|
||||||
|
for _, id := range criteria.CertificateIDs {
|
||||||
|
cert, err := s.certRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
continue // not-found certs silently drop out of the matched set
|
||||||
|
}
|
||||||
|
certs = append(certs, cert)
|
||||||
|
}
|
||||||
|
return certs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := &repository.CertificateFilter{
|
||||||
|
OwnerID: criteria.OwnerID,
|
||||||
|
TeamID: criteria.TeamID,
|
||||||
|
IssuerID: criteria.IssuerID,
|
||||||
|
AgentID: criteria.AgentID,
|
||||||
|
ProfileID: criteria.ProfileID,
|
||||||
|
PerPage: 10000,
|
||||||
|
}
|
||||||
|
certs, _, err := s.certRepo.List(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if hasExplicitIDs {
|
||||||
|
idSet := make(map[string]bool, len(criteria.CertificateIDs))
|
||||||
|
for _, id := range criteria.CertificateIDs {
|
||||||
|
idSet[id] = true
|
||||||
|
}
|
||||||
|
var filtered []*domain.ManagedCertificate
|
||||||
|
for _, cert := range certs {
|
||||||
|
if idSet[cert.ID] {
|
||||||
|
filtered = append(filtered, cert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
return certs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAuditDetails constructs a map of criteria fields for the audit
|
||||||
|
// event. Mirrors BulkRevocationService.buildAuditDetails so the audit
|
||||||
|
// dashboard renders bulk events uniformly.
|
||||||
|
func (s *BulkRenewalService) buildAuditDetails(criteria domain.BulkRenewalCriteria) map[string]interface{} {
|
||||||
|
details := map[string]interface{}{}
|
||||||
|
if criteria.ProfileID != "" {
|
||||||
|
details["profile_id"] = criteria.ProfileID
|
||||||
|
}
|
||||||
|
if criteria.OwnerID != "" {
|
||||||
|
details["owner_id"] = criteria.OwnerID
|
||||||
|
}
|
||||||
|
if criteria.AgentID != "" {
|
||||||
|
details["agent_id"] = criteria.AgentID
|
||||||
|
}
|
||||||
|
if criteria.IssuerID != "" {
|
||||||
|
details["issuer_id"] = criteria.IssuerID
|
||||||
|
}
|
||||||
|
if criteria.TeamID != "" {
|
||||||
|
details["team_id"] = criteria.TeamID
|
||||||
|
}
|
||||||
|
if len(criteria.CertificateIDs) > 0 {
|
||||||
|
details["certificate_ids"] = strings.Join(criteria.CertificateIDs, ",")
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newBulkRenewalTestService spins up a BulkRenewalService wired against
|
||||||
|
// the in-memory mocks used by every other service test in this package.
|
||||||
|
// keygenMode defaults to "agent" — production-like routing where renewal
|
||||||
|
// jobs start as AwaitingCSR.
|
||||||
|
func newBulkRenewalTestService() (*BulkRenewalService, *mockCertRepo, *mockJobRepo, *mockAuditRepo) {
|
||||||
|
certRepo := newMockCertificateRepository()
|
||||||
|
jobRepo := &mockJobRepo{Jobs: map[string]*domain.Job{}}
|
||||||
|
auditRepo := newMockAuditRepository()
|
||||||
|
auditService := NewAuditService(auditRepo)
|
||||||
|
svc := NewBulkRenewalService(certRepo, jobRepo, auditService, slog.Default(), "agent")
|
||||||
|
return svc, certRepo, jobRepo, auditRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
// addRenewableCert seeds a cert that is eligible for renewal (Active
|
||||||
|
// status, future expiry).
|
||||||
|
func addRenewableCert(repo *mockCertRepo, id string) {
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: id,
|
||||||
|
CommonName: id + ".example.com",
|
||||||
|
Status: domain.CertificateStatusActive,
|
||||||
|
ExpiresAt: time.Now().AddDate(0, 1, 0),
|
||||||
|
IssuerID: "iss-test",
|
||||||
|
}
|
||||||
|
repo.AddCert(cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkRenew_ByExplicitIDs — happy path. N IDs in, N jobs enqueued,
|
||||||
|
// EnqueuedJobs slice carries the {certificate_id, job_id} pairs.
|
||||||
|
func TestBulkRenew_ByExplicitIDs(t *testing.T) {
|
||||||
|
svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
|
||||||
|
addRenewableCert(certRepo, "mc-1")
|
||||||
|
addRenewableCert(certRepo, "mc-2")
|
||||||
|
addRenewableCert(certRepo, "mc-3")
|
||||||
|
|
||||||
|
res, err := svc.BulkRenew(context.Background(),
|
||||||
|
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
|
||||||
|
"alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkRenew failed: %v", err)
|
||||||
|
}
|
||||||
|
if res.TotalMatched != 3 || res.TotalEnqueued != 3 || res.TotalSkipped != 0 || res.TotalFailed != 0 {
|
||||||
|
t.Errorf("counts = matched:%d enqueued:%d skipped:%d failed:%d, want 3/3/0/0",
|
||||||
|
res.TotalMatched, res.TotalEnqueued, res.TotalSkipped, res.TotalFailed)
|
||||||
|
}
|
||||||
|
if len(res.EnqueuedJobs) != 3 {
|
||||||
|
t.Fatalf("EnqueuedJobs len = %d, want 3", len(res.EnqueuedJobs))
|
||||||
|
}
|
||||||
|
if len(jobRepo.Jobs) != 3 {
|
||||||
|
t.Errorf("jobRepo got %d jobs, want 3 (one per renewable cert)", len(jobRepo.Jobs))
|
||||||
|
}
|
||||||
|
for _, j := range res.EnqueuedJobs {
|
||||||
|
if j.JobID == "" {
|
||||||
|
t.Errorf("EnqueuedJob missing job_id for cert %s", j.CertificateID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkRenew_ByOwnerCriteria — criteria-mode resolution. The
|
||||||
|
// criteria-routing path must call resolveCertificates with the filter
|
||||||
|
// branch (not the explicit-IDs branch). Mocking convention in this
|
||||||
|
// package: mockCertRepo.List ignores the filter and returns all certs,
|
||||||
|
// so the test seeds only certs that should match (mirrors
|
||||||
|
// TestBulkRevoke_ByOwner shape in bulk_revocation_test.go).
|
||||||
|
func TestBulkRenew_ByOwnerCriteria(t *testing.T) {
|
||||||
|
svc, certRepo, _, _ := newBulkRenewalTestService()
|
||||||
|
for _, id := range []string{"mc-a1", "mc-a2"} {
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: id, CommonName: id, Status: domain.CertificateStatusActive,
|
||||||
|
OwnerID: "o-alice", ExpiresAt: time.Now().AddDate(0, 1, 0),
|
||||||
|
}
|
||||||
|
certRepo.AddCert(cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := svc.BulkRenew(context.Background(),
|
||||||
|
domain.BulkRenewalCriteria{OwnerID: "o-alice"}, "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkRenew failed: %v", err)
|
||||||
|
}
|
||||||
|
if res.TotalEnqueued != 2 {
|
||||||
|
t.Errorf("TotalEnqueued = %d, want 2 (alice's 2 certs)", res.TotalEnqueued)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkRenew_SkipsRenewalInProgress — a cert already in the renewal
|
||||||
|
// flow must NOT get a second job. This is the no-double-enqueue
|
||||||
|
// contract: dispatch the bulk-renew button twice in quick succession
|
||||||
|
// and the second call cleanly skips.
|
||||||
|
func TestBulkRenew_SkipsRenewalInProgress(t *testing.T) {
|
||||||
|
svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: "mc-rip", Status: domain.CertificateStatusRenewalInProgress,
|
||||||
|
ExpiresAt: time.Now().AddDate(0, 1, 0),
|
||||||
|
}
|
||||||
|
certRepo.AddCert(cert)
|
||||||
|
|
||||||
|
res, err := svc.BulkRenew(context.Background(),
|
||||||
|
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-rip"}}, "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkRenew failed: %v", err)
|
||||||
|
}
|
||||||
|
if res.TotalSkipped != 1 || res.TotalEnqueued != 0 {
|
||||||
|
t.Errorf("counts wrong: skipped=%d enqueued=%d, want 1/0",
|
||||||
|
res.TotalSkipped, res.TotalEnqueued)
|
||||||
|
}
|
||||||
|
if len(jobRepo.Jobs) != 0 {
|
||||||
|
t.Errorf("no job should be created for already-in-progress cert; got %d jobs", len(jobRepo.Jobs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkRenew_SkipsRevokedAndArchived — terminal states are silent
|
||||||
|
// no-ops, not errors. Operator selecting a mix of live and revoked certs
|
||||||
|
// shouldn't see "ERROR: revoked cert can't be renewed" 50 times.
|
||||||
|
func TestBulkRenew_SkipsRevokedAndArchived(t *testing.T) {
|
||||||
|
svc, certRepo, _, _ := newBulkRenewalTestService()
|
||||||
|
addRenewableCert(certRepo, "mc-live")
|
||||||
|
for _, st := range []domain.CertificateStatus{
|
||||||
|
domain.CertificateStatusRevoked,
|
||||||
|
domain.CertificateStatusArchived,
|
||||||
|
domain.CertificateStatusExpired,
|
||||||
|
} {
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: "mc-" + string(st), Status: st, ExpiresAt: time.Now().AddDate(0, 1, 0),
|
||||||
|
}
|
||||||
|
certRepo.AddCert(cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := svc.BulkRenew(context.Background(),
|
||||||
|
domain.BulkRenewalCriteria{CertificateIDs: []string{
|
||||||
|
"mc-live", "mc-Revoked", "mc-Archived", "mc-Expired",
|
||||||
|
}}, "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkRenew failed: %v", err)
|
||||||
|
}
|
||||||
|
if res.TotalEnqueued != 1 || res.TotalSkipped != 3 {
|
||||||
|
t.Errorf("counts = enqueued:%d skipped:%d, want 1/3 (only mc-live qualifies)",
|
||||||
|
res.TotalEnqueued, res.TotalSkipped)
|
||||||
|
}
|
||||||
|
if len(res.Errors) != 0 {
|
||||||
|
t.Errorf("status-skip should NOT populate Errors; got %v", res.Errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkRenew_EmptyCriteria_Error — defensive contract. Mirrors
|
||||||
|
// BulkRevocationCriteria.IsEmpty rejection so a stray empty POST
|
||||||
|
// doesn't try to renew the entire fleet.
|
||||||
|
func TestBulkRenew_EmptyCriteria_Error(t *testing.T) {
|
||||||
|
svc, _, _, _ := newBulkRenewalTestService()
|
||||||
|
_, err := svc.BulkRenew(context.Background(),
|
||||||
|
domain.BulkRenewalCriteria{}, "alice")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty criteria, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkRenew_PartialFailure — N=3, jobRepo.Create injected to fail
|
||||||
|
// on one of them. Response carries 2 enqueued + 1 error; no panic, no
|
||||||
|
// abort.
|
||||||
|
func TestBulkRenew_PartialFailure(t *testing.T) {
|
||||||
|
svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
|
||||||
|
addRenewableCert(certRepo, "mc-1")
|
||||||
|
addRenewableCert(certRepo, "mc-2")
|
||||||
|
addRenewableCert(certRepo, "mc-3")
|
||||||
|
|
||||||
|
// Make Create fail uniformly. Two of the three certs will still
|
||||||
|
// have their status flipped (because Update happened first), so
|
||||||
|
// the failure manifests as "I tried to enqueue, the job-create
|
||||||
|
// failed". Per-cert error string surfaced.
|
||||||
|
jobRepo.CreateErr = errors.New("simulated DB outage")
|
||||||
|
|
||||||
|
res, err := svc.BulkRenew(context.Background(),
|
||||||
|
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
|
||||||
|
"alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkRenew should not propagate per-cert errors as a top-level error; got: %v", err)
|
||||||
|
}
|
||||||
|
if res.TotalFailed != 3 || res.TotalEnqueued != 0 {
|
||||||
|
t.Errorf("counts = failed:%d enqueued:%d, want 3/0", res.TotalFailed, res.TotalEnqueued)
|
||||||
|
}
|
||||||
|
if len(res.Errors) != 3 {
|
||||||
|
t.Errorf("Errors len = %d, want 3", len(res.Errors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkRenew_AuditEventEmitted — exactly ONE bulk audit event for
|
||||||
|
// the operation, NOT N. This is the audit-volume contract that makes
|
||||||
|
// bulk endpoints scalable.
|
||||||
|
func TestBulkRenew_AuditEventEmitted(t *testing.T) {
|
||||||
|
svc, certRepo, _, auditRepo := newBulkRenewalTestService()
|
||||||
|
addRenewableCert(certRepo, "mc-1")
|
||||||
|
addRenewableCert(certRepo, "mc-2")
|
||||||
|
addRenewableCert(certRepo, "mc-3")
|
||||||
|
|
||||||
|
_, err := svc.BulkRenew(context.Background(),
|
||||||
|
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
|
||||||
|
"alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkRenew failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// audit_events count must be exactly 1 — the bulk-renewal envelope.
|
||||||
|
// Per-cert renewal events come from CertificateService.TriggerRenewal,
|
||||||
|
// which the bulk path bypasses for exactly this reason.
|
||||||
|
if len(auditRepo.Events) != 1 {
|
||||||
|
t.Errorf("audit events count = %d, want exactly 1 (one bulk event, NOT N per-cert events)", len(auditRepo.Events))
|
||||||
|
}
|
||||||
|
if len(auditRepo.Events) > 0 && auditRepo.Events[0].Action != "bulk_renewal_initiated" {
|
||||||
|
t.Errorf("audit action = %q, want 'bulk_renewal_initiated'", auditRepo.Events[0].Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- Migration 000017 (down): reverse the U-3 bundle.
|
||||||
|
--
|
||||||
|
-- Operators almost certainly never need this — each block in the up
|
||||||
|
-- migration was a strict improvement (column-name truth, dead-schema
|
||||||
|
-- removal, missing-column add). The down migration exists for
|
||||||
|
-- documentation and disaster-recovery completeness only.
|
||||||
|
--
|
||||||
|
-- Idempotent: each block uses the standard IF EXISTS / IF NOT EXISTS
|
||||||
|
-- guards plus a DO $$ guard on the rename to handle re-application.
|
||||||
|
-- Reverses the up migration's blocks in reverse order.
|
||||||
|
|
||||||
|
-- (3) Re-add the orphan health_check columns at their original defaults.
|
||||||
|
--
|
||||||
|
-- Note: re-adding does NOT restore the auto-health-check feature —
|
||||||
|
-- that code was never written. The column values revert to the
|
||||||
|
-- DEFAULT FALSE / 300 baseline that operators saw pre-U-3.
|
||||||
|
ALTER TABLE network_scan_targets
|
||||||
|
ADD COLUMN IF NOT EXISTS health_check_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS health_check_interval_seconds INTEGER DEFAULT 300;
|
||||||
|
|
||||||
|
-- (2) Drop the notification_events.created_at column.
|
||||||
|
--
|
||||||
|
-- This re-introduces the cat-o-notification_created_at_dead_field bug
|
||||||
|
-- (Go field with no DB column → API serializes 0001-01-01). Only roll
|
||||||
|
-- back if you've also rolled back the Go-side INSERT path that sets
|
||||||
|
-- created_at, otherwise INSERTs will fail with "column created_at does
|
||||||
|
-- not exist".
|
||||||
|
ALTER TABLE notification_events
|
||||||
|
DROP COLUMN IF EXISTS created_at;
|
||||||
|
|
||||||
|
-- (1) Rename the renewal_policies column back to the misleading name.
|
||||||
|
--
|
||||||
|
-- Re-introduces cat-o-retry_interval_unit_mismatch. Operators running
|
||||||
|
-- raw SQL revert to the 60x confusion. No data conversion (values are
|
||||||
|
-- still seconds; the column label lies again).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'renewal_policies'
|
||||||
|
AND column_name = 'retry_interval_seconds'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE renewal_policies
|
||||||
|
RENAME COLUMN retry_interval_seconds TO retry_interval_minutes;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
-- Migration 000017: DB coupling cleanup (U-3 bundle).
|
||||||
|
--
|
||||||
|
-- Closes three audit findings that share the migrations/ surface and the
|
||||||
|
-- "schema vs Go vs label drifts in different directions" pattern:
|
||||||
|
--
|
||||||
|
-- * cat-o-retry_interval_unit_mismatch (P1):
|
||||||
|
-- renewal_policies.retry_interval_minutes column stored seconds, named
|
||||||
|
-- minutes. Operators running raw SQL got 60x confusion.
|
||||||
|
--
|
||||||
|
-- * cat-o-notification_created_at_dead_field (P2):
|
||||||
|
-- internal/domain/notification.go::NotificationEvent.CreatedAt was
|
||||||
|
-- tagged json:"created_at" with no DB column behind it. Every API
|
||||||
|
-- response serialized 0001-01-01T00:00:00Z. Visible zero-value
|
||||||
|
-- timestamp on every notification row in the dashboard.
|
||||||
|
--
|
||||||
|
-- * cat-o-health_check_column_orphans (P1):
|
||||||
|
-- migration 000011 added network_scan_targets.health_check_enabled +
|
||||||
|
-- .health_check_interval_seconds. No Go field decoded either column;
|
||||||
|
-- no handler exposed them; OpenAPI schema didn't carry them. The
|
||||||
|
-- auto-health-check feature was never wired through. Removing dead
|
||||||
|
-- schema is cheaper than completing dead code; if the feature gets
|
||||||
|
-- revived, a future migration can re-add the columns alongside the
|
||||||
|
-- Go-side wiring.
|
||||||
|
--
|
||||||
|
-- Idempotency: RunMigrations at internal/repository/postgres/db.go has
|
||||||
|
-- no applied-tracking table — every server restart re-applies every
|
||||||
|
-- migration in sequence. Each block in this file MUST be safe to re-run
|
||||||
|
-- on a database that has already had it applied. The RENAME COLUMN in
|
||||||
|
-- block (1) is wrapped in a DO $$ guard that checks information_schema
|
||||||
|
-- before renaming; the ADD COLUMN in (2) and the DROP COLUMNs in (3)
|
||||||
|
-- use the standard IF NOT EXISTS / IF EXISTS clauses.
|
||||||
|
--
|
||||||
|
-- See the U-3 closure entry in
|
||||||
|
-- coverage-gap-audit-2026-04-24-v5/unified-audit.md and CHANGELOG.md
|
||||||
|
-- for the full rationale, the bundled-fix list, and the architectural
|
||||||
|
-- shift to runtime-only migration application.
|
||||||
|
|
||||||
|
-- (1) cat-o-retry_interval_unit_mismatch — rename column to match unit.
|
||||||
|
--
|
||||||
|
-- The values stored in this column have always been seconds (validator
|
||||||
|
-- at internal/service/renewal_policy.go enforces a [60, 86400] range
|
||||||
|
-- inclusive — 60 seconds to 24 hours, unambiguously seconds). The
|
||||||
|
-- column name was the bug; data conversion is a no-op. The Go field
|
||||||
|
-- has always been tagged json:"retry_interval_seconds", so the API
|
||||||
|
-- shape is unchanged.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'renewal_policies'
|
||||||
|
AND column_name = 'retry_interval_minutes'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE renewal_policies
|
||||||
|
RENAME COLUMN retry_interval_minutes TO retry_interval_seconds;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- (2) cat-o-notification_created_at_dead_field — add the missing column.
|
||||||
|
--
|
||||||
|
-- DEFAULT NOW() back-fills existing rows with the migration apply
|
||||||
|
-- timestamp. Acceptable trade-off: those rows had no real CreatedAt
|
||||||
|
-- info anyway (the field was a Go-only zero-value), and approximating
|
||||||
|
-- them with the migration time gives the dashboard a usable rendering
|
||||||
|
-- instead of '0001-01-01'. NOT NULL is enforced because the repo
|
||||||
|
-- INSERT path will set CreatedAt on every new row post-fix.
|
||||||
|
ALTER TABLE notification_events
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||||
|
|
||||||
|
-- (3) cat-o-health_check_column_orphans — drop unwired columns.
|
||||||
|
--
|
||||||
|
-- migrations/000011_health_checks.up.sql added these two columns with
|
||||||
|
-- the intent of wiring auto-health-checks for network-scan-discovered
|
||||||
|
-- endpoints. The Go side was never written; no handler reads or writes
|
||||||
|
-- them; the OpenAPI NetworkScanTarget schema doesn't expose them. The
|
||||||
|
-- columns have been carrying their default values (false / 300) on
|
||||||
|
-- every row since shipping. Dropping them removes dead schema; the
|
||||||
|
-- network_scan_targets row size shrinks marginally and operators stop
|
||||||
|
-- seeing flag/interval columns that don't actually do anything.
|
||||||
|
ALTER TABLE network_scan_targets
|
||||||
|
DROP COLUMN IF EXISTS health_check_enabled,
|
||||||
|
DROP COLUMN IF EXISTS health_check_interval_seconds;
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
-- Seed data for certificate control plane
|
-- Seed data for certificate control plane
|
||||||
|
|
||||||
-- Default renewal policy
|
-- Default renewal policy
|
||||||
INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_minutes, alert_thresholds_days)
|
INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_seconds, alert_thresholds_days)
|
||||||
VALUES (
|
VALUES (
|
||||||
'rp-default',
|
'rp-default',
|
||||||
'default',
|
'default',
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ ON CONFLICT (id) DO NOTHING;
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 2. Policies
|
-- 2. Policies
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_minutes, alert_thresholds_days, created_at, updated_at) VALUES
|
INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_seconds, alert_thresholds_days, created_at, updated_at) VALUES
|
||||||
('rp-standard', 'Standard 30-day', 30, true, 3, 60, '[30, 14, 7, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
('rp-standard', 'Standard 30-day', 30, true, 3, 60, '[30, 14, 7, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||||
('rp-urgent', 'Urgent 14-day', 14, true, 5, 30, '[14, 7, 3, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
('rp-urgent', 'Urgent 14-day', 14, true, 5, 30, '[14, 7, 3, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
|
||||||
('rp-manual', 'Manual Only', 30, false, 0, 0, '[30, 14, 7, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days')
|
('rp-manual', 'Manual Only', 30, false, 0, 0, '[30, 14, 7, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days')
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
createCertificate,
|
createCertificate,
|
||||||
triggerRenewal,
|
triggerRenewal,
|
||||||
revokeCertificate,
|
revokeCertificate,
|
||||||
exportCertificatePEM,
|
|
||||||
downloadCertificatePEM,
|
downloadCertificatePEM,
|
||||||
exportCertificatePKCS12,
|
exportCertificatePKCS12,
|
||||||
getAgents,
|
getAgents,
|
||||||
@@ -106,10 +105,8 @@ describe('API Client - Error Handling', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exportCertificatePEM propagates network error', async () => {
|
// B-1 closure (cat-b-9b97ffb35ef7): exportCertificatePEM removed as a
|
||||||
mockFetch.mockReturnValueOnce(mockNetworkError());
|
// dead duplicate of downloadCertificatePEM (zero consumers).
|
||||||
await expect(exportCertificatePEM('mc-test')).rejects.toThrow('Failed to fetch');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('downloadCertificatePEM propagates network error', async () => {
|
it('downloadCertificatePEM propagates network error', async () => {
|
||||||
mockFetch.mockReturnValueOnce(mockNetworkError());
|
mockFetch.mockReturnValueOnce(mockNetworkError());
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
archiveCertificate,
|
archiveCertificate,
|
||||||
revokeCertificate,
|
revokeCertificate,
|
||||||
bulkRevokeCertificates,
|
bulkRevokeCertificates,
|
||||||
exportCertificatePEM,
|
|
||||||
downloadCertificatePEM,
|
downloadCertificatePEM,
|
||||||
exportCertificatePKCS12,
|
exportCertificatePKCS12,
|
||||||
getAgents,
|
getAgents,
|
||||||
@@ -1151,15 +1150,8 @@ describe('API Client', () => {
|
|||||||
// ─── Certificate Export ────────────────────────────────
|
// ─── Certificate Export ────────────────────────────────
|
||||||
|
|
||||||
describe('Certificate Export', () => {
|
describe('Certificate Export', () => {
|
||||||
it('exportCertificatePEM fetches PEM data as JSON', async () => {
|
// B-1 closure (cat-b-9b97ffb35ef7): exportCertificatePEM was removed
|
||||||
const pemResult = { cert_pem: 'CERT', chain_pem: 'CHAIN', full_pem: 'FULL' };
|
// from client.ts as a dead duplicate of downloadCertificatePEM.
|
||||||
mockFetch.mockReturnValueOnce(mockJsonResponse(pemResult));
|
|
||||||
const result = await exportCertificatePEM('mc-1');
|
|
||||||
const [url] = mockFetch.mock.calls[0];
|
|
||||||
expect(url).toBe('/api/v1/certificates/mc-1/export/pem');
|
|
||||||
expect(result.cert_pem).toBe('CERT');
|
|
||||||
expect(result.full_pem).toBe('FULL');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('downloadCertificatePEM fetches blob with download=true', async () => {
|
it('downloadCertificatePEM fetches blob with download=true', async () => {
|
||||||
const mockBlob = new Blob(['pem-data'], { type: 'application/x-pem-file' });
|
const mockBlob = new Blob(['pem-data'], { type: 'application/x-pem-file' });
|
||||||
|
|||||||
+106
-3
@@ -2,6 +2,35 @@ import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEv
|
|||||||
|
|
||||||
const BASE = '/api/v1';
|
const BASE = '/api/v1';
|
||||||
|
|
||||||
|
// P-1 closure (diff-04x03-d24864996ad4 P2 + cat-b-dc46aadab98e P3):
|
||||||
|
// the audit flagged 26+16 orphan client functions. Recon at HEAD
|
||||||
|
// found 17 actual orphans (the 26+16 audit numbers conflated; many
|
||||||
|
// were eliminated by the B-1 / S-1 / I-2 / D-2 closures since the
|
||||||
|
// audit was written). The remaining 17 are all detail-page
|
||||||
|
// candidates — singleton-getter `getX(id)` fns that detail pages
|
||||||
|
// will need when the corresponding `XPage` grows a `XDetailPage`
|
||||||
|
// route. Preserved here (rather than deleted) so the future
|
||||||
|
// detail-page work doesn't have to relitigate the client.ts surface.
|
||||||
|
//
|
||||||
|
// Intentionally-orphan client functions:
|
||||||
|
// getAgentGroup, getAgentGroupMembers, getAuditEvent,
|
||||||
|
// getCertificateDeployments, getDiscoveredCertificate,
|
||||||
|
// getHealthCheck, getHealthCheckHistory, getNetworkScanTarget,
|
||||||
|
// getNotification, getOCSPStatus, getOwner, getPolicy,
|
||||||
|
// getPolicyViolations, getRenewalPolicy, getTeam, registerAgent
|
||||||
|
// (by-design pull-only; see C-1 closure docblock above its export),
|
||||||
|
// updateHealthCheck.
|
||||||
|
//
|
||||||
|
// CI guardrail at .github/workflows/ci.yml::"Documented orphan
|
||||||
|
// client fns sync guard (P-1)" enforces the docblock list ↔
|
||||||
|
// export list relationship: every name above must still be
|
||||||
|
// declared somewhere in this file, and conversely if a name is
|
||||||
|
// removed from the list its export must also be removed (orphans
|
||||||
|
// must never silently accumulate).
|
||||||
|
//
|
||||||
|
// See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||||
|
// diff-04x03-d24864996ad4 + cat-b-dc46aadab98e for closure rationale.
|
||||||
|
|
||||||
// API key stored in memory (not localStorage for security)
|
// API key stored in memory (not localStorage for security)
|
||||||
let apiKey: string | null = null;
|
let apiKey: string | null = null;
|
||||||
|
|
||||||
@@ -129,10 +158,73 @@ export const bulkRevokeCertificates = (criteria: BulkRevokeCriteria) =>
|
|||||||
body: JSON.stringify(criteria),
|
body: JSON.stringify(criteria),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Certificate Export
|
// L-1 master closure (cat-l-fa0c1ac07ab5): bulk renew. Mirrors
|
||||||
export const exportCertificatePEM = (id: string) =>
|
// BulkRevokeCriteria field-for-field so operators who already know the
|
||||||
fetchJSON<{ cert_pem: string; chain_pem: string; full_pem: string }>(`${BASE}/certificates/${id}/export/pem`);
|
// bulk-revoke contract have zero new surface to learn. Pre-L-1 the GUI
|
||||||
|
// looped `await triggerRenewal(id)` over the selection; 100 certs = 100
|
||||||
|
// HTTP round-trips. Post-L-1 it's a single POST returning per-cert
|
||||||
|
// {certificate_id, job_id} pairs in enqueued_jobs and per-cert errors
|
||||||
|
// in errors. The "renew all certs of profile X" use case is the
|
||||||
|
// canonical reason to support criteria-mode in addition to explicit IDs.
|
||||||
|
export interface BulkRenewalCriteria {
|
||||||
|
profile_id?: string;
|
||||||
|
owner_id?: string;
|
||||||
|
agent_id?: string;
|
||||||
|
issuer_id?: string;
|
||||||
|
team_id?: string;
|
||||||
|
certificate_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkRenewalResult {
|
||||||
|
total_matched: number;
|
||||||
|
total_enqueued: number;
|
||||||
|
total_skipped: number;
|
||||||
|
total_failed: number;
|
||||||
|
enqueued_jobs?: { certificate_id: string; job_id: string }[];
|
||||||
|
errors?: { certificate_id: string; error: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bulkRenewCertificates = (criteria: BulkRenewalCriteria) =>
|
||||||
|
fetchJSON<BulkRenewalResult>(`${BASE}/certificates/bulk-renew`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(criteria),
|
||||||
|
});
|
||||||
|
|
||||||
|
// L-2 closure (cat-l-8a1fb258a38a): bulk reassign owner (and optionally
|
||||||
|
// team) for a set of certificates. Narrower than bulk-renew — explicit
|
||||||
|
// IDs only, no criteria-mode (operators query first, then reassign by
|
||||||
|
// ID). Pre-L-2 the GUI looped `await updateCertificate(id, { owner_id })`.
|
||||||
|
// owner_id is required; team_id is optional and updates only when
|
||||||
|
// non-empty (matches the existing per-cert PUT contract).
|
||||||
|
export interface BulkReassignmentRequest {
|
||||||
|
certificate_ids: string[];
|
||||||
|
owner_id: string;
|
||||||
|
team_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkReassignmentResult {
|
||||||
|
total_matched: number;
|
||||||
|
total_reassigned: number;
|
||||||
|
total_skipped: number;
|
||||||
|
total_failed: number;
|
||||||
|
errors?: { certificate_id: string; error: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bulkReassignCertificates = (request: BulkReassignmentRequest) =>
|
||||||
|
fetchJSON<BulkReassignmentResult>(`${BASE}/certificates/bulk-reassign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Certificate Export
|
||||||
|
//
|
||||||
|
// B-1 master closure (cat-b-9b97ffb35ef7): the previous `exportCertificatePEM`
|
||||||
|
// helper that returned `{cert_pem, chain_pem, full_pem}` JSON was removed —
|
||||||
|
// it had zero consumers across web/, MCP, CLI, and tests, and was a dead
|
||||||
|
// duplicate of `downloadCertificatePEM` which is the only call site that
|
||||||
|
// actually exists in `CertificateDetailPage` (browser file-download path).
|
||||||
|
// If a JSON variant is ever needed again, re-add an explicit fetcher with a
|
||||||
|
// page consumer in the same commit; do not resurrect the orphan.
|
||||||
export const downloadCertificatePEM = (id: string) => {
|
export const downloadCertificatePEM = (id: string) => {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
@@ -185,6 +277,17 @@ export const getAgents = (params: Record<string, string> = {}) => {
|
|||||||
export const getAgent = (id: string) =>
|
export const getAgent = (id: string) =>
|
||||||
fetchJSON<Agent>(`${BASE}/agents/${id}`);
|
fetchJSON<Agent>(`${BASE}/agents/${id}`);
|
||||||
|
|
||||||
|
// C-1 closure (cat-b-6177f36636fb): registerAgent is intentionally
|
||||||
|
// orphan in the GUI per certctl's pull-only deployment model. Agents
|
||||||
|
// enroll via install-agent.sh + cmd/agent/main.go and register
|
||||||
|
// themselves at first heartbeat — operators don't (and shouldn't)
|
||||||
|
// drive registration from the dashboard. The client fn is preserved
|
||||||
|
// here (rather than deleted) so future features that want to drive
|
||||||
|
// registration from the GUI (e.g. a one-click "register proxy agent"
|
||||||
|
// panel for network-appliance topologies) can reach the endpoint
|
||||||
|
// without a client.ts edit. See docs/architecture.md::Agents for
|
||||||
|
// the architectural rationale and unified-audit.md cat-b-6177f36636fb
|
||||||
|
// for closure rationale.
|
||||||
export const registerAgent = (data: Partial<Agent>) =>
|
export const registerAgent = (data: Partial<Agent>) =>
|
||||||
fetchJSON<Agent>(`${BASE}/agents`, { method: 'POST', body: JSON.stringify(data) });
|
fetchJSON<Agent>(`${BASE}/agents`, { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
|
||||||
|
|||||||
+349
-11
@@ -1,6 +1,14 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { POLICY_TYPES, POLICY_SEVERITIES } from './types';
|
import { POLICY_TYPES, POLICY_SEVERITIES } from './types';
|
||||||
import type { Agent } from './types';
|
import type {
|
||||||
|
Agent,
|
||||||
|
Certificate,
|
||||||
|
CertificateVersion,
|
||||||
|
DiscoveredCertificate,
|
||||||
|
Issuer,
|
||||||
|
Notification,
|
||||||
|
Target,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression tests for the policy enum tuples.
|
* Regression tests for the policy enum tuples.
|
||||||
@@ -85,6 +93,9 @@ describe('Agent interface (I-004 retirement)', () => {
|
|||||||
// Construct an Agent with the retirement fields set. If Phase 2b names
|
// Construct an Agent with the retirement fields set. If Phase 2b names
|
||||||
// them anything other than retired_at / retired_reason, this fails to
|
// them anything other than retired_at / retired_reason, this fails to
|
||||||
// compile — which is exactly what the Red stage wants.
|
// compile — which is exactly what the Red stage wants.
|
||||||
|
// D-2 (master): the post-D-2 Agent shape no longer carries
|
||||||
|
// last_heartbeat / capabilities / tags / created_at / updated_at —
|
||||||
|
// those were TS phantoms the Go-side struct never emitted.
|
||||||
const retired: Agent = {
|
const retired: Agent = {
|
||||||
id: 'ag-1',
|
id: 'ag-1',
|
||||||
name: 'decom-01',
|
name: 'decom-01',
|
||||||
@@ -94,13 +105,8 @@ describe('Agent interface (I-004 retirement)', () => {
|
|||||||
architecture: 'amd64',
|
architecture: 'amd64',
|
||||||
status: 'Offline',
|
status: 'Offline',
|
||||||
version: '2.1.0',
|
version: '2.1.0',
|
||||||
last_heartbeat: '2026-01-01T00:00:00Z',
|
|
||||||
last_heartbeat_at: '2026-01-01T00:00:00Z',
|
last_heartbeat_at: '2026-01-01T00:00:00Z',
|
||||||
capabilities: [],
|
|
||||||
tags: {},
|
|
||||||
registered_at: '2024-01-01T00:00:00Z',
|
registered_at: '2024-01-01T00:00:00Z',
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
|
||||||
updated_at: '2026-01-01T00:00:00Z',
|
|
||||||
retired_at: '2026-01-01T00:00:00Z',
|
retired_at: '2026-01-01T00:00:00Z',
|
||||||
retired_reason: 'old hardware',
|
retired_reason: 'old hardware',
|
||||||
};
|
};
|
||||||
@@ -111,6 +117,7 @@ describe('Agent interface (I-004 retirement)', () => {
|
|||||||
it('accepts an Agent without retired_at / retired_reason (optional fields)', () => {
|
it('accepts an Agent without retired_at / retired_reason (optional fields)', () => {
|
||||||
// Active agents should not carry retirement metadata. If Phase 2b makes
|
// Active agents should not carry retirement metadata. If Phase 2b makes
|
||||||
// the fields required, this block fails to compile.
|
// the fields required, this block fails to compile.
|
||||||
|
// D-2 (master): post-D-2 Agent shape (see sibling describe block).
|
||||||
const active: Agent = {
|
const active: Agent = {
|
||||||
id: 'ag-2',
|
id: 'ag-2',
|
||||||
name: 'web01',
|
name: 'web01',
|
||||||
@@ -120,15 +127,346 @@ describe('Agent interface (I-004 retirement)', () => {
|
|||||||
architecture: 'amd64',
|
architecture: 'amd64',
|
||||||
status: 'Online',
|
status: 'Online',
|
||||||
version: '2.1.0',
|
version: '2.1.0',
|
||||||
last_heartbeat: '2026-04-18T12:00:00Z',
|
|
||||||
last_heartbeat_at: '2026-04-18T12:00:00Z',
|
last_heartbeat_at: '2026-04-18T12:00:00Z',
|
||||||
capabilities: ['deploy', 'scan'],
|
|
||||||
tags: {},
|
|
||||||
registered_at: '2024-06-01T00:00:00Z',
|
registered_at: '2024-06-01T00:00:00Z',
|
||||||
created_at: '2024-06-01T00:00:00Z',
|
|
||||||
updated_at: '2026-04-18T12:00:00Z',
|
|
||||||
};
|
};
|
||||||
expect(active.retired_at).toBeUndefined();
|
expect(active.retired_at).toBeUndefined();
|
||||||
expect(active.retired_reason).toBeUndefined();
|
expect(active.retired_reason).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-5 (cat-f-ae0d06b6588f, master): Certificate TS phantom-fields trim.
|
||||||
|
*
|
||||||
|
* Pre-D-5 the Certificate interface declared `serial_number`,
|
||||||
|
* `fingerprint_sha256`, `key_algorithm`, `key_size`, and `issued_at` as
|
||||||
|
* optional. These fields were never emitted by Go's `ManagedCertificate`
|
||||||
|
* (internal/domain/certificate.go) — they live on `CertificateVersion`,
|
||||||
|
* which is the per-issuance record fetched from
|
||||||
|
* /api/v1/certificates/{id}/versions. The optional declarations made
|
||||||
|
* `cert.serial_number` always-undefined on list responses, and downstream
|
||||||
|
* consumers (CertificateDetailPage's Key Algorithm / Key Size rows in
|
||||||
|
* particular) silently rendered '—' for every cert despite the data
|
||||||
|
* being available a single fetch away.
|
||||||
|
*
|
||||||
|
* Post-D-5 the TS type makes the missing-data case explicit: a
|
||||||
|
* `cert.serial_number` access becomes a TS compile error, forcing every
|
||||||
|
* consumer to acknowledge the version-fallback pattern. This regression
|
||||||
|
* test pins the trim — if a future PR re-adds any of the five phantom
|
||||||
|
* fields to Certificate (e.g. via merge conflict, copy-paste, or a
|
||||||
|
* codegen run that regenerates from a stale OpenAPI spec), the
|
||||||
|
* compile-fail block here will surface it.
|
||||||
|
*/
|
||||||
|
describe('Certificate interface (D-5 phantom-fields trim)', () => {
|
||||||
|
it('does NOT declare per-issuance fields — those live on CertificateVersion', () => {
|
||||||
|
// Construct a fully-populated Certificate. If a future PR re-adds
|
||||||
|
// any of the five phantom fields (serial_number, fingerprint_sha256,
|
||||||
|
// key_algorithm, key_size, issued_at) to the interface, every
|
||||||
|
// omission in this literal becomes "missing required field" and
|
||||||
|
// the test fails to compile. Conversely, attempting to set any of
|
||||||
|
// the five fields on the literal is a TS error today (excess
|
||||||
|
// property), so the negative-assertion block below also fails to
|
||||||
|
// compile if someone re-adds them as optional.
|
||||||
|
const cert: Certificate = {
|
||||||
|
id: 'mc-test',
|
||||||
|
name: 'test',
|
||||||
|
common_name: 'test.example.com',
|
||||||
|
sans: [],
|
||||||
|
status: 'Active',
|
||||||
|
environment: 'production',
|
||||||
|
issuer_id: 'iss-test',
|
||||||
|
owner_id: 'o-test',
|
||||||
|
team_id: 't-test',
|
||||||
|
renewal_policy_id: 'rp-default',
|
||||||
|
certificate_profile_id: 'cp-default',
|
||||||
|
expires_at: '2027-01-01T00:00:00Z',
|
||||||
|
tags: {},
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
expect(cert.id).toBe('mc-test');
|
||||||
|
|
||||||
|
// Excess-property check: each of these MUST be a TS error if
|
||||||
|
// uncommented. Keep them in the test as documentation of what's
|
||||||
|
// intentionally absent. (We can't directly assert "type does not
|
||||||
|
// have property X" without a type-level helper, but the literal
|
||||||
|
// construction above plus tsc --noEmit in CI is the binding check.)
|
||||||
|
//
|
||||||
|
// const broken: Certificate = { ...cert, serial_number: '01:02' }; // ❌ TS2353
|
||||||
|
// const broken2: Certificate = { ...cert, key_algorithm: 'EC' }; // ❌ TS2353
|
||||||
|
// const broken3: Certificate = { ...cert, key_size: 256 }; // ❌ TS2353
|
||||||
|
// const broken4: Certificate = { ...cert, fingerprint_sha256: '' };// ❌ TS2353
|
||||||
|
// const broken5: Certificate = { ...cert, issued_at: '...' }; // ❌ TS2353
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CertificateVersion still carries the per-issuance fields', () => {
|
||||||
|
// The other half of the contract: the trimmed fields didn't go to
|
||||||
|
// /dev/null — they live (and have always lived) on CertificateVersion.
|
||||||
|
// If a refactor removes them from CertificateVersion too, the
|
||||||
|
// CertificateDetailPage fallback path breaks. Pin both halves.
|
||||||
|
const v: CertificateVersion = {
|
||||||
|
id: 'mcv-test',
|
||||||
|
certificate_id: 'mc-test',
|
||||||
|
serial_number: '01:02:03',
|
||||||
|
fingerprint_sha256: 'a'.repeat(64),
|
||||||
|
pem_chain: '-----BEGIN CERTIFICATE-----\n...',
|
||||||
|
csr_pem: '-----BEGIN CERTIFICATE REQUEST-----\n...',
|
||||||
|
not_before: '2026-01-01T00:00:00Z',
|
||||||
|
not_after: '2027-01-01T00:00:00Z',
|
||||||
|
key_algorithm: 'ECDSA',
|
||||||
|
key_size: 256,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
expect(v.serial_number).toBe('01:02:03');
|
||||||
|
expect(v.key_algorithm).toBe('ECDSA');
|
||||||
|
expect(v.key_size).toBe(256);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-2 (diff-05x06-7cdf4e78ae24, master): Agent TS phantom-fields trim.
|
||||||
|
*
|
||||||
|
* Pre-D-2 the `Agent` interface declared five fields that the Go-side
|
||||||
|
* struct (`internal/domain/connector.go::Agent`) does NOT emit on the
|
||||||
|
* wire: `last_heartbeat`, `capabilities`, `tags`, `created_at`,
|
||||||
|
* `updated_at`. Two of them had real consumers (`AgentDetailPage.tsx`
|
||||||
|
* read `agent.capabilities` and `agent.tags`) — both always rendered the
|
||||||
|
* empty-state branch because the runtime values were always `undefined`.
|
||||||
|
*
|
||||||
|
* Post-D-2 a `agent.capabilities` access is a TS compile error, forcing
|
||||||
|
* every consumer to acknowledge the field is not part of the Agent
|
||||||
|
* contract. The Go-side struct emits exactly: id, name, hostname, status,
|
||||||
|
* last_heartbeat_at (note the `_at` suffix — this is the real heartbeat
|
||||||
|
* field and stays), registered_at, os, architecture, ip_address, version,
|
||||||
|
* retired_at?, retired_reason?.
|
||||||
|
*/
|
||||||
|
describe('Agent interface (D-2 phantom-fields trim)', () => {
|
||||||
|
it('does NOT declare last_heartbeat / capabilities / tags / created_at / updated_at', () => {
|
||||||
|
// Construct an Agent with ONLY the post-D-2 field set. If a future
|
||||||
|
// PR re-adds any of the five trimmed fields, the excess-property
|
||||||
|
// comments below become live TS errors when uncommented (and the
|
||||||
|
// CI guardrail in .github/workflows/ci.yml fires regardless).
|
||||||
|
const a: Agent = {
|
||||||
|
id: 'ag-test',
|
||||||
|
name: 'web-01',
|
||||||
|
hostname: 'web-01.prod',
|
||||||
|
status: 'Online',
|
||||||
|
last_heartbeat_at: '2026-04-25T12:00:00Z',
|
||||||
|
registered_at: '2024-06-01T00:00:00Z',
|
||||||
|
os: 'linux',
|
||||||
|
architecture: 'amd64',
|
||||||
|
ip_address: '10.0.0.1',
|
||||||
|
version: '2.1.0',
|
||||||
|
};
|
||||||
|
expect(a.id).toBe('ag-test');
|
||||||
|
expect(a.last_heartbeat_at).toBe('2026-04-25T12:00:00Z');
|
||||||
|
|
||||||
|
// Excess-property check (each MUST be a TS error if uncommented):
|
||||||
|
// const broken1: Agent = { ...a, last_heartbeat: '2026-...' }; // ❌ TS2353
|
||||||
|
// const broken2: Agent = { ...a, capabilities: ['deploy'] }; // ❌ TS2353
|
||||||
|
// const broken3: Agent = { ...a, tags: { env: 'prod' } }; // ❌ TS2353
|
||||||
|
// const broken4: Agent = { ...a, created_at: '...' }; // ❌ TS2353
|
||||||
|
// const broken5: Agent = { ...a, updated_at: '...' }; // ❌ TS2353
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps last_heartbeat_at (the real Go-emitted heartbeat field)', () => {
|
||||||
|
// Negative-prevention guard: the awk-windowed CI grep for the trimmed
|
||||||
|
// `last_heartbeat` field must NOT trip on the legitimate
|
||||||
|
// `last_heartbeat_at`. This test pins that the legitimate field stays.
|
||||||
|
const a: Agent = {
|
||||||
|
id: 'ag-2',
|
||||||
|
name: 'web-02',
|
||||||
|
hostname: 'web-02.prod',
|
||||||
|
status: 'Offline',
|
||||||
|
registered_at: '2024-06-01T00:00:00Z',
|
||||||
|
os: 'linux',
|
||||||
|
architecture: 'amd64',
|
||||||
|
ip_address: '10.0.0.2',
|
||||||
|
version: '2.1.0',
|
||||||
|
};
|
||||||
|
expect(a.last_heartbeat_at).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-2 (diff-05x06-2044a46f4dd0, master): Target retirement-fields ADD.
|
||||||
|
*
|
||||||
|
* Pre-D-2 the Go-side `DeploymentTarget` struct
|
||||||
|
* (`internal/domain/connector.go:24`) emitted `retired_at` and
|
||||||
|
* `retired_reason` (I-004 soft-retirement, mirroring the Agent
|
||||||
|
* treatment), but the TS `Target` interface did not declare them.
|
||||||
|
* Consumers wanting to surface the retired state in the GUI had to
|
||||||
|
* use `(target as any).retired_at` escapes that lost type-checking.
|
||||||
|
*
|
||||||
|
* Post-D-2 the TS interface declares both as optional nullable strings,
|
||||||
|
* mirroring the existing Agent retirement-fields shape.
|
||||||
|
*/
|
||||||
|
describe('Target interface (D-2 retirement fields)', () => {
|
||||||
|
it('accepts retired_at and retired_reason as optional nullable strings', () => {
|
||||||
|
const retired: Target = {
|
||||||
|
id: 't-decom-01',
|
||||||
|
name: 'old-iis-server',
|
||||||
|
type: 'iis',
|
||||||
|
agent_id: 'ag-old',
|
||||||
|
config: {},
|
||||||
|
enabled: false,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
retired_at: '2026-03-01T00:00:00Z',
|
||||||
|
retired_reason: 'replaced by new iis-server',
|
||||||
|
};
|
||||||
|
expect(retired.retired_at).toBe('2026-03-01T00:00:00Z');
|
||||||
|
expect(retired.retired_reason).toBe('replaced by new iis-server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a Target without the retirement fields (active row)', () => {
|
||||||
|
const active: Target = {
|
||||||
|
id: 't-1',
|
||||||
|
name: 'iis-server',
|
||||||
|
type: 'iis',
|
||||||
|
agent_id: 'ag-1',
|
||||||
|
config: {},
|
||||||
|
enabled: true,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
expect(active.retired_at).toBeUndefined();
|
||||||
|
expect(active.retired_reason).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-2 (diff-05x06-85ab6b98a2f7, master): DiscoveredCertificate pem_data ADD.
|
||||||
|
*
|
||||||
|
* Pre-D-2 the Go-side `DiscoveredCertificate` struct
|
||||||
|
* (`internal/domain/discovery.go::DiscoveredCertificate.PEMData`) emitted
|
||||||
|
* `pem_data` (omitempty — populated by repo SELECT, agent ingestion at
|
||||||
|
* cmd/agent/main.go:1021, and connector scans at
|
||||||
|
* internal/connector/discovery/azurekv/azurekv.go:234), but the TS
|
||||||
|
* `DiscoveredCertificate` interface did not declare it. Consumers wanting
|
||||||
|
* to inspect or download the raw PEM had to use `(d as any).pem_data`.
|
||||||
|
*
|
||||||
|
* Post-D-2 the TS interface declares it as `pem_data?: string`, optional
|
||||||
|
* because the Go side uses `omitempty` (empty string → not emitted).
|
||||||
|
*
|
||||||
|
* Performance note (deferred follow-up): the LIST endpoint also loads
|
||||||
|
* pem_data via the same repo SELECT; for large discovered-cert tables
|
||||||
|
* this can ship kilobytes per row. Optimising the list response to omit
|
||||||
|
* pem_data is a separate backend change.
|
||||||
|
*/
|
||||||
|
describe('DiscoveredCertificate interface (D-2 pem_data ADD)', () => {
|
||||||
|
it('accepts pem_data as an optional string', () => {
|
||||||
|
const d: DiscoveredCertificate = {
|
||||||
|
id: 'dc-1',
|
||||||
|
fingerprint_sha256: 'a'.repeat(64),
|
||||||
|
common_name: 'discovered.example.com',
|
||||||
|
sans: [],
|
||||||
|
serial_number: '01:02:03',
|
||||||
|
issuer_dn: 'CN=Test CA',
|
||||||
|
subject_dn: 'CN=discovered.example.com',
|
||||||
|
key_algorithm: 'ECDSA',
|
||||||
|
key_size: 256,
|
||||||
|
is_ca: false,
|
||||||
|
source_path: '/etc/ssl/certs/disc.pem',
|
||||||
|
source_format: 'pem',
|
||||||
|
agent_id: 'ag-1',
|
||||||
|
status: 'Unmanaged',
|
||||||
|
first_seen_at: '2026-04-25T12:00:00Z',
|
||||||
|
last_seen_at: '2026-04-25T12:00:00Z',
|
||||||
|
created_at: '2026-04-25T12:00:00Z',
|
||||||
|
updated_at: '2026-04-25T12:00:00Z',
|
||||||
|
pem_data: '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n',
|
||||||
|
};
|
||||||
|
expect(d.pem_data).toContain('BEGIN CERTIFICATE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a DiscoveredCertificate without pem_data (list-response shape)', () => {
|
||||||
|
const d: DiscoveredCertificate = {
|
||||||
|
id: 'dc-2',
|
||||||
|
fingerprint_sha256: 'b'.repeat(64),
|
||||||
|
common_name: 'list.example.com',
|
||||||
|
sans: [],
|
||||||
|
serial_number: '04:05:06',
|
||||||
|
issuer_dn: 'CN=Test CA',
|
||||||
|
subject_dn: 'CN=list.example.com',
|
||||||
|
key_algorithm: 'ECDSA',
|
||||||
|
key_size: 256,
|
||||||
|
is_ca: false,
|
||||||
|
source_path: '/etc/ssl/certs/list.pem',
|
||||||
|
source_format: 'pem',
|
||||||
|
agent_id: 'ag-1',
|
||||||
|
status: 'Unmanaged',
|
||||||
|
first_seen_at: '2026-04-25T12:00:00Z',
|
||||||
|
last_seen_at: '2026-04-25T12:00:00Z',
|
||||||
|
created_at: '2026-04-25T12:00:00Z',
|
||||||
|
updated_at: '2026-04-25T12:00:00Z',
|
||||||
|
};
|
||||||
|
expect(d.pem_data).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-2 (diff-05x06-97fab8783a5c, master): Issuer status phantom trim.
|
||||||
|
*
|
||||||
|
* Pre-D-2 the TS `Issuer` interface declared a required `status: string`
|
||||||
|
* field that the Go-side struct (`internal/domain/connector.go::Issuer`)
|
||||||
|
* never emitted — the Go struct has only `Enabled bool`. The TS interface
|
||||||
|
* comment claimed "Backend returns enabled boolean; status is derived
|
||||||
|
* from this" but no derivation logic existed: `IssuersPage.tsx::~line 23`
|
||||||
|
* read `issuer.status || 'Unknown'` and always rendered 'Unknown'.
|
||||||
|
*
|
||||||
|
* Post-D-2 the `status` field is removed; the consumer now derives the
|
||||||
|
* displayed status from `enabled` at render time.
|
||||||
|
*/
|
||||||
|
describe('Issuer interface (D-2 status phantom trim)', () => {
|
||||||
|
it('does NOT declare a phantom `status` field — derive from `enabled`', () => {
|
||||||
|
// Construct a fully-populated Issuer with the post-D-2 shape.
|
||||||
|
// If `status` is re-added, this construction fails with "missing
|
||||||
|
// required" (TS2741) when status is required, or the excess-property
|
||||||
|
// comment below trips when it's added back as optional.
|
||||||
|
const i: Issuer = {
|
||||||
|
id: 'iss-test',
|
||||||
|
name: 'Test ACME',
|
||||||
|
type: 'acme',
|
||||||
|
config: {},
|
||||||
|
enabled: true,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
expect(i.id).toBe('iss-test');
|
||||||
|
expect(i.enabled).toBe(true);
|
||||||
|
|
||||||
|
// Excess-property check:
|
||||||
|
// const broken: Issuer = { ...i, status: 'Active' }; // ❌ TS2353
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-2 (diff-05x06-caba9eb3620e, master): Notification subject phantom trim.
|
||||||
|
*
|
||||||
|
* Pre-D-2 the TS `Notification` interface declared `subject?: string` —
|
||||||
|
* the field was acknowledged in the existing comment as "a historical
|
||||||
|
* frontend-only field the backend never emits" but kept on the interface
|
||||||
|
* "so legacy fixtures and the pendingNotif test mock still type
|
||||||
|
* correctly." Real consumer at `NotificationsPage.tsx::~line 241` had
|
||||||
|
* `{n.message || n.subject}` as a fallback that always fell through to
|
||||||
|
* `n.message` (since `n.subject` was always undefined).
|
||||||
|
*
|
||||||
|
* Post-D-2 the field is removed; the consumer drops the dead fallback
|
||||||
|
* and the test fixtures drop the dead `subject:` initializer.
|
||||||
|
*/
|
||||||
|
describe('Notification interface (D-2 subject phantom trim)', () => {
|
||||||
|
it('does NOT declare the phantom `subject` field', () => {
|
||||||
|
const n: Notification = {
|
||||||
|
id: 'no-test',
|
||||||
|
type: 'CertificateExpiring',
|
||||||
|
channel: 'email',
|
||||||
|
recipient: 'ops@example.com',
|
||||||
|
message: 'Certificate api.example.com expires in 14 days',
|
||||||
|
status: 'pending',
|
||||||
|
created_at: '2026-04-25T12:00:00Z',
|
||||||
|
};
|
||||||
|
expect(n.id).toBe('no-test');
|
||||||
|
expect(n.message).toContain('14 days');
|
||||||
|
|
||||||
|
// Excess-property check:
|
||||||
|
// const broken: Notification = { ...n, subject: 'Cert expiring' }; // ❌ TS2353
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+75
-17
@@ -1,3 +1,15 @@
|
|||||||
|
// D-5 (cat-f-ae0d06b6588f, master): the five per-issuance fields
|
||||||
|
// (serial_number, fingerprint_sha256, key_algorithm, key_size,
|
||||||
|
// issued_at) USED to live here as optional. They were never emitted
|
||||||
|
// by Go's `ManagedCertificate` (internal/domain/certificate.go) — they
|
||||||
|
// live on `CertificateVersion` (per-issuance evidence) and are fetched
|
||||||
|
// via getCertificateVersions(id). Render-site consumers (notably
|
||||||
|
// CertificateDetailPage) use `latestVersion?.field` as the canonical
|
||||||
|
// access path. Pre-D-5 the optional declaration silently returned
|
||||||
|
// `undefined` on every list response, so consumers who didn't know
|
||||||
|
// about the version-fallback pattern rendered '—' for every cert; now
|
||||||
|
// the missing-data case is explicit at the type level (a `cert.X`
|
||||||
|
// access for one of these fields is a TS compile error).
|
||||||
export interface Certificate {
|
export interface Certificate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -10,11 +22,6 @@ export interface Certificate {
|
|||||||
team_id: string;
|
team_id: string;
|
||||||
renewal_policy_id: string;
|
renewal_policy_id: string;
|
||||||
certificate_profile_id: string;
|
certificate_profile_id: string;
|
||||||
serial_number?: string;
|
|
||||||
fingerprint_sha256?: string;
|
|
||||||
key_algorithm?: string;
|
|
||||||
key_size?: number;
|
|
||||||
issued_at?: string;
|
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
revoked_at?: string;
|
revoked_at?: string;
|
||||||
revocation_reason?: string;
|
revocation_reason?: string;
|
||||||
@@ -60,6 +67,23 @@ export interface CertificateVersion {
|
|||||||
// API contract. See docs/architecture.md ER-diagram note and
|
// API contract. See docs/architecture.md ER-diagram note and
|
||||||
// coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-s5-apikey_leak
|
// coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-s5-apikey_leak
|
||||||
// for the closure rationale.
|
// for the closure rationale.
|
||||||
|
//
|
||||||
|
// D-2 (diff-05x06-7cdf4e78ae24, master): pre-D-2 this interface declared
|
||||||
|
// five fields the Go-side struct (internal/domain/connector.go::Agent)
|
||||||
|
// does NOT emit on the wire: `last_heartbeat` (the real field is
|
||||||
|
// `last_heartbeat_at`; the bare-name was a sibling typo never rejected
|
||||||
|
// at compile time), `capabilities`, `tags`, `created_at`, `updated_at`.
|
||||||
|
// Two of them had real consumers (AgentDetailPage rendered
|
||||||
|
// `agent.capabilities` and `agent.tags`) — both always rendered the
|
||||||
|
// empty-state branch because the runtime values were always undefined.
|
||||||
|
// Post-D-2 the interface field set matches the Go-emitted JSON exactly:
|
||||||
|
// id, name, hostname, status, last_heartbeat_at, registered_at, os,
|
||||||
|
// architecture, ip_address, version, retired_at?, retired_reason?. A
|
||||||
|
// `agent.capabilities` access is now a TS compile error. The CI guardrail
|
||||||
|
// in .github/workflows/ci.yml (`Forbidden StatusBadge dead-key + TS
|
||||||
|
// phantom-field regression guard (D-1 + D-2)`) blocks reintroduction of
|
||||||
|
// the trimmed field names while explicitly excluding `last_heartbeat_at`
|
||||||
|
// from the `last_heartbeat` regex.
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -69,13 +93,8 @@ export interface Agent {
|
|||||||
architecture: string;
|
architecture: string;
|
||||||
status: string;
|
status: string;
|
||||||
version: string;
|
version: string;
|
||||||
last_heartbeat: string;
|
last_heartbeat_at?: string;
|
||||||
last_heartbeat_at: string;
|
|
||||||
capabilities: string[];
|
|
||||||
tags: Record<string, string>;
|
|
||||||
registered_at: string;
|
registered_at: string;
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
// I-004: soft-retirement fields. When retired_at is non-null, the agent is
|
// I-004: soft-retirement fields. When retired_at is non-null, the agent is
|
||||||
// tombstoned — it will never heartbeat again and cascaded targets have been
|
// tombstoned — it will never heartbeat again and cascaded targets have been
|
||||||
// retired alongside it. The retired tab on AgentsPage uses these to show the
|
// retired alongside it. The retired tab on AgentsPage uses these to show the
|
||||||
@@ -152,9 +171,17 @@ export interface Job {
|
|||||||
* without chasing server logs.
|
* without chasing server logs.
|
||||||
*
|
*
|
||||||
* `sent_at` and `error` are the pre-I-005 audit fields on the backend struct.
|
* `sent_at` and `error` are the pre-I-005 audit fields on the backend struct.
|
||||||
* `subject` is a historical frontend-only field the backend never emits; it's
|
*
|
||||||
* kept optional so legacy fixtures and the pendingNotif test mock still type
|
* D-2 (diff-05x06-caba9eb3620e, master): pre-D-2 this interface carried a
|
||||||
* correctly without forcing a rewrite of every existing consumer.
|
* phantom `subject?: string` field documented as "kept optional so legacy
|
||||||
|
* fixtures and the pendingNotif test mock still type correctly without
|
||||||
|
* forcing a rewrite of every existing consumer." The Go-side struct
|
||||||
|
* (`internal/domain/notification.go::NotificationEvent`) never emitted it,
|
||||||
|
* so `n.subject` was always `undefined` at runtime. The one real consumer
|
||||||
|
* (NotificationsPage rendering `{n.message || n.subject}`) always fell
|
||||||
|
* through to `n.message`. Post-D-2 the field is removed; the consumer
|
||||||
|
* drops the dead `|| n.subject` fallback and the test fixtures drop the
|
||||||
|
* dead `subject:` initializer. The CI guardrail blocks reintroduction.
|
||||||
*
|
*
|
||||||
* Status values follow the backend NotificationStatus constants:
|
* Status values follow the backend NotificationStatus constants:
|
||||||
* pending · sent · failed · dead · read
|
* pending · sent · failed · dead · read
|
||||||
@@ -167,7 +194,6 @@ export interface Notification {
|
|||||||
type: string;
|
type: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
subject?: string;
|
|
||||||
message: string;
|
message: string;
|
||||||
status: string;
|
status: string;
|
||||||
certificate_id?: string;
|
certificate_id?: string;
|
||||||
@@ -262,13 +288,21 @@ export interface RenewalPolicy {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D-2 (diff-05x06-97fab8783a5c, master): pre-D-2 this interface declared
|
||||||
|
// a required `status: string` field that the Go-side struct
|
||||||
|
// (`internal/domain/connector.go::Issuer`) never emitted — the Go struct
|
||||||
|
// has only `Enabled bool`. The TS comment claimed "status is derived from
|
||||||
|
// this" but no derivation ever existed: `IssuersPage.tsx` read
|
||||||
|
// `issuer.status || 'Unknown'` and always rendered 'Unknown'. Post-D-2
|
||||||
|
// the phantom is removed; render sites derive the displayed status from
|
||||||
|
// `enabled` (and optionally `test_status`) at the call site. The CI
|
||||||
|
// guardrail in .github/workflows/ci.yml blocks reintroduction.
|
||||||
export interface Issuer {
|
export interface Issuer {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
status: string;
|
/** Backend returns enabled boolean; render sites derive status labels from this */
|
||||||
/** Backend returns enabled boolean; status is derived from this */
|
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Timestamp of last connection test */
|
/** Timestamp of last connection test */
|
||||||
last_tested_at?: string;
|
last_tested_at?: string;
|
||||||
@@ -280,6 +314,14 @@ export interface Issuer {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D-2 (diff-05x06-2044a46f4dd0, master): pre-D-2 this interface lacked
|
||||||
|
// `retired_at` and `retired_reason` even though the Go-side struct
|
||||||
|
// (`internal/domain/connector.go::DeploymentTarget`) emits both as part
|
||||||
|
// of the I-004 soft-retirement model. Consumers wanting to surface the
|
||||||
|
// retired state had to escape via `(target as any).retired_at`. Post-D-2
|
||||||
|
// the TS interface declares both as optional nullable strings, mirroring
|
||||||
|
// the Agent retirement-fields shape (an Agent retire cascades to all
|
||||||
|
// associated Targets per service.RetireAgent → repository.RetireTarget).
|
||||||
export interface Target {
|
export interface Target {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -290,6 +332,8 @@ export interface Target {
|
|||||||
last_tested_at?: string;
|
last_tested_at?: string;
|
||||||
test_status?: string;
|
test_status?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
|
retired_at?: string | null;
|
||||||
|
retired_reason?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
@@ -396,6 +440,19 @@ export interface IssuanceRateDataPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Discovery types
|
// Discovery types
|
||||||
|
//
|
||||||
|
// D-2 (diff-05x06-85ab6b98a2f7, master): pre-D-2 this interface lacked
|
||||||
|
// `pem_data` even though the Go-side struct
|
||||||
|
// (`internal/domain/discovery.go::DiscoveredCertificate.PEMData`,
|
||||||
|
// json:"pem_data,omitempty") emits it on the wire. The field is
|
||||||
|
// populated by the agent's filesystem scanner
|
||||||
|
// (cmd/agent/main.go::buildDiscoveryReport), the cloud-secret-manager
|
||||||
|
// connectors (e.g. internal/connector/discovery/azurekv/azurekv.go), and
|
||||||
|
// the repo SELECT that materialises the row from PostgreSQL. Post-D-2
|
||||||
|
// the TS interface declares `pem_data?: string`, optional because the
|
||||||
|
// Go side uses `omitempty` (empty string → not emitted). Performance
|
||||||
|
// follow-up: the LIST endpoint loads pem_data via the same repo SELECT;
|
||||||
|
// a future change should gate emission on the per-id detail path only.
|
||||||
export interface DiscoveredCertificate {
|
export interface DiscoveredCertificate {
|
||||||
id: string;
|
id: string;
|
||||||
fingerprint_sha256: string;
|
fingerprint_sha256: string;
|
||||||
@@ -409,6 +466,7 @@ export interface DiscoveredCertificate {
|
|||||||
key_algorithm: string;
|
key_algorithm: string;
|
||||||
key_size: number;
|
key_size: number;
|
||||||
is_ca: boolean;
|
is_ca: boolean;
|
||||||
|
pem_data?: string;
|
||||||
source_path: string;
|
source_path: string;
|
||||||
source_format: string;
|
source_format: string;
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ export function formatDateTime(iso: string | undefined | null): string {
|
|||||||
return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function timeAgo(iso: string): string {
|
// D-2 (master): widened to accept undefined/null since several Go-side
|
||||||
|
// timestamp fields are emitted as `omitempty` (e.g. Agent.last_heartbeat_at
|
||||||
|
// for never-heartbeated agents). Pre-D-2 the TS interfaces declared
|
||||||
|
// these as required strings, masking the case; post-D-2 the optionality
|
||||||
|
// is propagated end-to-end and the helper handles it explicitly.
|
||||||
|
export function timeAgo(iso: string | undefined | null): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const then = new Date(iso).getTime();
|
const then = new Date(iso).getTime();
|
||||||
|
|||||||
@@ -5,6 +5,24 @@ interface Column<T> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// F-1 closure (cat-k-e85d1099b2d7): DataTable was a render-only
|
||||||
|
// component pre-F-1 — every consumer page handed it the first 50
|
||||||
|
// rows from a paginated endpoint and there was no way for the
|
||||||
|
// operator to advance. The backend has always returned `{data,
|
||||||
|
// total, page, per_page}` but the frontend never surfaced page
|
||||||
|
// 2+. The pagination prop below opt-ins reusable controls in the
|
||||||
|
// table footer; CertificatesPage is the first consumer (and the
|
||||||
|
// audit's flagged page), but TargetsPage / IssuersPage / others
|
||||||
|
// can adopt by passing the same prop.
|
||||||
|
interface PaginationProps {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPerPageChange?: (perPage: number) => void;
|
||||||
|
perPageOptions?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
interface DataTableProps<T> {
|
interface DataTableProps<T> {
|
||||||
columns: Column<T>[];
|
columns: Column<T>[];
|
||||||
data: T[];
|
data: T[];
|
||||||
@@ -15,9 +33,10 @@ interface DataTableProps<T> {
|
|||||||
selectable?: boolean;
|
selectable?: boolean;
|
||||||
selectedKeys?: Set<string>;
|
selectedKeys?: Set<string>;
|
||||||
onSelectionChange?: (keys: Set<string>) => void;
|
onSelectionChange?: (keys: Set<string>) => void;
|
||||||
|
pagination?: PaginationProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange }: DataTableProps<T>) {
|
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16 text-ink-muted">
|
<div className="flex items-center justify-center py-16 text-ink-muted">
|
||||||
@@ -111,8 +130,69 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{pagination && pagination.total > 0 && (
|
||||||
|
<PaginationControls {...pagination} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Column };
|
// F-1 closure (cat-k-e85d1099b2d7): pagination footer for DataTable
|
||||||
|
// consumers that want prev/next + page counter + per-page selector
|
||||||
|
// against a paginated backend response. Disabling logic guards the
|
||||||
|
// boundaries (prev disabled on page 1; next disabled when page *
|
||||||
|
// per_page >= total).
|
||||||
|
function PaginationControls({ page, perPage, total, onPageChange, onPerPageChange, perPageOptions }: PaginationProps) {
|
||||||
|
const start = total === 0 ? 0 : (page - 1) * perPage + 1;
|
||||||
|
const end = Math.min(page * perPage, total);
|
||||||
|
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
||||||
|
const isFirst = page <= 1;
|
||||||
|
const isLast = page >= lastPage;
|
||||||
|
const options = perPageOptions ?? [25, 50, 100, 200];
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-t border-surface-border px-4 py-3 text-sm text-ink-muted">
|
||||||
|
<span>
|
||||||
|
Showing <span className="font-medium text-ink">{start}</span>–<span className="font-medium text-ink">{end}</span> of <span className="font-medium text-ink">{total.toLocaleString()}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{onPerPageChange && (
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span>Rows per page:</span>
|
||||||
|
<select
|
||||||
|
value={perPage}
|
||||||
|
onChange={e => onPerPageChange(Number(e.target.value))}
|
||||||
|
className="rounded border border-surface-border bg-white px-2 py-1 text-xs text-ink focus:outline-none focus:border-brand-400"
|
||||||
|
>
|
||||||
|
{options.map(opt => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<span className="text-xs">
|
||||||
|
Page <span className="font-medium text-ink">{page}</span> of <span className="font-medium text-ink">{lastPage}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={isFirst}
|
||||||
|
className="rounded border border-surface-border px-3 py-1 text-xs text-ink hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={isLast}
|
||||||
|
className="rounded border border-surface-border px-3 py-1 text-xs text-ink hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Column, PaginationProps };
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const nav = [
|
|||||||
{ to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
|
{ to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
|
||||||
{ to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
|
{ to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
|
||||||
{ to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
|
{ to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
|
||||||
|
{ to: '/renewal-policies', label: 'Renewal Policies', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
|
||||||
{ to: '/profiles', label: 'Profiles', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
{ to: '/profiles', label: 'Profiles', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||||
{ to: '/issuers', label: 'Issuers', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
{ to: '/issuers', label: 'Issuers', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
||||||
{ to: '/targets', label: 'Targets', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
|
{ to: '/targets', label: 'Targets', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// D-1 master — StatusBadge enum-coverage contract
|
||||||
|
//
|
||||||
|
// The single source of truth for what Go actually emits on the wire.
|
||||||
|
// Update this if the Go enums change (and the StatusBadge will go red
|
||||||
|
// here BEFORE any user sees a wrong color in production).
|
||||||
|
//
|
||||||
|
// Sources (mirror the Go const blocks verbatim — wire VALUES, not Go
|
||||||
|
// identifier names):
|
||||||
|
// AgentStatus — internal/domain/connector.go:174-176
|
||||||
|
// CertificateStatus — internal/domain/certificate.go:50-57
|
||||||
|
// JobStatus — internal/domain/job.go:43-49
|
||||||
|
// NotificationStatus— internal/domain/notification.go:51-55
|
||||||
|
// DiscoveryStatus — internal/domain/discovery.go:13-17
|
||||||
|
// HealthStatus — internal/domain/health_check.go:9-13
|
||||||
|
//
|
||||||
|
// Issuer 'Enabled' / 'Disabled' are NOT a Go enum — they're frontend-
|
||||||
|
// synthesized labels mapped from `Issuer.enabled bool` at the call
|
||||||
|
// site (TargetsPage.tsx similarly). Pinned in a separate group below.
|
||||||
|
//
|
||||||
|
// Pre-D-1 drift this test would have caught:
|
||||||
|
// - Agent: StatusBadge had 'Stale' (never emitted), missing 'Degraded'
|
||||||
|
// (real). Degraded agents rendered as default neutral grey, hiding
|
||||||
|
// attention-needed state from operators.
|
||||||
|
// - Notification: StatusBadge missing 'dead' (retries exhausted).
|
||||||
|
// Dead-letter notifications rendered as default neutral, visually
|
||||||
|
// equated with 'read' (operator-acknowledged).
|
||||||
|
// - Certificate: StatusBadge had 'PendingIssuance' (never emitted).
|
||||||
|
// Dead key, latent confusion vector if anyone copies it as
|
||||||
|
// canonical.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
const ENUMS_FROM_GO = {
|
||||||
|
AgentStatus: ['Online', 'Offline', 'Degraded'] as const,
|
||||||
|
CertificateStatus: ['Pending', 'Active', 'Expiring', 'Expired',
|
||||||
|
'RenewalInProgress', 'Failed', 'Revoked', 'Archived'] as const,
|
||||||
|
JobStatus: ['Pending', 'AwaitingCSR', 'AwaitingApproval', 'Running',
|
||||||
|
'Completed', 'Failed', 'Cancelled'] as const,
|
||||||
|
NotificationStatus: ['pending', 'sent', 'failed', 'dead', 'read'] as const,
|
||||||
|
DiscoveryStatus: ['Unmanaged', 'Managed', 'Dismissed'] as const,
|
||||||
|
HealthStatus: ['healthy', 'degraded', 'down', 'cert_mismatch', 'unknown'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Frontend-synthesized labels — not in any Go enum, but surfaced via
|
||||||
|
// StatusBadge from real call sites (TargetsPage, AgentGroupsPage etc.)
|
||||||
|
// and therefore part of the visual contract this component owns.
|
||||||
|
const FRONTEND_SYNTHESIZED = ['Enabled', 'Disabled'] as const;
|
||||||
|
|
||||||
|
describe('StatusBadge — enum-coverage contract (D-1 master)', () => {
|
||||||
|
// Iterate every Go-emitted value across every enum and assert the
|
||||||
|
// rendered <span> carries a class OTHER than the default 'badge-neutral'.
|
||||||
|
// EXCEPT for legitimately-neutral statuses (Archived, Cancelled,
|
||||||
|
// Dismissed, read, unknown) which are intentionally neutral by UX
|
||||||
|
// design — those are pinned by a separate sub-test below.
|
||||||
|
const INTENTIONALLY_NEUTRAL = new Set(['Archived', 'Cancelled', 'Dismissed', 'read', 'unknown']);
|
||||||
|
|
||||||
|
for (const [enumName, values] of Object.entries(ENUMS_FROM_GO)) {
|
||||||
|
for (const v of values) {
|
||||||
|
it(`${enumName}: '${v}' renders a recognised class (no fallthrough)`, () => {
|
||||||
|
const { container } = render(<StatusBadge status={v} />);
|
||||||
|
const span = container.querySelector('span');
|
||||||
|
expect(span).not.toBeNull();
|
||||||
|
const cls = span!.className;
|
||||||
|
if (INTENTIONALLY_NEUTRAL.has(v)) {
|
||||||
|
// Neutral is the right semantic answer for terminal-acknowledged
|
||||||
|
// states — but it must come from an EXPLICIT mapping, not the
|
||||||
|
// dictionary-default fallthrough. Asserting a 'badge-neutral'
|
||||||
|
// class here pins that the explicit entry exists; if someone
|
||||||
|
// deletes it, this still passes (because the default is also
|
||||||
|
// 'badge-neutral'). The negative assertion in the dead-keys
|
||||||
|
// sub-test below catches the deletion case.
|
||||||
|
expect(cls).toBe('badge badge-neutral');
|
||||||
|
} else {
|
||||||
|
expect(cls).toMatch(/badge-(success|warning|danger|info)/);
|
||||||
|
expect(cls).not.toBe('badge badge-neutral');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of FRONTEND_SYNTHESIZED) {
|
||||||
|
it(`Frontend-synthesized '${v}' has an explicit StatusBadge mapping`, () => {
|
||||||
|
const { container } = render(<StatusBadge status={v} />);
|
||||||
|
const cls = container.querySelector('span')!.className;
|
||||||
|
// 'Disabled' is intentionally neutral; 'Enabled' is success.
|
||||||
|
expect(cls).toMatch(/badge-(success|warning|danger|info|neutral)/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative contract: the dead keys we deleted MUST fall through to the
|
||||||
|
// default. If a future PR re-adds 'Stale' or 'PendingIssuance' to
|
||||||
|
// statusStyles, this test will surface it because the rendered class
|
||||||
|
// will no longer be 'badge badge-neutral' (it'd be the explicit value
|
||||||
|
// someone re-added, e.g. 'badge-warning').
|
||||||
|
it.each(['Stale', 'PendingIssuance'])(
|
||||||
|
"dead key '%s' falls through to neutral default (no explicit mapping)",
|
||||||
|
(deadKey) => {
|
||||||
|
const { container } = render(<StatusBadge status={deadKey} />);
|
||||||
|
expect(container.querySelector('span')!.className).toBe('badge badge-neutral');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Specific danger-class contracts (UX correctness, not just non-default).
|
||||||
|
// These pin the operator-attention semantics. If anyone changes 'dead'
|
||||||
|
// or 'Degraded' away from these classes, the operator's perception of
|
||||||
|
// "this needs my attention" changes — these are the highest-stakes
|
||||||
|
// visual semantics in the dashboard.
|
||||||
|
it("Notification 'dead' renders as danger (operator attention required)", () => {
|
||||||
|
const { container } = render(<StatusBadge status="dead" />);
|
||||||
|
expect(container.querySelector('span')!.className).toContain('badge-danger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Agent 'Degraded' renders as warning (degradation, not failure)", () => {
|
||||||
|
const { container } = render(<StatusBadge status="Degraded" />);
|
||||||
|
expect(container.querySelector('span')!.className).toContain('badge-warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unknown statuses fall through to neutral. The string is still
|
||||||
|
// displayed verbatim so an operator can see "what is this?" rather
|
||||||
|
// than nothing at all.
|
||||||
|
it('unknown status string renders as neutral but preserves the label text', () => {
|
||||||
|
const { container } = render(<StatusBadge status="SomeFutureStatus" />);
|
||||||
|
const span = container.querySelector('span');
|
||||||
|
expect(span!.className).toBe('badge badge-neutral');
|
||||||
|
expect(span!.textContent).toBe('SomeFutureStatus');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,41 @@
|
|||||||
|
// StatusBadge — single source of truth for the certctl dashboard's
|
||||||
|
// per-status color mapping. Keys are the EXACT wire values Go emits
|
||||||
|
// (case-sensitive). Update this file when a new status value lands on
|
||||||
|
// the Go side; StatusBadge.test.tsx walks every value and will go red
|
||||||
|
// before users see a default-grey "what is happening?" badge.
|
||||||
|
//
|
||||||
|
// D-1 master closure (cat-d-359e92c20cbf, cat-d-9f4c8e4a91f1,
|
||||||
|
// cat-d-1447e04732e7, cat-f-cert_detail_page_key_render_fallback,
|
||||||
|
// cat-f-ae0d06b6588f) fixed the pre-master drift:
|
||||||
|
// - Agent: 'Stale' (never emitted) → 'Degraded' (real value);
|
||||||
|
// `internal/domain/connector.go::AgentStatusDegraded = "Degraded"`.
|
||||||
|
// - Notification: added 'dead' (was falling through to neutral);
|
||||||
|
// `internal/domain/notification.go::NotificationStatusDead = "dead"`.
|
||||||
|
// - Certificate: dropped dead 'PendingIssuance' key — the real
|
||||||
|
// `CertificateStatusPending = "Pending"` is mapped under Job
|
||||||
|
// statuses below.
|
||||||
|
//
|
||||||
|
// Source-of-truth references (re-verify if the Go enum changes):
|
||||||
|
// - internal/domain/connector.go::AgentStatus*
|
||||||
|
// - internal/domain/certificate.go::CertificateStatus*
|
||||||
|
// - internal/domain/job.go::JobStatus*
|
||||||
|
// - internal/domain/notification.go::NotificationStatus*
|
||||||
|
// - internal/domain/discovery.go::DiscoveryStatus*
|
||||||
|
// - internal/domain/health_check.go::HealthStatus*
|
||||||
|
//
|
||||||
|
// Issuer 'Enabled'/'Disabled' are frontend-synthesized labels (mapped
|
||||||
|
// from the `enabled bool` field on the Issuer struct), not Go-emitted
|
||||||
|
// enum values, but they're surfaced via StatusBadge for consistency.
|
||||||
const statusStyles: Record<string, string> = {
|
const statusStyles: Record<string, string> = {
|
||||||
// Certificate statuses
|
// Certificate statuses (internal/domain/certificate.go::CertificateStatus*)
|
||||||
Active: 'badge-success',
|
Active: 'badge-success',
|
||||||
Expiring: 'badge-warning',
|
Expiring: 'badge-warning',
|
||||||
Expired: 'badge-danger',
|
Expired: 'badge-danger',
|
||||||
RenewalInProgress: 'badge-info',
|
RenewalInProgress: 'badge-info',
|
||||||
PendingIssuance: 'badge-info',
|
|
||||||
Archived: 'badge-neutral',
|
Archived: 'badge-neutral',
|
||||||
Revoked: 'badge-danger',
|
Revoked: 'badge-danger',
|
||||||
// Job statuses
|
// Job statuses (internal/domain/job.go::JobStatus*) — note: 'Pending' is
|
||||||
|
// shared between CertificateStatusPending and JobStatusPending.
|
||||||
Pending: 'badge-info',
|
Pending: 'badge-info',
|
||||||
AwaitingCSR: 'badge-info',
|
AwaitingCSR: 'badge-info',
|
||||||
AwaitingApproval: 'badge-info',
|
AwaitingApproval: 'badge-info',
|
||||||
@@ -15,23 +43,30 @@ const statusStyles: Record<string, string> = {
|
|||||||
Completed: 'badge-success',
|
Completed: 'badge-success',
|
||||||
Failed: 'badge-danger',
|
Failed: 'badge-danger',
|
||||||
Cancelled: 'badge-neutral',
|
Cancelled: 'badge-neutral',
|
||||||
// Agent statuses
|
// Agent statuses (internal/domain/connector.go::AgentStatus*) — D-1:
|
||||||
|
// 'Degraded' replaces the never-emitted 'Stale' from pre-D-1 (the Go
|
||||||
|
// domain has only Online / Offline / Degraded; mapping 'Stale' yellow
|
||||||
|
// and letting 'Degraded' fall through to neutral hid degraded agents).
|
||||||
Online: 'badge-success',
|
Online: 'badge-success',
|
||||||
Offline: 'badge-danger',
|
Offline: 'badge-danger',
|
||||||
Stale: 'badge-warning',
|
Degraded: 'badge-warning',
|
||||||
// Discovery statuses
|
// Discovery statuses (internal/domain/discovery.go::DiscoveryStatus*)
|
||||||
Unmanaged: 'badge-warning',
|
Unmanaged: 'badge-warning',
|
||||||
Managed: 'badge-success',
|
Managed: 'badge-success',
|
||||||
Dismissed: 'badge-neutral',
|
Dismissed: 'badge-neutral',
|
||||||
// Issuer statuses
|
// Issuer statuses (frontend-synthesized from Issuer.enabled bool)
|
||||||
Enabled: 'badge-success',
|
Enabled: 'badge-success',
|
||||||
Disabled: 'badge-neutral',
|
Disabled: 'badge-neutral',
|
||||||
// Notification statuses
|
// Notification statuses (internal/domain/notification.go::NotificationStatus*)
|
||||||
|
// — D-2: added 'dead' (retries exhausted, dead-letter queue). Pre-D-2 it
|
||||||
|
// fell through to neutral, visually equating "needs operator attention"
|
||||||
|
// with "operator already acknowledged" (read).
|
||||||
sent: 'badge-success',
|
sent: 'badge-success',
|
||||||
pending: 'badge-warning',
|
pending: 'badge-warning',
|
||||||
failed: 'badge-danger',
|
failed: 'badge-danger',
|
||||||
|
dead: 'badge-danger',
|
||||||
read: 'badge-neutral',
|
read: 'badge-neutral',
|
||||||
// Health check statuses
|
// Health check statuses (internal/domain/health_check.go::HealthStatus*)
|
||||||
healthy: 'badge-success',
|
healthy: 'badge-success',
|
||||||
degraded: 'badge-warning',
|
degraded: 'badge-warning',
|
||||||
down: 'badge-danger',
|
down: 'badge-danger',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import AgentDetailPage from './pages/AgentDetailPage';
|
|||||||
import JobsPage from './pages/JobsPage';
|
import JobsPage from './pages/JobsPage';
|
||||||
import NotificationsPage from './pages/NotificationsPage';
|
import NotificationsPage from './pages/NotificationsPage';
|
||||||
import PoliciesPage from './pages/PoliciesPage';
|
import PoliciesPage from './pages/PoliciesPage';
|
||||||
|
import RenewalPoliciesPage from './pages/RenewalPoliciesPage';
|
||||||
import IssuersPage from './pages/IssuersPage';
|
import IssuersPage from './pages/IssuersPage';
|
||||||
import TargetsPage from './pages/TargetsPage';
|
import TargetsPage from './pages/TargetsPage';
|
||||||
import ProfilesPage from './pages/ProfilesPage';
|
import ProfilesPage from './pages/ProfilesPage';
|
||||||
@@ -62,6 +63,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="jobs/:id" element={<JobDetailPage />} />
|
<Route path="jobs/:id" element={<JobDetailPage />} />
|
||||||
<Route path="notifications" element={<NotificationsPage />} />
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
<Route path="policies" element={<PoliciesPage />} />
|
<Route path="policies" element={<PoliciesPage />} />
|
||||||
|
<Route path="renewal-policies" element={<RenewalPoliciesPage />} />
|
||||||
<Route path="profiles" element={<ProfilesPage />} />
|
<Route path="profiles" element={<ProfilesPage />} />
|
||||||
<Route path="issuers" element={<IssuersPage />} />
|
<Route path="issuers" element={<IssuersPage />} />
|
||||||
<Route path="issuers/:id" element={<IssuerDetailPage />} />
|
<Route path="issuers/:id" element={<IssuerDetailPage />} />
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function heartbeatStatus(lastHeartbeat: string): string {
|
// D-2 (master): the `lastHeartbeat` parameter accepts undefined because
|
||||||
|
// the Go-side struct emits `last_heartbeat_at` as `omitempty` (a never-
|
||||||
|
// heartbeated agent omits the field entirely). Pre-D-2 the TS interface
|
||||||
|
// declared the field as required, masking this case. Post-D-2 the empty
|
||||||
|
// case is explicit at both the type level and the function signature.
|
||||||
|
function heartbeatStatus(lastHeartbeat: string | undefined): string {
|
||||||
if (!lastHeartbeat) return 'Offline';
|
if (!lastHeartbeat) return 'Offline';
|
||||||
const ago = Date.now() - new Date(lastHeartbeat).getTime();
|
const ago = Date.now() - new Date(lastHeartbeat).getTime();
|
||||||
if (ago < 5 * 60 * 1000) return 'Online';
|
if (ago < 5 * 60 * 1000) return 'Online';
|
||||||
@@ -89,8 +94,15 @@ export default function AgentDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
) : '—'
|
) : '—'
|
||||||
} />
|
} />
|
||||||
<InfoRow label="Registered" value={formatDateTime(agent.created_at)} />
|
{/* D-2 (master): pre-D-2 these rows used `agent.created_at`
|
||||||
<InfoRow label="Updated" value={formatDateTime(agent.updated_at)} />
|
+ `agent.updated_at` — TS phantoms that the Go-side
|
||||||
|
struct (`internal/domain/connector.go::Agent`) never
|
||||||
|
emitted. The "Registered" row now reads from the real
|
||||||
|
Go-emitted `registered_at` field; the "Updated" row is
|
||||||
|
dropped because the Go struct has no equivalent
|
||||||
|
update-timestamp on Agent (heartbeats are tracked via
|
||||||
|
`last_heartbeat_at` above). */}
|
||||||
|
<InfoRow label="Registered" value={formatDateTime(agent.registered_at)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* System Info */}
|
{/* System Info */}
|
||||||
@@ -100,26 +112,17 @@ export default function AgentDetailPage() {
|
|||||||
<InfoRow label="Architecture" value={agent.architecture || '—'} />
|
<InfoRow label="Architecture" value={agent.architecture || '—'} />
|
||||||
<InfoRow label="IP Address" value={<span className="font-mono text-xs">{agent.ip_address || '—'}</span>} />
|
<InfoRow label="IP Address" value={<span className="font-mono text-xs">{agent.ip_address || '—'}</span>} />
|
||||||
<InfoRow label="Agent Version" value={agent.version || '—'} />
|
<InfoRow label="Agent Version" value={agent.version || '—'} />
|
||||||
{agent.capabilities?.length ? (
|
{/* D-2 (master): the previous "Capabilities" + "Tags" sections
|
||||||
<div className="mt-4">
|
rendered `agent.capabilities` and `agent.tags`, both of
|
||||||
<p className="text-xs text-ink-muted mb-2">Capabilities</p>
|
which were TS phantom fields the Go-side struct
|
||||||
<div className="flex flex-wrap gap-2">
|
(`internal/domain/connector.go::Agent`) never emitted.
|
||||||
{agent.capabilities.map((c) => (
|
Both sections always rendered as the empty-state fallback
|
||||||
<span key={c} className="badge badge-info">{c}</span>
|
(the `?.length ?` and `Object.keys(...).length > 0`
|
||||||
))}
|
guards always evaluated false). Removed in D-2 master.
|
||||||
</div>
|
If/when the backend grows real Agent metadata fields
|
||||||
</div>
|
(capabilities advertised at heartbeat time, operator-
|
||||||
) : null}
|
applied tags), re-introduce here in the same commit that
|
||||||
{agent.tags && Object.keys(agent.tags).length > 0 ? (
|
ships the Go-side change. */}
|
||||||
<div className="mt-4">
|
|
||||||
<p className="text-xs text-ink-muted mb-2">Tags</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Object.entries(agent.tags).map(([k, v]) => (
|
|
||||||
<span key={k} className="badge badge-neutral">{k}: {v}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getAgentGroups, deleteAgentGroup, createAgentGroup } from '../api/client';
|
import { getAgentGroups, deleteAgentGroup, createAgentGroup, updateAgentGroup } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
@@ -144,9 +144,115 @@ function CreateAgentGroupModal({ isOpen, onClose, onSuccess, isLoading, error }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditAgentGroupModal — B-1 master closure (cat-b-31ceb6aaa9f1).
|
||||||
|
// Mirrors CreateAgentGroupModal; pre-populates from the editing group;
|
||||||
|
// calls updateAgentGroup(id, fields) to close the destructive-rename
|
||||||
|
// hazard. Membership-rule fields (match_os, match_architecture,
|
||||||
|
// match_ip_cidr, match_version) are editable like the rest — operators
|
||||||
|
// frequently want to widen/narrow group membership without recreating.
|
||||||
|
interface EditAgentGroupModalProps {
|
||||||
|
group: AgentGroup | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditAgentGroupModal({ group, onClose, onSuccess, isLoading, error }: EditAgentGroupModalProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [matchOs, setMatchOs] = useState('');
|
||||||
|
const [matchArch, setMatchArch] = useState('');
|
||||||
|
const [matchIpCidr, setMatchIpCidr] = useState('');
|
||||||
|
const [matchVersion, setMatchVersion] = useState('');
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (group) {
|
||||||
|
setName(group.name);
|
||||||
|
setDescription(group.description || '');
|
||||||
|
setMatchOs(group.match_os || '');
|
||||||
|
setMatchArch(group.match_architecture || '');
|
||||||
|
setMatchIpCidr(group.match_ip_cidr || '');
|
||||||
|
setMatchVersion(group.match_version || '');
|
||||||
|
setEnabled(group.enabled);
|
||||||
|
}
|
||||||
|
}, [group]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!group || !name.trim()) return;
|
||||||
|
await updateAgentGroup(group.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
match_os: matchOs.trim(),
|
||||||
|
match_architecture: matchArch.trim(),
|
||||||
|
match_ip_cidr: matchIpCidr.trim(),
|
||||||
|
match_version: matchVersion.trim(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!group) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||||
|
<h2 className="text-lg font-semibold text-ink mb-4">Edit Agent Group</h2>
|
||||||
|
<p className="text-xs text-ink-muted mb-4 font-mono">{group.id}</p>
|
||||||
|
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)} required
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Description</label>
|
||||||
|
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={2}
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Match OS</label>
|
||||||
|
<input value={matchOs} onChange={e => setMatchOs(e.target.value)} placeholder="linux"
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Match Architecture</label>
|
||||||
|
<input value={matchArch} onChange={e => setMatchArch(e.target.value)} placeholder="amd64"
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Match IP CIDR</label>
|
||||||
|
<input value={matchIpCidr} onChange={e => setMatchIpCidr(e.target.value)} placeholder="10.0.0.0/24"
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Match Version</label>
|
||||||
|
<input value={matchVersion} onChange={e => setMatchVersion(e.target.value)} placeholder="v2.0.x"
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-ink">
|
||||||
|
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<button type="submit" disabled={isLoading} className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
{isLoading ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AgentGroupsPage() {
|
export default function AgentGroupsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [editingGroup, setEditingGroup] = useState<AgentGroup | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['agent-groups'],
|
queryKey: ['agent-groups'],
|
||||||
@@ -166,6 +272,14 @@ export default function AgentGroupsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<AgentGroup> }) => updateAgentGroup(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-groups'] });
|
||||||
|
setEditingGroup(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns: Column<AgentGroup>[] = [
|
const columns: Column<AgentGroup>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
@@ -214,12 +328,20 @@ export default function AgentGroupsPage() {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
render: (g) => (
|
render: (g) => (
|
||||||
<button
|
<div className="flex gap-3 justify-end">
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete group ${g.name}?`)) deleteMutation.mutate(g.id); }}
|
<button
|
||||||
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
onClick={(e) => { e.stopPropagation(); setEditingGroup(g); }}
|
||||||
>
|
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
|
||||||
Delete
|
>
|
||||||
</button>
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete group ${g.name}?`)) deleteMutation.mutate(g.id); }}
|
||||||
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -252,6 +374,16 @@ export default function AgentGroupsPage() {
|
|||||||
isLoading={createMutation.isPending}
|
isLoading={createMutation.isPending}
|
||||||
error={createMutation.error ? (createMutation.error as Error).message : null}
|
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||||
/>
|
/>
|
||||||
|
<EditAgentGroupModal
|
||||||
|
group={editingGroup}
|
||||||
|
onClose={() => setEditingGroup(null)}
|
||||||
|
onSuccess={() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-groups'] });
|
||||||
|
setEditingGroup(null);
|
||||||
|
}}
|
||||||
|
isLoading={updateMutation.isPending}
|
||||||
|
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import ErrorState from '../components/ErrorState';
|
|||||||
import { timeAgo } from '../api/utils';
|
import { timeAgo } from '../api/utils';
|
||||||
import type { Agent, AgentDependencyCounts } from '../api/types';
|
import type { Agent, AgentDependencyCounts } from '../api/types';
|
||||||
|
|
||||||
function heartbeatStatus(lastHeartbeat: string): string {
|
// D-2 (master): the `lastHeartbeat` parameter accepts undefined because
|
||||||
|
// the Go-side struct emits `last_heartbeat_at` as `omitempty` (never-
|
||||||
|
// heartbeated agents omit the field). Mirror of the same helper in
|
||||||
|
// AgentDetailPage.tsx — kept as twin definitions to avoid a shared-
|
||||||
|
// helper PR detour during D-2; consolidate in a follow-up if desired.
|
||||||
|
function heartbeatStatus(lastHeartbeat: string | undefined): string {
|
||||||
if (!lastHeartbeat) return 'Offline';
|
if (!lastHeartbeat) return 'Offline';
|
||||||
const ago = Date.now() - new Date(lastHeartbeat).getTime();
|
const ago = Date.now() - new Date(lastHeartbeat).getTime();
|
||||||
if (ago < 5 * 60 * 1000) return 'Online';
|
if (ago < 5 * 60 * 1000) return 'Online';
|
||||||
|
|||||||
@@ -380,11 +380,20 @@ export default function CertificateDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive certificate metadata from latest version (backend doesn't include these on the cert object)
|
// Derive certificate metadata from latest version. Per-issuance fields
|
||||||
|
// (serial_number, fingerprint_sha256, key_algorithm, key_size, issued_at)
|
||||||
|
// live on `CertificateVersion`, NOT on `ManagedCertificate` — the Go
|
||||||
|
// domain has always been this way; the TS interface used to lie about
|
||||||
|
// it via optional `cert.X?` declarations that always returned undefined
|
||||||
|
// on list responses (D-5 / cat-f-ae0d06b6588f). Post-D-5 the TS type
|
||||||
|
// makes the missing-data case explicit, and every read goes through
|
||||||
|
// `latestVersion?.field` here.
|
||||||
const latestVersion = versions?.data?.[0];
|
const latestVersion = versions?.data?.[0];
|
||||||
const serialNumber = cert.serial_number || latestVersion?.serial_number;
|
const serialNumber = latestVersion?.serial_number;
|
||||||
const fingerprintSha256 = cert.fingerprint_sha256 || latestVersion?.fingerprint_sha256;
|
const fingerprintSha256 = latestVersion?.fingerprint_sha256;
|
||||||
const issuedAt = cert.issued_at || latestVersion?.not_before;
|
const issuedAt = latestVersion?.not_before;
|
||||||
|
const keyAlgorithm = latestVersion?.key_algorithm;
|
||||||
|
const keySize = latestVersion?.key_size;
|
||||||
|
|
||||||
const days = daysUntil(cert.expires_at);
|
const days = daysUntil(cert.expires_at);
|
||||||
const isRevoked = cert.status === 'Revoked';
|
const isRevoked = cert.status === 'Revoked';
|
||||||
@@ -536,8 +545,13 @@ export default function CertificateDetailPage() {
|
|||||||
<InfoRow label="Fingerprint" value={
|
<InfoRow label="Fingerprint" value={
|
||||||
fingerprintSha256 ? <span className="font-mono text-xs">{fingerprintSha256.slice(0, 24)}...</span> : '—'
|
fingerprintSha256 ? <span className="font-mono text-xs">{fingerprintSha256.slice(0, 24)}...</span> : '—'
|
||||||
} />
|
} />
|
||||||
<InfoRow label="Key Algorithm" value={cert.key_algorithm || '—'} />
|
{/* D-4 (cat-f-cert_detail_page_key_render_fallback): mirror the
|
||||||
<InfoRow label="Key Size" value={cert.key_size ? `${cert.key_size} bits` : '—'} />
|
latestVersion fallback used for serialNumber / fingerprintSha256
|
||||||
|
above. Pre-D-4 these rows accessed `cert.key_algorithm` /
|
||||||
|
`cert.key_size` directly — both phantom Certificate fields per
|
||||||
|
D-5 (cat-f-ae0d06b6588f), so the rows always rendered '—'. */}
|
||||||
|
<InfoRow label="Key Algorithm" value={keyAlgorithm || '—'} />
|
||||||
|
<InfoRow label="Key Size" value={keySize != null ? `${keySize} bits` : '—'} />
|
||||||
{profile?.allowed_ekus && profile.allowed_ekus.length > 0 && (
|
{profile?.allowed_ekus && profile.allowed_ekus.length > 0 && (
|
||||||
<InfoRow label="Extended Key Usage" value={
|
<InfoRow label="Extended Key Usage" value={
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getTeams, getRenewalPolicies, getProfiles, getIssuers, bulkRevokeCertificates } from '../api/client';
|
import { getCertificates, createCertificate, revokeCertificate, getOwners, getTeams, getRenewalPolicies, getProfiles, getIssuers, bulkRevokeCertificates, bulkRenewCertificates, bulkReassignCertificates } from '../api/client';
|
||||||
import { useAuth } from '../components/AuthProvider';
|
import { useAuth } from '../components/AuthProvider';
|
||||||
import { REVOCATION_REASONS } from '../api/types';
|
import { REVOCATION_REASONS } from '../api/types';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -311,22 +311,38 @@ function BulkReassignModal({ ids, onClose, onSuccess }: { ids: string[]; onClose
|
|||||||
queryFn: () => getOwners(),
|
queryFn: () => getOwners(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// L-2 closure (cat-l-8a1fb258a38a): pre-L-2 this looped
|
||||||
|
// `await updateCertificate(id, { owner_id })` over the selection
|
||||||
|
// (N HTTP round-trips). Post-L-2 it's a single POST to
|
||||||
|
// /api/v1/certificates/bulk-reassign. The CI guardrail in
|
||||||
|
// .github/workflows/ci.yml (`Forbidden client-side bulk-action loop
|
||||||
|
// regression guard (L-1)`) catches reintroduction of the loop shape.
|
||||||
const handleReassign = async () => {
|
const handleReassign = async () => {
|
||||||
if (!ownerId) return;
|
if (!ownerId) return;
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setError('');
|
setError('');
|
||||||
let succeeded = 0;
|
setProgress(0);
|
||||||
for (const id of ids) {
|
try {
|
||||||
try {
|
const result = await bulkReassignCertificates({
|
||||||
await updateCertificate(id, { owner_id: ownerId } as Partial<Certificate>);
|
certificate_ids: ids,
|
||||||
succeeded++;
|
owner_id: ownerId,
|
||||||
setProgress(succeeded);
|
});
|
||||||
} catch (err) {
|
setProgress(result.total_reassigned);
|
||||||
setError(`Failed on ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
if (result.total_failed > 0) {
|
||||||
break;
|
const first = result.errors?.[0];
|
||||||
|
setError(
|
||||||
|
`${result.total_failed} of ${result.total_matched} failed${
|
||||||
|
first ? `: ${first.certificate_id} — ${first.error}` : ''
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onSuccess();
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Bulk reassignment failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
}
|
}
|
||||||
if (!error) onSuccess();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -382,6 +398,24 @@ export default function CertificatesPage() {
|
|||||||
const [issuerFilter, setIssuerFilter] = useState('');
|
const [issuerFilter, setIssuerFilter] = useState('');
|
||||||
const [ownerFilter, setOwnerFilter] = useState('');
|
const [ownerFilter, setOwnerFilter] = useState('');
|
||||||
const [profileFilter, setProfileFilter] = useState('');
|
const [profileFilter, setProfileFilter] = useState('');
|
||||||
|
// F-1 closure (cat-e-610251c8f72d): pre-F-1 the page exposed only 5 of
|
||||||
|
// the backend handler's 17 supported query filters. Three new operator-
|
||||||
|
// facing filters added: team_id (already first-class elsewhere),
|
||||||
|
// expires_before (drives the "expiring in N days" workflow), and a
|
||||||
|
// sort selector (defaults to backend ordering). Audit-recommended
|
||||||
|
// minimum-add per the closure rationale; remaining filters
|
||||||
|
// (agent_id, expires_after, created_after, updated_after, cursor,
|
||||||
|
// fields, sort_desc) are deferred until a consumer use case
|
||||||
|
// demands them — over-stuffing the toolbar is its own UX cost.
|
||||||
|
const [teamFilter, setTeamFilter] = useState('');
|
||||||
|
const [expiresBefore, setExpiresBefore] = useState('');
|
||||||
|
const [sortBy, setSortBy] = useState('');
|
||||||
|
// F-1 closure (cat-k-e85d1099b2d7): pre-F-1 the page rendered the
|
||||||
|
// first 50 certs returned by the backend with no way to advance.
|
||||||
|
// The reusable DataTable pagination prop (added in this same
|
||||||
|
// commit) takes the page + per_page state declared here.
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [perPage, setPerPage] = useState(50);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [showBulkRevoke, setShowBulkRevoke] = useState(false);
|
const [showBulkRevoke, setShowBulkRevoke] = useState(false);
|
||||||
@@ -391,13 +425,21 @@ export default function CertificatesPage() {
|
|||||||
const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) });
|
const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) });
|
||||||
const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) });
|
const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) });
|
||||||
const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) });
|
const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) });
|
||||||
|
// F-1 closure: hydrate the team filter dropdown.
|
||||||
|
const { data: teamsFilterData } = useQuery({ queryKey: ['teams-filter'], queryFn: () => getTeams({ per_page: '100' }) });
|
||||||
|
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (statusFilter) params.status = statusFilter;
|
if (statusFilter) params.status = statusFilter;
|
||||||
if (envFilter) params.environment = envFilter;
|
if (envFilter) params.environment = envFilter;
|
||||||
if (issuerFilter) params.issuer_id = issuerFilter;
|
if (issuerFilter) params.issuer_id = issuerFilter;
|
||||||
if (ownerFilter) params.owner_id = ownerFilter;
|
if (ownerFilter) params.owner_id = ownerFilter;
|
||||||
if (profileFilter) params.profile_id = profileFilter;
|
if (profileFilter) params.profile_id = profileFilter;
|
||||||
|
if (teamFilter) params.team_id = teamFilter;
|
||||||
|
if (expiresBefore) params.expires_before = expiresBefore;
|
||||||
|
if (sortBy) params.sort = sortBy;
|
||||||
|
// Pagination (F-1) — re-fetch on page / per_page change.
|
||||||
|
params.page = String(page);
|
||||||
|
params.per_page = String(perPage);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['certificates', params],
|
queryKey: ['certificates', params],
|
||||||
@@ -405,20 +447,32 @@ export default function CertificatesPage() {
|
|||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// L-1 closure (cat-l-fa0c1ac07ab5): pre-L-1 this looped
|
||||||
|
// `await triggerRenewal(ids[i])` over the selection (N HTTP round-
|
||||||
|
// trips × ~50–200ms each = 5–20s wedge for 100 selected certs).
|
||||||
|
// Post-L-1 it's a single POST to /api/v1/certificates/bulk-renew;
|
||||||
|
// the server resolves the criteria, applies status filters
|
||||||
|
// (RenewalInProgress/Revoked/Archived/Expired all silent-skip), and
|
||||||
|
// enqueues N renewal jobs server-side, returning a per-cert
|
||||||
|
// {certificate_id, job_id} envelope. CI guardrail at
|
||||||
|
// .github/workflows/ci.yml catches loop-shape regression.
|
||||||
const handleBulkRenewal = async () => {
|
const handleBulkRenewal = async () => {
|
||||||
const ids = Array.from(selectedIds);
|
const ids = Array.from(selectedIds);
|
||||||
setBulkRenewProgress({ done: 0, total: ids.length, running: true });
|
setBulkRenewProgress({ done: 0, total: ids.length, running: true });
|
||||||
for (let i = 0; i < ids.length; i++) {
|
try {
|
||||||
try {
|
const result = await bulkRenewCertificates({ certificate_ids: ids });
|
||||||
await triggerRenewal(ids[i]);
|
setBulkRenewProgress({
|
||||||
} catch {
|
done: result.total_enqueued,
|
||||||
// continue on individual failures
|
total: result.total_matched,
|
||||||
}
|
running: false,
|
||||||
setBulkRenewProgress({ done: i + 1, total: ids.length, running: i + 1 < ids.length });
|
});
|
||||||
|
} catch {
|
||||||
|
// surface as a "0 of N" terminal state — no retries.
|
||||||
|
setBulkRenewProgress({ done: 0, total: ids.length, running: false });
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['certificates'] });
|
queryClient.invalidateQueries({ queryKey: ['certificates'] });
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setTimeout(() => setBulkRenewProgress(null), 3000);
|
setTimeout(() => setBulkRenewProgress(null), 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: Column<Certificate>[] = [
|
const columns: Column<Certificate>[] = [
|
||||||
@@ -559,6 +613,36 @@ export default function CertificatesPage() {
|
|||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{/* F-1 closure (cat-e-610251c8f72d): team / expires_before / sort */}
|
||||||
|
<select
|
||||||
|
value={teamFilter}
|
||||||
|
onChange={e => { setTeamFilter(e.target.value); setPage(1); }}
|
||||||
|
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
|
||||||
|
>
|
||||||
|
<option value="">All teams</option>
|
||||||
|
{teamsFilterData?.data?.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={expiresBefore}
|
||||||
|
onChange={e => { setExpiresBefore(e.target.value); setPage(1); }}
|
||||||
|
title="Expires before (drives the 'expiring in N days' workflow)"
|
||||||
|
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={e => { setSortBy(e.target.value); setPage(1); }}
|
||||||
|
title="Sort order"
|
||||||
|
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
|
||||||
|
>
|
||||||
|
<option value="">Default sort</option>
|
||||||
|
<option value="notAfter">Expires soonest</option>
|
||||||
|
<option value="-notAfter">Expires latest</option>
|
||||||
|
<option value="createdAt">Created earliest</option>
|
||||||
|
<option value="-createdAt">Created latest</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -573,6 +657,13 @@ export default function CertificatesPage() {
|
|||||||
selectable
|
selectable
|
||||||
selectedKeys={selectedIds}
|
selectedKeys={selectedIds}
|
||||||
onSelectionChange={setSelectedIds}
|
onSelectionChange={setSelectedIds}
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onPerPageChange: (n) => { setPerPage(n); setPage(1); },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,12 +19,15 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Derive display status from backend enabled boolean */
|
// Derive display status from backend `enabled` boolean.
|
||||||
|
//
|
||||||
|
// D-2 (diff-05x06-97fab8783a5c, master): pre-D-2 the fall-through here
|
||||||
|
// was `issuer.status || 'Unknown'`, but `Issuer.status` was a TS phantom
|
||||||
|
// the Go-side struct never emitted (see types.ts::Issuer docblock for the
|
||||||
|
// full closure rationale). Post-D-2 the phantom is gone; this function
|
||||||
|
// derives the displayed status from `enabled` exclusively.
|
||||||
function issuerStatus(issuer: Issuer): string {
|
function issuerStatus(issuer: Issuer): string {
|
||||||
if (issuer.enabled !== undefined) {
|
return issuer.enabled ? 'Enabled' : 'Disabled';
|
||||||
return issuer.enabled ? 'Enabled' : 'Disabled';
|
|
||||||
}
|
|
||||||
return issuer.status || 'Unknown';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IssuerDetailPage() {
|
export default function IssuerDetailPage() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
|
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer, updateIssuer } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
@@ -14,13 +14,19 @@ import TypeSelector from '../components/issuer/TypeSelector';
|
|||||||
import ConfigForm from '../components/issuer/ConfigForm';
|
import ConfigForm from '../components/issuer/ConfigForm';
|
||||||
import ConfigDetailModal from '../components/issuer/ConfigDetailModal';
|
import ConfigDetailModal from '../components/issuer/ConfigDetailModal';
|
||||||
|
|
||||||
/** Derive display status from backend enabled boolean */
|
// Derive display status from backend `enabled` boolean.
|
||||||
|
//
|
||||||
|
// D-2 (diff-05x06-97fab8783a5c, master): pre-D-2 the fall-through chain
|
||||||
|
// here was `issuer.status || 'Unknown'`, which always rendered 'Unknown'
|
||||||
|
// because the Go-side struct never emitted a `status` field — the TS
|
||||||
|
// interface comment claimed status was "derived from enabled" but no
|
||||||
|
// derivation existed. Post-D-2 the phantom `Issuer.status` is gone and
|
||||||
|
// this function is the canonical derivation site. `enabled` is a
|
||||||
|
// required boolean on Go's Issuer struct so the `!== undefined` guard
|
||||||
|
// is now belt-and-suspenders rather than load-bearing, but kept for
|
||||||
|
// defensive rendering against malformed responses.
|
||||||
function issuerStatus(issuer: Issuer): string {
|
function issuerStatus(issuer: Issuer): string {
|
||||||
if (issuer.enabled !== undefined) {
|
return issuer.enabled ? 'Enabled' : 'Disabled';
|
||||||
return issuer.enabled ? 'Enabled' : 'Disabled';
|
|
||||||
}
|
|
||||||
// Fallback for legacy data that may have status string
|
|
||||||
return issuer.status || 'Unknown';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IssuersPage() {
|
export default function IssuersPage() {
|
||||||
@@ -30,6 +36,18 @@ export default function IssuersPage() {
|
|||||||
const [preselectedType, setPreselectedType] = useState<string | null>(null);
|
const [preselectedType, setPreselectedType] = useState<string | null>(null);
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('');
|
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||||
const [configModal, setConfigModal] = useState<{ title: string; config: Record<string, unknown> } | null>(null);
|
const [configModal, setConfigModal] = useState<{ title: string; config: Record<string, unknown> } | null>(null);
|
||||||
|
// B-1 master closure (cat-b-7a34f893a8f9): rename-only Edit affordance.
|
||||||
|
// Pre-B-1 the only way to rename an issuer was delete-and-recreate,
|
||||||
|
// which destroyed cert provenance and forced a re-encryption cycle
|
||||||
|
// through internal/crypto/encryption.go for every cert under the
|
||||||
|
// issuer. Type and credential blob are intentionally NOT editable here
|
||||||
|
// — changing the underlying CA driver type would require
|
||||||
|
// re-encrypting config under a different schema, and credentials are
|
||||||
|
// stored encrypted at rest (we can't decrypt them client-side to
|
||||||
|
// pre-populate). Operators who need to rotate credentials still
|
||||||
|
// delete + recreate. Documented as a deferred follow-up in the L-1
|
||||||
|
// CHANGELOG entry.
|
||||||
|
const [editingIssuer, setEditingIssuer] = useState<Issuer | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['issuers'],
|
queryKey: ['issuers'],
|
||||||
@@ -57,6 +75,19 @@ export default function IssuersPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// B-1 master closure: updateIssuer is wired to the rename-only Edit
|
||||||
|
// modal. Type and credential blob are NOT mutated here — see editingIssuer
|
||||||
|
// docblock above. Sends `{ name, type, config }` to satisfy the backend
|
||||||
|
// PUT contract (the handler decodes into a full domain.Issuer struct);
|
||||||
|
// type + config are preserved by reading them from the editing target.
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<Issuer> }) => updateIssuer(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issuers'] });
|
||||||
|
setEditingIssuer(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const catalogStatus = useMemo(
|
const catalogStatus = useMemo(
|
||||||
() => getIssuerCatalogStatus(data?.data || []),
|
() => getIssuerCatalogStatus(data?.data || []),
|
||||||
[data?.data]
|
[data?.data]
|
||||||
@@ -129,6 +160,12 @@ export default function IssuersPage() {
|
|||||||
>
|
>
|
||||||
Test
|
Test
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setEditingIssuer(i); }}
|
||||||
|
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete issuer ${i.name}?`)) deleteMutation.mutate(i.id); }}
|
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete issuer ${i.name}?`)) deleteMutation.mutate(i.id); }}
|
||||||
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
@@ -244,10 +281,91 @@ export default function IssuersPage() {
|
|||||||
isSubmitting={createMutation.isPending}
|
isSubmitting={createMutation.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* B-1 closure: EditIssuerModal — rename-only. */}
|
||||||
|
<EditIssuerModal
|
||||||
|
issuer={editingIssuer}
|
||||||
|
onClose={() => setEditingIssuer(null)}
|
||||||
|
onSave={(name) => {
|
||||||
|
if (!editingIssuer) return;
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: editingIssuer.id,
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
// Preserve type + config + enabled — the rename-only
|
||||||
|
// contract. Credential blob stays encrypted at rest.
|
||||||
|
type: editingIssuer.type,
|
||||||
|
config: editingIssuer.config,
|
||||||
|
enabled: editingIssuer.enabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
isSaving={updateMutation.isPending}
|
||||||
|
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── EditIssuerModal — rename-only Edit modal (B-1) ─────────────
|
||||||
|
//
|
||||||
|
// Locked: type, config (credentials), enabled. Editable: name only.
|
||||||
|
// The audit's "destructive rename workflow" complaint is specifically
|
||||||
|
// about renames; B-1 closes that hazard. Credential rotation still
|
||||||
|
// requires delete-and-recreate (see CHANGELOG B-1 known follow-ups).
|
||||||
|
interface EditIssuerModalProps {
|
||||||
|
issuer: Issuer | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (name: string) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditIssuerModal({ issuer, onClose, onSave, isSaving, error }: EditIssuerModalProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
useEffect(() => { if (issuer) setName(issuer.name); }, [issuer]);
|
||||||
|
|
||||||
|
if (!issuer) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
onSave(name.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<h2 className="text-lg font-semibold text-ink mb-4">Edit Issuer</h2>
|
||||||
|
<p className="text-xs text-ink-muted mb-4 font-mono">{issuer.id}</p>
|
||||||
|
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)} required
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink-muted mb-1">Type (locked)</label>
|
||||||
|
<input value={issuer.type} disabled
|
||||||
|
className="w-full bg-surface-border/50 border border-surface-border rounded px-3 py-2 text-sm text-ink-muted font-mono" />
|
||||||
|
<p className="text-xs text-ink-faint mt-1">
|
||||||
|
To change issuer type or rotate credentials, delete and recreate.
|
||||||
|
See CHANGELOG B-1 known follow-ups.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<button type="submit" disabled={isSaving} className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Catalog Card ───────────────────────────────────────────────
|
// ─── Catalog Card ───────────────────────────────────────────────
|
||||||
|
|
||||||
interface CatalogCardProps {
|
interface CatalogCardProps {
|
||||||
|
|||||||
@@ -50,12 +50,14 @@ function renderWithQuery(ui: ReactNode) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D-2 (master): pre-D-2 these mocks set `subject:` — the field was a TS
|
||||||
|
// phantom the Go-side struct never emitted. Post-D-2 the phantom is
|
||||||
|
// removed from the Notification interface; the mocks no longer set it.
|
||||||
const pendingNotif = {
|
const pendingNotif = {
|
||||||
id: 'notif-001',
|
id: 'notif-001',
|
||||||
type: 'ExpirationWarning',
|
type: 'ExpirationWarning',
|
||||||
channel: 'Email',
|
channel: 'Email',
|
||||||
recipient: 'admin@example.com',
|
recipient: 'admin@example.com',
|
||||||
subject: 'Certificate expiring',
|
|
||||||
message: 'Certificate expiring in 7 days',
|
message: 'Certificate expiring in 7 days',
|
||||||
status: 'Pending',
|
status: 'Pending',
|
||||||
certificate_id: 'mc-prod-001',
|
certificate_id: 'mc-prod-001',
|
||||||
@@ -67,7 +69,6 @@ const deadNotif = {
|
|||||||
type: 'ExpirationWarning',
|
type: 'ExpirationWarning',
|
||||||
channel: 'Email',
|
channel: 'Email',
|
||||||
recipient: 'admin@example.com',
|
recipient: 'admin@example.com',
|
||||||
subject: 'Certificate expiring',
|
|
||||||
message: 'Certificate expiring in 7 days',
|
message: 'Certificate expiring in 7 days',
|
||||||
status: 'dead',
|
status: 'dead',
|
||||||
certificate_id: 'mc-prod-001',
|
certificate_id: 'mc-prod-001',
|
||||||
|
|||||||
@@ -238,7 +238,13 @@ function NotificationRow({
|
|||||||
<StatusBadge status={n.status} />
|
<StatusBadge status={n.status} />
|
||||||
<span className="text-xs text-ink-faint">{n.channel}</span>
|
<span className="text-xs text-ink-faint">{n.channel}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-ink-muted truncate">{n.message || n.subject}</p>
|
{/* D-2 (master): pre-D-2 the fallback was `{n.message || n.subject}`,
|
||||||
|
but `subject` was a TS phantom the Go struct never emitted
|
||||||
|
(`internal/domain/notification.go::NotificationEvent` has only
|
||||||
|
`message`). The fallback always fell through to `message`
|
||||||
|
because `subject` was always undefined. Post-D-2 the dead
|
||||||
|
fallback is dropped along with the phantom field. */}
|
||||||
|
<p className="text-xs text-ink-muted truncate">{n.message}</p>
|
||||||
{isDead && (
|
{isDead && (
|
||||||
<div className="flex items-center gap-3 mt-1 text-xs">
|
<div className="flex items-center gap-3 mt-1 text-xs">
|
||||||
<span className="text-ink-faint">
|
<span className="text-ink-faint">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getOwners, getTeams, deleteOwner, createOwner } from '../api/client';
|
import { getOwners, getTeams, deleteOwner, createOwner, updateOwner } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
@@ -102,9 +102,103 @@ function CreateOwnerModal({ isOpen, onClose, onSuccess, isLoading, error, teamsD
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditOwnerModal — B-1 master closure (cat-b-31ceb6aaa9f1). Pre-B-1 the
|
||||||
|
// only way to rename an owner was delete-and-recreate, which destroyed
|
||||||
|
// audit history and broke every cert that referenced the old owner_id.
|
||||||
|
// Mirrors CreateOwnerModal shape; pre-populates from the editing owner;
|
||||||
|
// calls updateOwner(id, fields) instead of createOwner.
|
||||||
|
interface EditOwnerModalProps {
|
||||||
|
owner: Owner | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
teamsData?: { data: Team[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditOwnerModal({ owner, onClose, onSuccess, isLoading, error, teamsData }: EditOwnerModalProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [teamId, setTeamId] = useState('');
|
||||||
|
|
||||||
|
// Reset form fields whenever the editing target changes (modal opens).
|
||||||
|
useEffect(() => {
|
||||||
|
if (owner) {
|
||||||
|
setName(owner.name);
|
||||||
|
setEmail(owner.email);
|
||||||
|
setTeamId(owner.team_id || '');
|
||||||
|
}
|
||||||
|
}, [owner]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!owner || !name.trim() || !email.trim()) return;
|
||||||
|
await updateOwner(owner.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
team_id: teamId || undefined,
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!owner) return null;
|
||||||
|
const teams = teamsData?.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<h2 className="text-lg font-semibold text-ink mb-4">Edit Owner</h2>
|
||||||
|
<p className="text-xs text-ink-muted mb-4 font-mono">{owner.id}</p>
|
||||||
|
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Team</label>
|
||||||
|
<select
|
||||||
|
value={teamId}
|
||||||
|
onChange={e => setTeamId(e.target.value)}
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||||
|
>
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{teams.map(team => (
|
||||||
|
<option key={team.id} value={team.id}>{team.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<button type="submit" disabled={isLoading} className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
{isLoading ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function OwnersPage() {
|
export default function OwnersPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [editingOwner, setEditingOwner] = useState<Owner | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['owners'],
|
queryKey: ['owners'],
|
||||||
@@ -130,6 +224,14 @@ export default function OwnersPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<Owner> }) => updateOwner(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['owners'] });
|
||||||
|
setEditingOwner(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const teamMap = new Map<string, Team>();
|
const teamMap = new Map<string, Team>();
|
||||||
(teamsData?.data || []).forEach((t) => teamMap.set(t.id, t));
|
(teamsData?.data || []).forEach((t) => teamMap.set(t.id, t));
|
||||||
|
|
||||||
@@ -168,12 +270,20 @@ export default function OwnersPage() {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
render: (o) => (
|
render: (o) => (
|
||||||
<button
|
<div className="flex gap-3 justify-end">
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete owner ${o.name}?`)) deleteMutation.mutate(o.id); }}
|
<button
|
||||||
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
onClick={(e) => { e.stopPropagation(); setEditingOwner(o); }}
|
||||||
>
|
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
|
||||||
Delete
|
>
|
||||||
</button>
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete owner ${o.name}?`)) deleteMutation.mutate(o.id); }}
|
||||||
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -207,6 +317,17 @@ export default function OwnersPage() {
|
|||||||
error={createMutation.error ? (createMutation.error as Error).message : null}
|
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||||
teamsData={teamsData}
|
teamsData={teamsData}
|
||||||
/>
|
/>
|
||||||
|
<EditOwnerModal
|
||||||
|
owner={editingOwner}
|
||||||
|
onClose={() => setEditingOwner(null)}
|
||||||
|
onSuccess={() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['owners'] });
|
||||||
|
setEditingOwner(null);
|
||||||
|
}}
|
||||||
|
isLoading={updateMutation.isPending}
|
||||||
|
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
||||||
|
teamsData={teamsData}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getProfiles, deleteProfile, createProfile } from '../api/client';
|
import { getProfiles, deleteProfile, createProfile, updateProfile } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
@@ -288,6 +288,12 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
|||||||
export default function ProfilesPage() {
|
export default function ProfilesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
// B-1 master closure (cat-b-7a34f893a8f9): rename + description Edit
|
||||||
|
// affordance. Deeper policy fields (allowed_ekus, max_ttl_seconds,
|
||||||
|
// allowed_key_algorithms, etc.) stay on the delete-and-recreate path
|
||||||
|
// for v1 — closing the audit's destructive-rename complaint requires
|
||||||
|
// only the simple metadata edit. Documented as a follow-up.
|
||||||
|
const [editingProfile, setEditingProfile] = useState<CertificateProfile | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['profiles'],
|
queryKey: ['profiles'],
|
||||||
@@ -307,6 +313,14 @@ export default function ProfilesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<CertificateProfile> }) => updateProfile(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
|
setEditingProfile(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns: Column<CertificateProfile>[] = [
|
const columns: Column<CertificateProfile>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
@@ -382,12 +396,20 @@ export default function ProfilesPage() {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
render: (p) => (
|
render: (p) => (
|
||||||
<button
|
<div className="flex gap-3 justify-end">
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete profile ${p.name}?`)) deleteMutation.mutate(p.id); }}
|
<button
|
||||||
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
onClick={(e) => { e.stopPropagation(); setEditingProfile(p); }}
|
||||||
>
|
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
|
||||||
Delete
|
>
|
||||||
</button>
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete profile ${p.name}?`)) deleteMutation.mutate(p.id); }}
|
||||||
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -420,6 +442,95 @@ export default function ProfilesPage() {
|
|||||||
isLoading={createMutation.isPending}
|
isLoading={createMutation.isPending}
|
||||||
error={createMutation.error ? (createMutation.error as Error).message : null}
|
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||||
/>
|
/>
|
||||||
|
<EditProfileModal
|
||||||
|
profile={editingProfile}
|
||||||
|
onClose={() => setEditingProfile(null)}
|
||||||
|
onSave={(data) => {
|
||||||
|
if (!editingProfile) return;
|
||||||
|
updateMutation.mutate({ id: editingProfile.id, data });
|
||||||
|
}}
|
||||||
|
isSaving={updateMutation.isPending}
|
||||||
|
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditProfileModal — B-1 closure (cat-b-7a34f893a8f9). Rename +
|
||||||
|
// description only. Deeper policy fields (allowed_ekus, max_ttl_seconds,
|
||||||
|
// allowed_key_algorithms, required_san_patterns, spiffe_uri_pattern,
|
||||||
|
// allow_short_lived) stay on delete-and-recreate for v1 — closing the
|
||||||
|
// audit's destructive-rename complaint requires only the simple
|
||||||
|
// metadata edit. The PUT contract takes a full Partial<CertificateProfile>
|
||||||
|
// so we forward the existing policy fields untouched.
|
||||||
|
interface EditProfileModalProps {
|
||||||
|
profile: CertificateProfile | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (data: Partial<CertificateProfile>) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditProfileModal({ profile, onClose, onSave, isSaving, error }: EditProfileModalProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile) {
|
||||||
|
setName(profile.name);
|
||||||
|
setDescription(profile.description || '');
|
||||||
|
}
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
onSave({
|
||||||
|
// Pass the full struct minus id/timestamps. Backend PUT needs the
|
||||||
|
// policy fields preserved so we forward them from the editing target.
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
allowed_key_algorithms: profile.allowed_key_algorithms,
|
||||||
|
max_ttl_seconds: profile.max_ttl_seconds,
|
||||||
|
allowed_ekus: profile.allowed_ekus,
|
||||||
|
required_san_patterns: profile.required_san_patterns,
|
||||||
|
spiffe_uri_pattern: profile.spiffe_uri_pattern,
|
||||||
|
allow_short_lived: profile.allow_short_lived,
|
||||||
|
enabled: profile.enabled,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<h2 className="text-lg font-semibold text-ink mb-4">Edit Profile</h2>
|
||||||
|
<p className="text-xs text-ink-muted mb-4 font-mono">{profile.id}</p>
|
||||||
|
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)} required
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-1">Description</label>
|
||||||
|
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={2}
|
||||||
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ink-faint">
|
||||||
|
Policy fields (TTL, EKUs, key algorithms, SAN patterns) stay on the
|
||||||
|
create-recreate path for v1. See CHANGELOG B-1 known follow-ups.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<button type="submit" disabled={isSaving} className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user