mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 23:18:55 +00:00
WIP: M-1 handler sentinel error mapping (checkpoint before branch cleanup)
Uncommitted migration work at the time of branch cleanup. Tagged as checkpoint/m1-migration-wip so the commit survives git gc --prune=now. Session context: Phase 3 Part B+C of the M-1 sentinel error migration was in progress. 38 modified files, 4 new files (errors.go + errors_test.go in internal/service/ and internal/api/handler/). Resume from this commit via 'git checkout checkpoint/m1-migration-wip'.
This commit is contained in:
@@ -3,12 +3,14 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// AgentGroupService defines the service interface for agent group operations.
|
||||
@@ -22,6 +24,19 @@ type AgentGroupService interface {
|
||||
}
|
||||
|
||||
// AgentGroupHandler handles HTTP requests for agent group operations.
|
||||
//
|
||||
// Error dispatch (post-M-1): every service error routes through the [errToStatus]
|
||||
// choke point via `errors.Is` walking the wrap chain, with one explicit
|
||||
// [service.ErrValidation] arm on the write paths (Create, Update) so the
|
||||
// composed "validation: <field-specific reason>" message the service layer
|
||||
// attaches via `fmt.Errorf("%w: ...", ErrValidation)` can be passed through to
|
||||
// the 400 response body. Before M-1, the Create handler branched on
|
||||
// `strings.Contains(err.Error(), "invalid"|"required")` — fragile because a
|
||||
// single reword in [service.validateAgentGroup] would demote the 400 to 500
|
||||
// with no compile-time signal — and the Update/Delete handlers branched on
|
||||
// `strings.Contains(err.Error(), "not found")`, coupling HTTP classification
|
||||
// to repository human-readable strings. Both now ride the typed
|
||||
// [repository.ErrNotFound] wrap through errToStatus. Mirrors ProfileHandler.
|
||||
type AgentGroupHandler struct {
|
||||
svc AgentGroupService
|
||||
}
|
||||
@@ -89,7 +104,18 @@ func (h AgentGroupHandler) GetAgentGroup(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
group, err := h.svc.GetAgentGroup(r.Context(), id)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||
// M-1: route through errToStatus so a repo-level `sql.ErrNoRows`
|
||||
// (wrapped as repository.ErrNotFound) becomes 404, but a transient DB
|
||||
// failure no longer masquerades as 404 — it correctly surfaces 500. The
|
||||
// pre-M-1 "any error → 404" shortcut was plausible when Get's only
|
||||
// expected failure was "not found", but the choke point now gives us
|
||||
// correct dispatch for free. Mirrors GetProfile.
|
||||
status := errToStatus(err)
|
||||
msg := "Failed to get agent group"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Agent group not found"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,7 +149,15 @@ func (h AgentGroupHandler) CreateAgentGroup(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
created, err := h.svc.CreateAgentGroup(r.Context(), group)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") {
|
||||
// M-1: replace the 2-term substring net (`"invalid"|"required"`) with a
|
||||
// single `errors.Is(err, service.ErrValidation)` arm. validateAgentGroup
|
||||
// wraps every field-specific failure via `fmt.Errorf("%w: <reason>",
|
||||
// ErrValidation)`, so `err.Error()` still contains the human-readable
|
||||
// reason (e.g., "agent group name is required") and can be safely passed
|
||||
// to the 400 body — but the status decision no longer depends on the
|
||||
// exact wording. Other errors redact to a generic 500. Mirrors
|
||||
// CreateProfile.
|
||||
if errors.Is(err, service.ErrValidation) {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
@@ -160,11 +194,22 @@ func (h AgentGroupHandler) UpdateAgentGroup(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
updated, err := h.svc.UpdateAgentGroup(r.Context(), id, group)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||
// M-1: explicit ErrValidation arm preserves the user-facing reason in
|
||||
// the 400 body (validateAgentGroup wraps every failure via
|
||||
// `fmt.Errorf("%w: ...", ErrValidation)`); every other error — including
|
||||
// repo-layer ErrNotFound on a missing row — routes through errToStatus
|
||||
// so the 404/500 decision no longer depends on substring matching.
|
||||
// Mirrors UpdateProfile.
|
||||
if errors.Is(err, service.ErrValidation) {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update agent group", requestID)
|
||||
status := errToStatus(err)
|
||||
msg := "Failed to update agent group"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Agent group not found"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -188,11 +233,15 @@ func (h AgentGroupHandler) DeleteAgentGroup(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteAgentGroup(r.Context(), id); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
|
||||
return
|
||||
// M-1: sentinel dispatch replaces the substring 404 check — see the
|
||||
// parallel comment block in UpdateAgentGroup for the rationale. Mirrors
|
||||
// DeleteProfile.
|
||||
status := errToStatus(err)
|
||||
msg := "Failed to delete agent group"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Agent group not found"
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete agent group", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,19 @@ func (h AgentHandler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
agent, err := h.svc.GetAgent(r.Context(), id)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
||||
// M-1 (P2): route through errToStatus so a repo-level
|
||||
// sql.ErrNoRows (wrapped as repository.ErrNotFound) becomes 404,
|
||||
// but a transient DB failure no longer masquerades as 404 — it
|
||||
// correctly surfaces 500. The pre-M-1 "any error → 404" shortcut
|
||||
// was plausible when Get's only expected failure was "not found",
|
||||
// but the choke point now gives us correct dispatch for free.
|
||||
// Mirrors GetAgentGroup / GetProfile.
|
||||
status := errToStatus(err)
|
||||
msg := "Failed to get agent"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Agent not found"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -147,11 +159,20 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
created, err := h.svc.RegisterAgent(r.Context(), agent)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") || strings.Contains(errMsg, "already exists") {
|
||||
// M-1 (P2): replace the 3-term substring net
|
||||
// (`"unique"|"duplicate"|"already exists"`) with a typed
|
||||
// errors.Is(err, service.ErrConflict) arm. The service layer now
|
||||
// wraps pg SQLSTATE 23505 duplicate-name violations via
|
||||
// fmt.Errorf("%w: agent name already exists", ErrConflict), so
|
||||
// classification no longer depends on the exact driver wording.
|
||||
// Other errors redact to a generic 500 with slog.Error server-
|
||||
// side diagnostic capture (F-002). Mirrors CreateProfile's
|
||||
// ErrValidation arm pattern, adapted for the conflict case.
|
||||
if errors.Is(err, service.ErrConflict) {
|
||||
ErrorWithRequestID(w, http.StatusConflict, "Agent with this name already exists", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("RegisterAgent failed", "name", agent.Name, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
|
||||
return
|
||||
}
|
||||
@@ -211,7 +232,15 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
ErrorWithRequestID(w, http.StatusGone, "Agent has been retired", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
// M-1 (P2): the pre-M-1 `strings.Contains(err.Error(), "not
|
||||
// found")` branch now rides the errToStatus choke point, which
|
||||
// recognizes repository.ErrNotFound via errors.Is. The retired-
|
||||
// agent sentinel is still checked FIRST above so the 410 Gone
|
||||
// short-circuit is never masked by the 404 arm. Any other error
|
||||
// redacts to a generic 500 with slog.Error server-side diagnostic
|
||||
// capture (F-002). Mirrors GetAgentGroup.
|
||||
status := errToStatus(err)
|
||||
if status == http.StatusNotFound {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
||||
return
|
||||
}
|
||||
@@ -308,7 +337,16 @@ func (h AgentHandler) AgentCertificatePickup(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
certPEM, err := h.svc.CertificatePickup(r.Context(), agentID, certID)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found or not ready", requestID)
|
||||
// M-1 (P2): route through errToStatus so a repo-level
|
||||
// sql.ErrNoRows (wrapped as repository.ErrNotFound) becomes 404,
|
||||
// but a transient DB failure no longer masquerades as 404 — it
|
||||
// correctly surfaces 500. Mirrors GetAgent.
|
||||
status := errToStatus(err)
|
||||
msg := "Failed to retrieve certificate"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Certificate not found or not ready"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -491,7 +529,16 @@ func (h AgentHandler) RetireAgent(w http.ResponseWriter, r *http.Request) {
|
||||
JSON(w, http.StatusConflict, body)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
// M-1 (P2): the pre-M-1 `strings.Contains(err.Error(), "not
|
||||
// found")` branch now rides the errToStatus choke point, which
|
||||
// recognizes repository.ErrNotFound via errors.Is. The sentinel
|
||||
// (ErrAgentIsSentinel, ErrForceReasonRequired) and typed
|
||||
// (*BlockedByDependenciesError) checks above still run FIRST so
|
||||
// the 403/400/409 structural refusals are never masked by the
|
||||
// 404 arm. Any other error redacts to a generic 500 with
|
||||
// slog.Error server-side diagnostic capture (F-002).
|
||||
status := errToStatus(err)
|
||||
if status == http.StatusNotFound {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -86,7 +87,24 @@ func (h AuditHandler) GetAuditEvent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
event, err := h.svc.GetAuditEvent(r.Context(), id)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Audit event not found", requestID)
|
||||
// M-1 (P2): dispatch routes through errToStatus. Pre-M-1 this branch was
|
||||
// a blanket `any error → 404 Audit event not found` shortcut — which
|
||||
// silently demoted transient DB failures from the service's auditRepo.List
|
||||
// wrap to 404 Not Found with no observable external signal. Post-M-1:
|
||||
// service/audit.go GetAuditEvent only wraps the genuine zero-events path
|
||||
// with service.ErrNotFound via %w, and the repo.List wrap surfaces without
|
||||
// that sentinel — so errors.Is(err, service.ErrNotFound) picks up the real
|
||||
// 404s and everything else correctly surfaces as 500 with server-side
|
||||
// slog.Error capture (F-002 redacted-500 pattern preserved).
|
||||
status := errToStatus(err)
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("GetAuditEvent failed", "audit_event_id", id, "error", err.Error())
|
||||
}
|
||||
msg := "Failed to get audit event"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Audit event not found"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -298,12 +298,13 @@ func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Req
|
||||
|
||||
updated, err := h.svc.UpdateCertificate(r.Context(), id, cert)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("UpdateCertificate failed", "cert_id", id, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
slog.Error("UpdateCertificate failed", "cert_id", id, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -327,11 +328,13 @@ func (h CertificateHandler) ArchiveCertificate(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
if err := h.svc.ArchiveCertificate(r.Context(), id); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("ArchiveCertificate failed", "cert_id", id, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to archive certificate", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -373,12 +376,13 @@ func (h CertificateHandler) GetCertificateVersions(w http.ResponseWriter, r *htt
|
||||
|
||||
versions, total, err := h.svc.GetCertificateVersions(r.Context(), certID, page, perPage)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("GetCertificateVersions failed", "cert_id", certID, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
slog.Error("GetCertificateVersions failed", "cert_id", certID, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get certificate versions", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -414,20 +418,13 @@ func (h CertificateHandler) TriggerRenewal(w http.ResponseWriter, r *http.Reques
|
||||
actor := resolveActor(r.Context())
|
||||
|
||||
if err := h.svc.TriggerRenewal(r.Context(), certID, actor); err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("TriggerRenewal failed", "cert_id", certID, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
if strings.Contains(errMsg, "cannot renew") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "already in progress") {
|
||||
ErrorWithRequestID(w, http.StatusConflict, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger renewal", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -516,19 +513,13 @@ func (h CertificateHandler) RevokeCertificate(w http.ResponseWriter, r *http.Req
|
||||
actor := resolveActor(r.Context())
|
||||
|
||||
if err := h.svc.RevokeCertificate(r.Context(), certID, req.Reason, actor); err != nil {
|
||||
// Distinguish between client errors and server errors
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "already revoked") ||
|
||||
strings.Contains(errMsg, "cannot revoke") ||
|
||||
strings.Contains(errMsg, "invalid revocation reason") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
|
||||
return
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("RevokeCertificate failed", "cert_id", certID, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "failed to fetch") || strings.Contains(errMsg, "failed to get") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to revoke certificate", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -557,16 +548,13 @@ func (h CertificateHandler) GetDERCRL(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
derBytes, err := h.svc.GenerateDERCRL(r.Context(), issuerID)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, errMsg, requestID)
|
||||
return
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("GenerateDERCRL failed", "issuer_id", issuerID, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
if strings.Contains(errMsg, "do not support") || strings.Contains(errMsg, "does not support") {
|
||||
ErrorWithRequestID(w, http.StatusNotImplemented, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to generate CRL", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -602,16 +590,13 @@ func (h CertificateHandler) HandleOCSP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
derBytes, err := h.svc.GetOCSPResponse(r.Context(), issuerID, serialHex)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, errMsg, requestID)
|
||||
return
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("GetOCSPResponse failed", "issuer_id", issuerID, "serial", serialHex, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
if strings.Contains(errMsg, "do not support") || strings.Contains(errMsg, "does not support") {
|
||||
ErrorWithRequestID(w, http.StatusNotImplemented, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to generate OCSP response", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -642,12 +627,13 @@ func (h CertificateHandler) GetCertificateDeployments(w http.ResponseWriter, r *
|
||||
|
||||
deployments, err := h.svc.GetCertificateDeployments(r.Context(), certID)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("GetCertificateDeployments failed", "cert_id", certID, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get deployments", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/lib/pq"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// errToStatus is the single choke point that maps a service-layer or
|
||||
// repository-layer error to its HTTP status code. Before M-1 (P2), 42 switch
|
||||
// branches across 11 handler files classified errors via
|
||||
// `strings.Contains(err.Error(), ...)` substring matching — a pattern that
|
||||
// made every HTTP status mapping one sentinel-message reword away from silent
|
||||
// regression (see M-003 self-approval privilege boundary: a reword of
|
||||
// ErrSelfApproval.Error() would have demoted 403 Forbidden to 500 Internal
|
||||
// Server Error with no compile-time error, no test failure, and no observable
|
||||
// external signal).
|
||||
//
|
||||
// All handler branches now route through this function via errors.Is and
|
||||
// errors.As, which walks the wrap chain built by fmt.Errorf("%w: ...", ...).
|
||||
// The generic sentinels live in internal/service/errors.go; domain-specific
|
||||
// sentinels (ErrSelfApproval, ErrAgentIsSentinel, ErrBlockedByDependencies,
|
||||
// ErrForceReasonRequired, ErrAgentNotFound) wrap those generics via %w so both
|
||||
// errors.Is(err, ErrSelfApproval) and errors.Is(err, ErrForbidden) succeed on
|
||||
// the same wrapped error.
|
||||
//
|
||||
// # Dispatch order
|
||||
//
|
||||
// 1. ErrAgentRetired → 410 Gone. Tested FIRST. It is deliberately NOT wrapped
|
||||
// under any generic sentinel — 410 Gone is semantically distinct from
|
||||
// 403/404/409 (permanently-terminated resource identity that drives
|
||||
// deterministic agent-binary shutdown at cmd/agent/main.go:1291). Must
|
||||
// short-circuit before any generic check so wrapping can never demote it.
|
||||
// 2. ErrNotFound → 404 Not Found. Both service.ErrNotFound and
|
||||
// repository.ErrNotFound route here — repositories wrap sql.ErrNoRows with
|
||||
// repository.ErrNotFound so a "row not found" escapes the repo layer as a
|
||||
// typed sentinel rather than an untyped fmt.Errorf string. Tested BEFORE
|
||||
// ErrForbidden so RFC 7235's preference for hiding resource existence from
|
||||
// unauthorized callers is preserved (a caller who cannot see a resource
|
||||
// should get 404, not 403).
|
||||
// 3. ErrUnauthenticated → 401 Unauthorized. SCEP challenge-password mismatch
|
||||
// and similar credential failures.
|
||||
// 4. ErrForbidden → 403 Forbidden. M-003 gate. Tested BEFORE ErrValidation so
|
||||
// double-wrapping (e.g., a future fmt.Errorf("%w: ctx", ErrSelfApproval)
|
||||
// in a wrapping call site) cannot demote 403 to 400.
|
||||
// 5. ErrConflict / repository.ErrRenewalPolicyDuplicateName /
|
||||
// repository.ErrRenewalPolicyInUse → 409 Conflict. The repo-layer sentinels
|
||||
// are routed here explicitly so handlers do not need their own dispatch
|
||||
// tree for G-1's renewal-policy FK + unique-name violations.
|
||||
// 6. ErrValidation → 400 Bad Request. Generic input validation / malformed
|
||||
// request bodies / invalid state transitions that the caller could correct
|
||||
// by changing their request.
|
||||
// 7. ErrUnprocessable → 422 Unprocessable Entity. Distinct from
|
||||
// ErrValidation: ErrValidation is "caller sent bad input" (400), while
|
||||
// ErrUnprocessable is "caller's input was fine but our stored data can't
|
||||
// satisfy the operation" — e.g., an X.509 PEM in the inventory that fails
|
||||
// to decode. The pre-M-1 ExportPKCS12 handler pinned 422 on
|
||||
// strings.Contains(err.Error(), "cannot be parsed"); the sentinel makes
|
||||
// that dispatch survive message rewording.
|
||||
// 8. ErrNotImplemented → 501 Not Implemented. Reserved for feature-flag-gated
|
||||
// code paths.
|
||||
// 9. *pq.Error fallback on SQLSTATE 23503 (FK violation) / 23505 (unique
|
||||
// violation) → 409 Conflict. Final branch before the default 500. Anything
|
||||
// that reaches here is technically a code smell (the repository layer
|
||||
// should normally wrap driver errors into a typed sentinel) but the status
|
||||
// mapping is still correct.
|
||||
//
|
||||
// # Why a function, not a middleware
|
||||
//
|
||||
// Handlers must continue to call [Error] / [ErrorWithRequestID] with a
|
||||
// caller-chosen human-readable message (sometimes the wrapped err.Error(),
|
||||
// sometimes a redacted "internal error" for 500s per F-002). This function
|
||||
// gives handlers the status code; the handler keeps control of the body.
|
||||
func errToStatus(err error) int {
|
||||
if err == nil {
|
||||
return http.StatusOK
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, service.ErrAgentRetired):
|
||||
return http.StatusGone // 410 — must short-circuit before generic dispatch
|
||||
case errors.Is(err, service.ErrNotFound),
|
||||
errors.Is(err, repository.ErrNotFound):
|
||||
return http.StatusNotFound // 404 — before ErrForbidden (RFC 7235 existence hiding)
|
||||
case errors.Is(err, service.ErrUnauthenticated):
|
||||
return http.StatusUnauthorized // 401
|
||||
case errors.Is(err, service.ErrForbidden):
|
||||
return http.StatusForbidden // 403 — before ErrValidation (preserves M-003 gate under double-wrap)
|
||||
case errors.Is(err, service.ErrConflict),
|
||||
errors.Is(err, repository.ErrRenewalPolicyDuplicateName),
|
||||
errors.Is(err, repository.ErrRenewalPolicyInUse):
|
||||
return http.StatusConflict // 409
|
||||
case errors.Is(err, service.ErrValidation):
|
||||
return http.StatusBadRequest // 400
|
||||
case errors.Is(err, service.ErrUnprocessable):
|
||||
return http.StatusUnprocessableEntity // 422 — stored-data-unparseable, not caller-input-bad
|
||||
case errors.Is(err, service.ErrNotImplemented):
|
||||
return http.StatusNotImplemented // 501
|
||||
}
|
||||
|
||||
// Driver-level fallback. Raw *pq.Error escaping the repository layer is a
|
||||
// code smell but a real escape hatch today — we still want a correct 409
|
||||
// instead of a generic 500 for FK/unique violations.
|
||||
var pgErr *pq.Error
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "23503", "23505":
|
||||
return http.StatusConflict
|
||||
}
|
||||
}
|
||||
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/lib/pq"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// TestErrToStatus_DispatchMatrix pins the handler's single error → HTTP
|
||||
// status choke point. Each row covers one branch of the dispatch switch and
|
||||
// the dispatch order invariants documented in errors.go:
|
||||
//
|
||||
// - ErrAgentRetired FIRST (410 short-circuits before generic checks)
|
||||
// - ErrNotFound before ErrForbidden (RFC 7235 existence hiding)
|
||||
// - ErrForbidden before ErrValidation (preserves M-003 gate under double-wrap)
|
||||
// - Repo sentinels route to 409 alongside ErrConflict
|
||||
// - *pq.Error on 23503 / 23505 routes to 409 as the driver-level fallback
|
||||
// - Default path is 500
|
||||
func TestErrToStatus_DispatchMatrix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"nil → 200", nil, http.StatusOK},
|
||||
|
||||
// Each generic sentinel resolves to its documented status code.
|
||||
{"ErrNotFound → 404", service.ErrNotFound, http.StatusNotFound},
|
||||
{"ErrValidation → 400", service.ErrValidation, http.StatusBadRequest},
|
||||
{"ErrConflict → 409", service.ErrConflict, http.StatusConflict},
|
||||
{"ErrForbidden → 403", service.ErrForbidden, http.StatusForbidden},
|
||||
{"ErrUnauthenticated → 401", service.ErrUnauthenticated, http.StatusUnauthorized},
|
||||
{"ErrNotImplemented → 501", service.ErrNotImplemented, http.StatusNotImplemented},
|
||||
|
||||
// Wrapped domain sentinels route through their generic wrap.
|
||||
{"ErrSelfApproval → 403 (via ErrForbidden)", service.ErrSelfApproval, http.StatusForbidden},
|
||||
{"ErrAgentIsSentinel → 403 (via ErrForbidden)", service.ErrAgentIsSentinel, http.StatusForbidden},
|
||||
{"ErrBlockedByDependencies → 409 (via ErrConflict)", service.ErrBlockedByDependencies, http.StatusConflict},
|
||||
{"ErrForceReasonRequired → 400 (via ErrValidation)", service.ErrForceReasonRequired, http.StatusBadRequest},
|
||||
{"ErrAgentNotFound → 400 (via ErrValidation)", service.ErrAgentNotFound, http.StatusBadRequest},
|
||||
|
||||
// ErrAgentRetired is standalone — 410 Gone must fire before any
|
||||
// generic dispatch. This locks in the semantic-distinct short-circuit.
|
||||
{"ErrAgentRetired → 410", service.ErrAgentRetired, http.StatusGone},
|
||||
|
||||
// Repository-layer sentinels (G-1 + M-1).
|
||||
{"repo.ErrNotFound → 404", repository.ErrNotFound, http.StatusNotFound},
|
||||
{"wrapped repo.ErrNotFound → 404",
|
||||
fmt.Errorf("%w: renewal policy rp-foo", repository.ErrNotFound),
|
||||
http.StatusNotFound},
|
||||
{"repo.ErrRenewalPolicyDuplicateName → 409", repository.ErrRenewalPolicyDuplicateName, http.StatusConflict},
|
||||
{"repo.ErrRenewalPolicyInUse → 409", repository.ErrRenewalPolicyInUse, http.StatusConflict},
|
||||
|
||||
// Wrapped errors with additional context survive the dispatch.
|
||||
{"wrapped ErrNotFound with context → 404",
|
||||
fmt.Errorf("lookup failed: %w", service.ErrNotFound),
|
||||
http.StatusNotFound},
|
||||
{"wrapped ErrSelfApproval with context → 403",
|
||||
fmt.Errorf("approval gate: %w", service.ErrSelfApproval),
|
||||
http.StatusForbidden},
|
||||
|
||||
// Driver-level fallback: raw *pq.Error escaping repo layer.
|
||||
{"*pq.Error 23503 → 409", &pq.Error{Code: "23503"}, http.StatusConflict},
|
||||
{"*pq.Error 23505 → 409", &pq.Error{Code: "23505"}, http.StatusConflict},
|
||||
{"*pq.Error 08006 → 500", &pq.Error{Code: "08006"}, http.StatusInternalServerError},
|
||||
|
||||
// Default path.
|
||||
{"unknown error → 500", errors.New("something arbitrary"), http.StatusInternalServerError},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := errToStatus(c.err)
|
||||
if got != c.want {
|
||||
t.Errorf("errToStatus(%v) = %d, want %d", c.err, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrToStatus_AgentRetiredShortCircuit is a dedicated regression guard
|
||||
// for the most fragile dispatch invariant: ErrAgentRetired's 410 Gone must
|
||||
// fire FIRST. If a future commit wraps it under ErrForbidden (e.g., to
|
||||
// include it in a generic "agent operations forbidden" bucket), this test
|
||||
// goes red and the agent-binary shutdown at cmd/agent/main.go:1291 would
|
||||
// silently stop triggering.
|
||||
func TestErrToStatus_AgentRetiredShortCircuit(t *testing.T) {
|
||||
if got := errToStatus(service.ErrAgentRetired); got != http.StatusGone {
|
||||
t.Fatalf("ErrAgentRetired → %d, want 410 Gone (short-circuit must fire before any generic dispatch)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrToStatus_NotFoundBeforeForbidden locks the RFC 7235 existence-
|
||||
// hiding dispatch order. If someone were to reorder the switch arms to put
|
||||
// ErrForbidden first, an authorization failure on a nonexistent resource
|
||||
// would leak existence via a 403 instead of masking it with a 404.
|
||||
func TestErrToStatus_NotFoundBeforeForbidden(t *testing.T) {
|
||||
// A hypothetical wrapping where both would match — contrived but the
|
||||
// ordering guarantee is what we're testing.
|
||||
both := fmt.Errorf("%w: layered with %w", service.ErrNotFound, service.ErrForbidden)
|
||||
if got := errToStatus(both); got != http.StatusNotFound {
|
||||
t.Errorf("dual-wrapped err → %d, want 404 (ErrNotFound must dispatch before ErrForbidden)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrToStatus_ForbiddenBeforeValidation guards the M-003 self-approval
|
||||
// gate against a future call site that double-wraps ErrSelfApproval under
|
||||
// ErrValidation (intentionally or accidentally). The dispatch must pick
|
||||
// 403, not 400.
|
||||
func TestErrToStatus_ForbiddenBeforeValidation(t *testing.T) {
|
||||
doubled := fmt.Errorf("%w: %w", service.ErrSelfApproval, service.ErrValidation)
|
||||
if got := errToStatus(doubled); got != http.StatusForbidden {
|
||||
t.Errorf("double-wrapped err → %d, want 403 (ErrForbidden must dispatch before ErrValidation — M-003 gate)", got)
|
||||
}
|
||||
}
|
||||
@@ -46,12 +46,26 @@ func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result, err := h.svc.ExportPEM(r.Context(), id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
// M-1 (P2): dispatch routes through errToStatus. Pre-M-1 this branch
|
||||
// classified 404 via strings.Contains(err.Error(), "not found"), which
|
||||
// gave false positives on any error whose rendered text happened to
|
||||
// contain "not found" — notably a transient DB failure when the service
|
||||
// layer wrapped every certRepo.Get error with "certificate not found".
|
||||
// Post-M-1: service/export.go now wraps with "failed to get certificate"
|
||||
// and only the genuine sql.ErrNoRows path surfaces repository.ErrNotFound
|
||||
// through the wrap chain, so errors.Is(err, repository.ErrNotFound) picks
|
||||
// up the real 404s and everything else — including transient DB errors —
|
||||
// correctly surfaces as 500 with server-side slog.Error capture (F-002
|
||||
// redacted-500 pattern preserved).
|
||||
status := errToStatus(err)
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("ExportPEM failed", "cert_id", id, "error", err.Error())
|
||||
}
|
||||
slog.Error("ExportPEM failed", "cert_id", id, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID)
|
||||
msg := "Failed to export certificate"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Certificate not found"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,16 +108,32 @@ func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
// M-1 (P2): dispatch routes through errToStatus. The pre-M-1 3-term
|
||||
// substring net (`"not found"|"cannot be parsed"|"no certificates
|
||||
// found"`) is replaced with sentinel dispatch:
|
||||
// - repository.ErrNotFound (from certificate.go Get/GetLatestVersion
|
||||
// sql.ErrNoRows wrap) → 404
|
||||
// - service.ErrUnprocessable (from service/export.go ExportPKCS12's
|
||||
// parsePEMCertificates-failure and empty-chain wraps) → 422 —
|
||||
// semantically correct because the caller's request is fine; our
|
||||
// stored PEM chain is what cannot be processed
|
||||
// - everything else → 500 with slog.Error capture (F-002 redacted-500
|
||||
// pattern preserved)
|
||||
// A transient DB failure that pre-M-1 would have been swept into the
|
||||
// 404 substring branch (because the service wrapped every certRepo.Get
|
||||
// error with "certificate not found") now correctly surfaces as 500.
|
||||
status := errToStatus(err)
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("ExportPKCS12 failed", "cert_id", id, "error", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "cannot be parsed") || strings.Contains(err.Error(), "no certificates found") {
|
||||
ErrorWithRequestID(w, http.StatusUnprocessableEntity, "Certificate data cannot be parsed as X.509", requestID)
|
||||
return
|
||||
msg := "Failed to export PKCS#12"
|
||||
switch status {
|
||||
case http.StatusNotFound:
|
||||
msg = "Certificate not found"
|
||||
case http.StatusUnprocessableEntity:
|
||||
msg = "Certificate data cannot be parsed as X.509"
|
||||
}
|
||||
slog.Error("ExportPKCS12 failed", "cert_id", id, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -108,9 +108,17 @@ func TestExportPEM_Download(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExportPEM_NotFound(t *testing.T) {
|
||||
// M-1 (P2): wrap with service.ErrNotFound via %w so the handler's
|
||||
// errToStatus choke point dispatches to 404 via errors.Is. Pre-M-1 this
|
||||
// test used a raw `fmt.Errorf("certificate not found")` string and relied
|
||||
// on the handler's strings.Contains(err.Error(), "not found") classifier
|
||||
// — which was the same mechanism that silently misclassified transient DB
|
||||
// failures whose text happened to include "not found" (see docblock on
|
||||
// ExportPEM handler). Pinning the sentinel contract makes this test
|
||||
// regression-proof against wrap-text changes.
|
||||
mockSvc := &MockExportService{
|
||||
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
||||
return nil, fmt.Errorf("certificate not found")
|
||||
return nil, fmt.Errorf("%w: certificate", service.ErrNotFound)
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
@@ -214,9 +222,11 @@ func TestExportPKCS12_EmptyPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExportPKCS12_NotFound(t *testing.T) {
|
||||
// M-1 (P2): same sentinel migration as TestExportPEM_NotFound — see
|
||||
// rationale there.
|
||||
mockSvc := &MockExportService{
|
||||
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("certificate not found")
|
||||
return nil, fmt.Errorf("%w: certificate", service.ErrNotFound)
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
@@ -231,6 +241,31 @@ func TestExportPKCS12_NotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportPKCS12_Unprocessable pins the M-1 (P2) 422 contract: when the
|
||||
// service layer wraps a parse failure with service.ErrUnprocessable, the
|
||||
// handler's errToStatus choke point must dispatch to 422 Unprocessable
|
||||
// Entity. Pre-M-1 this was classified via a 2-term substring net
|
||||
// (`"cannot be parsed"|"no certificates found"`) at export.go:101, which
|
||||
// would have been silently broken by a message reword in service/export.go.
|
||||
// The new sentinel makes the dispatch survive message rewording.
|
||||
func TestExportPKCS12_Unprocessable(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("%w: certificate data cannot be parsed as X.509: asn1 decode error", service.ErrUnprocessable)
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPKCS12(w, req)
|
||||
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_ServiceError(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
|
||||
|
||||
@@ -135,16 +135,13 @@ func (h IssuerHandler) CreateIssuer(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
created, err := h.svc.CreateIssuer(r.Context(), issuer)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create issuer", "error", err, "name", issuer.Name, "type", issuer.Type)
|
||||
errMsg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate"):
|
||||
ErrorWithRequestID(w, http.StatusConflict, "An issuer with this name already exists", requestID)
|
||||
case strings.Contains(errMsg, "unsupported issuer type"):
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
|
||||
default:
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create issuer", requestID)
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
h.logger.Error("failed to create issuer", "error", err, "name", issuer.Name, "type", issuer.Type)
|
||||
msg = "internal error"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -177,16 +174,13 @@ func (h IssuerHandler) UpdateIssuer(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
updated, err := h.svc.UpdateIssuer(r.Context(), id, issuer)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update issuer", "error", err, "id", id)
|
||||
errMsg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate"):
|
||||
ErrorWithRequestID(w, http.StatusConflict, "An issuer with this name already exists", requestID)
|
||||
case strings.Contains(errMsg, "not found"):
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID)
|
||||
default:
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update issuer", requestID)
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
h.logger.Error("failed to update issuer", "error", err, "id", id)
|
||||
msg = "internal error"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -210,13 +204,13 @@ func (h IssuerHandler) DeleteIssuer(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteIssuer(r.Context(), id); err != nil {
|
||||
if strings.Contains(err.Error(), "violates foreign key") || strings.Contains(err.Error(), "RESTRICT") {
|
||||
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete issuer: certificates are still using this issuer", requestID)
|
||||
} else if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID)
|
||||
} else {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete issuer", requestID)
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
h.logger.Error("DeleteIssuer failed", "issuer_id", id, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// JobService defines the service interface for job operations.
|
||||
@@ -160,22 +159,13 @@ func (h JobHandler) ApproveJob(w http.ResponseWriter, r *http.Request) {
|
||||
actor := resolveActor(r.Context())
|
||||
|
||||
if err := h.svc.ApproveJob(r.Context(), jobID, actor); err != nil {
|
||||
// M-003: self-approval by the certificate owner is forbidden.
|
||||
if errors.Is(err, service.ErrSelfApproval) {
|
||||
ErrorWithRequestID(w, http.StatusForbidden,
|
||||
"Self-approval is forbidden: the certificate owner cannot approve their own renewal",
|
||||
requestID)
|
||||
return
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("ApproveJob failed", "job_id", jobID, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "cannot approve") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to approve job", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -213,15 +203,13 @@ func (h JobHandler) RejectJob(w http.ResponseWriter, r *http.Request) {
|
||||
actor := resolveActor(r.Context())
|
||||
|
||||
if err := h.svc.RejectJob(r.Context(), jobID, body.Reason, actor); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
|
||||
return
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("RejectJob failed", "job_id", jobID, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
if strings.Contains(err.Error(), "cannot reject") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to reject job", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -107,7 +108,25 @@ func (h NotificationHandler) GetNotification(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
notification, err := h.svc.GetNotification(r.Context(), id)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Notification not found", requestID)
|
||||
// M-1 (P2): dispatch routes through errToStatus. Pre-M-1 this branch was
|
||||
// a blanket `any error → 404 Notification not found` shortcut — which
|
||||
// silently demoted transient DB failures from the service's List() wrap
|
||||
// (notification.go:386 pre-M-1) to 404 Not Found with no observable
|
||||
// external signal. Post-M-1: service/notification.go GetNotification only
|
||||
// wraps the genuine "not found" path with service.ErrNotFound via %w, and
|
||||
// every other error (including the List() wrap) surfaces without that
|
||||
// sentinel — so errors.Is(err, service.ErrNotFound) picks up the real
|
||||
// 404s and everything else correctly surfaces as 500 with server-side
|
||||
// slog.Error capture (F-002 redacted-500 pattern preserved).
|
||||
status := errToStatus(err)
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("GetNotification failed", "notification_id", id, "error", err.Error())
|
||||
}
|
||||
msg := "Failed to get notification"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Notification not found"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,11 +189,26 @@ func (h NotificationHandler) RequeueNotification(w http.ResponseWriter, r *http.
|
||||
notificationID := parts[0]
|
||||
|
||||
if err := h.svc.RequeueNotification(r.Context(), notificationID); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Notification not found", requestID)
|
||||
return
|
||||
// M-1 (P2): dispatch routes through errToStatus. Pre-M-1 this branch
|
||||
// classified 404 via strings.Contains(err.Error(), "not found"), which
|
||||
// would have given false positives on any error whose rendered text
|
||||
// happened to contain "not found" — notably a transient DB failure whose
|
||||
// driver message mentioned a missing relation or column. Post-M-1: the
|
||||
// repo-layer Requeue (postgres/notification.go) wraps zero-rows-affected
|
||||
// with repository.ErrNotFound via %w, and the service layer forwards
|
||||
// that error with %w — so errors.Is(err, repository.ErrNotFound) walks
|
||||
// the full wrap chain and picks up the real 404s while everything else
|
||||
// (including transient DB errors) correctly surfaces as 500 with
|
||||
// server-side slog.Error capture (F-002 redacted-500 pattern preserved).
|
||||
status := errToStatus(err)
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("RequeueNotification failed", "notification_id", notificationID, "error", err.Error())
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to requeue notification", requestID)
|
||||
msg := "Failed to requeue notification"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Notification not found"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -184,13 +185,13 @@ func (h OwnerHandler) DeleteOwner(w http.ResponseWriter, r *http.Request) {
|
||||
id = parts[0]
|
||||
|
||||
if err := h.svc.DeleteOwner(r.Context(), id); err != nil {
|
||||
if strings.Contains(err.Error(), "violates foreign key") || strings.Contains(err.Error(), "RESTRICT") {
|
||||
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete owner: certificates are still assigned to this owner", requestID)
|
||||
} else if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Owner not found", requestID)
|
||||
} else {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete owner", requestID)
|
||||
status := errToStatus(err)
|
||||
msg := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
slog.Error("DeleteOwner failed", "owner_id", id, "error", err)
|
||||
msg = "internal error"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// ProfileService defines the service interface for certificate profile operations.
|
||||
@@ -21,6 +23,20 @@ type ProfileService interface {
|
||||
}
|
||||
|
||||
// ProfileHandler handles HTTP requests for certificate profile operations.
|
||||
//
|
||||
// Error dispatch (post-M-1): every service error routes through the [errToStatus]
|
||||
// choke point via `errors.Is` walking the wrap chain, with one explicit
|
||||
// [service.ErrValidation] arm on the write paths (Create, Update) so the
|
||||
// composed "validation: <field-specific reason>" message the service layer
|
||||
// attaches via `fmt.Errorf("%w: ...", ErrValidation)` can be passed through to
|
||||
// the 400 response body. Before M-1, the Create and Update handlers branched on
|
||||
// `strings.Contains(err.Error(), "invalid"|"required"|"must be"|"cannot")` — a
|
||||
// fragile pattern where a single reword in [service.validateProfile] would
|
||||
// demote the 400 to 500 with no compile-time signal. The substring-based 404
|
||||
// branches on Update and Delete likewise depended on the repository's
|
||||
// human-readable "profile not found" message surviving forever; both now ride
|
||||
// the same [repository.ErrNotFound] wrap that G-1's renewal-policy and M-1's
|
||||
// other repositories use.
|
||||
type ProfileHandler struct {
|
||||
svc ProfileService
|
||||
}
|
||||
@@ -88,7 +104,18 @@ func (h ProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
profile, err := h.svc.GetProfile(r.Context(), id)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
||||
// M-1: route through errToStatus so a repo-level `sql.ErrNoRows`
|
||||
// (wrapped as repository.ErrNotFound) becomes 404, but a transient DB
|
||||
// failure no longer masquerades as 404 — it correctly surfaces 500. The
|
||||
// pre-M-1 "any error → 404" shortcut was plausible when Get's only
|
||||
// expected failure was "not found", but the choke point now gives us
|
||||
// correct dispatch for free. Mirrors GetRenewalPolicy.
|
||||
status := errToStatus(err)
|
||||
msg := "Failed to get profile"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Profile not found"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,9 +150,15 @@ func (h ProfileHandler) CreateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
created, err := h.svc.CreateProfile(r.Context(), profile)
|
||||
if err != nil {
|
||||
// Check if it's a validation error from the service
|
||||
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") ||
|
||||
strings.Contains(err.Error(), "must be") || strings.Contains(err.Error(), "cannot") {
|
||||
// M-1: replace the 4-term substring net
|
||||
// (`"invalid"|"required"|"must be"|"cannot"`) with a single
|
||||
// `errors.Is(err, service.ErrValidation)` arm. validateProfile wraps
|
||||
// every field-specific failure via `fmt.Errorf("%w: <reason>",
|
||||
// ErrValidation)`, so `err.Error()` still contains the human-readable
|
||||
// reason (e.g., "RSA minimum key size must be at least 2048") and can be
|
||||
// safely passed to the 400 body — but the status decision no longer
|
||||
// depends on the exact wording. Other errors redact to a generic 500.
|
||||
if errors.Is(err, service.ErrValidation) {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
@@ -162,16 +195,21 @@ func (h ProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
updated, err := h.svc.UpdateProfile(r.Context(), id, profile)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") ||
|
||||
strings.Contains(err.Error(), "must be") || strings.Contains(err.Error(), "cannot") {
|
||||
// M-1: explicit ErrValidation arm preserves the user-facing reason in
|
||||
// the 400 body (validateProfile wraps every failure via
|
||||
// `fmt.Errorf("%w: ...", ErrValidation)`); every other error — including
|
||||
// repo-layer ErrNotFound on a missing row — routes through errToStatus
|
||||
// so the 404/500 decision no longer depends on substring matching.
|
||||
if errors.Is(err, service.ErrValidation) {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update profile", requestID)
|
||||
status := errToStatus(err)
|
||||
msg := "Failed to update profile"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Profile not found"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,11 +233,14 @@ func (h ProfileHandler) DeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteProfile(r.Context(), id); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
||||
return
|
||||
// M-1: sentinel dispatch replaces the substring 404 check — see the
|
||||
// parallel comment block in UpdateProfile for the rationale.
|
||||
status := errToStatus(err)
|
||||
msg := "Failed to delete profile"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Profile not found"
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete profile", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -26,14 +26,28 @@ type RenewalPolicyService interface {
|
||||
|
||||
// RenewalPolicyHandler serves /api/v1/renewal-policies CRUD endpoints.
|
||||
//
|
||||
// G-1 design note: the service-level `ErrRenewalPolicyDuplicateName` /
|
||||
// `ErrRenewalPolicyInUse` sentinels alias the repository sentinels (same var
|
||||
// identity), so `errors.Is` walks transparently across layers. Delete/Update
|
||||
// not-found detection intentionally uses a `strings.Contains(err.Error(),
|
||||
// "not found")` substring check — the repo wraps `sql.ErrNoRows` as
|
||||
// `fmt.Errorf("renewal policy not found: %s", id)` which strips the sentinel,
|
||||
// and the handler red-tests' `ErrMockNotFound = errors.New("mock not found
|
||||
// error")` follows the same substring convention.
|
||||
// Error dispatch (post-M-1): every service error routes through the [errToStatus]
|
||||
// choke point via `errors.Is` walking the wrap chain. Three sentinel identities
|
||||
// cover the full dispatch surface:
|
||||
//
|
||||
// - [service.ErrRenewalPolicyDuplicateName] / [service.ErrRenewalPolicyInUse]
|
||||
// are `var`-aliased to the repository-layer sentinels of the same name (G-1),
|
||||
// so handler-side `errors.Is` succeeds against a sentinel raised three layers
|
||||
// deep in [internal/repository/postgres.RenewalPolicyRepository] without the
|
||||
// service layer having to translate. [errToStatus] routes both to 409.
|
||||
// - [repository.ErrNotFound] is wrapped around `sql.ErrNoRows` inside the
|
||||
// repo's Get/Update/Delete methods via `fmt.Errorf("%w: renewal policy %s",
|
||||
// repository.ErrNotFound, id)` (M-1). [errToStatus] routes that to 404 in
|
||||
// the same switch arm as [service.ErrNotFound], preserving the existing
|
||||
// 404-on-missing behavior that the pre-M-1 substring check provided —
|
||||
// without the rewording-regression risk that motivated the migration.
|
||||
//
|
||||
// The handler layer keeps two explicit `errors.Is` arms for the
|
||||
// duplicate-name / in-use sentinels so each 409 response can carry a
|
||||
// constraint-specific human-readable message ("with that name" vs. "still
|
||||
// referenced by managed certificates"); every other error path — including
|
||||
// not-found — delegates the status decision to [errToStatus] and provides a
|
||||
// generic body via the F-002 redacted-500 pattern.
|
||||
type RenewalPolicyHandler struct {
|
||||
svc RenewalPolicyService
|
||||
}
|
||||
@@ -105,11 +119,18 @@ func (h RenewalPolicyHandler) GetRenewalPolicy(w http.ResponseWriter, r *http.Re
|
||||
|
||||
policy, err := h.svc.GetRenewalPolicy(r.Context(), id)
|
||||
if err != nil {
|
||||
// Matches the PolicyHandler.GetPolicy convention: any error from the
|
||||
// service surfaces as 404. The repo wraps sql.ErrNoRows as
|
||||
// "renewal policy not found: %s" and there's no other expected failure
|
||||
// mode on Get — the caller gets a clean 404.
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
|
||||
// M-1: route through errToStatus so a repo-level `sql.ErrNoRows`
|
||||
// (wrapped as repository.ErrNotFound) becomes 404, but a transient DB
|
||||
// failure no longer masquerades as 404 — it correctly surfaces 500.
|
||||
// The pre-M-1 "any error → 404" shortcut was plausible when Get's only
|
||||
// expected failure was "not found", but the choke point now gives us
|
||||
// correct dispatch for free.
|
||||
status := errToStatus(err)
|
||||
msg := "Failed to get renewal policy"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Renewal policy not found"
|
||||
}
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -158,11 +179,11 @@ func (h RenewalPolicyHandler) CreateRenewalPolicy(w http.ResponseWriter, r *http
|
||||
// UpdateRenewalPolicy replaces the fields of an existing renewal policy.
|
||||
// PUT /api/v1/renewal-policies/{id}
|
||||
//
|
||||
// Error mapping:
|
||||
// - invalid JSON / empty ID → 400
|
||||
// - ErrRenewalPolicyDuplicateName → 409
|
||||
// - error text contains "not found" → 404 (see struct doc comment re: substring check)
|
||||
// - anything else → 500
|
||||
// Error mapping (post-M-1, sentinel-driven):
|
||||
// - invalid JSON / empty ID → 400
|
||||
// - ErrRenewalPolicyDuplicateName (pg 23505) → 409 (explicit arm, custom msg)
|
||||
// - ErrNotFound (wrapping sql.ErrNoRows) → 404 (via errToStatus)
|
||||
// - anything else → 500 (via errToStatus, body redacted)
|
||||
func (h RenewalPolicyHandler) UpdateRenewalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
@@ -191,11 +212,17 @@ func (h RenewalPolicyHandler) UpdateRenewalPolicy(w http.ResponseWriter, r *http
|
||||
ErrorWithRequestID(w, http.StatusConflict, "A renewal policy with that name already exists", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
|
||||
return
|
||||
// M-1: drop the `strings.Contains(err.Error(), "not found")` branch.
|
||||
// [repository.ErrNotFound] now wraps sql.ErrNoRows at the three
|
||||
// renewal-policy repo methods (Get/Update/Delete), so errToStatus
|
||||
// routes a missing row to 404 via errors.Is without depending on the
|
||||
// repo's fmt.Errorf format string surviving a future reword.
|
||||
status := errToStatus(err)
|
||||
msg := "Failed to update renewal policy"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Renewal policy not found"
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update renewal policy", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -205,11 +232,11 @@ func (h RenewalPolicyHandler) UpdateRenewalPolicy(w http.ResponseWriter, r *http
|
||||
// DeleteRenewalPolicy removes a renewal policy.
|
||||
// DELETE /api/v1/renewal-policies/{id}
|
||||
//
|
||||
// Error mapping:
|
||||
// - empty ID (trailing slash) → 400
|
||||
// - ErrRenewalPolicyInUse (pg 23503 FK-RESTRICT against managed_certificates.renewal_policy_id) → 409
|
||||
// - error text contains "not found" → 404
|
||||
// - anything else → 500
|
||||
// Error mapping (post-M-1, sentinel-driven):
|
||||
// - empty ID (trailing slash) → 400
|
||||
// - ErrRenewalPolicyInUse (pg 23503 FK-RESTRICT) → 409 (explicit arm, custom msg)
|
||||
// - ErrNotFound (wrapping sql.ErrNoRows) → 404 (via errToStatus)
|
||||
// - anything else → 500 (via errToStatus, body redacted)
|
||||
func (h RenewalPolicyHandler) DeleteRenewalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
@@ -231,11 +258,14 @@ func (h RenewalPolicyHandler) DeleteRenewalPolicy(w http.ResponseWriter, r *http
|
||||
ErrorWithRequestID(w, http.StatusConflict, "Renewal policy is still referenced by managed certificates", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
|
||||
return
|
||||
// M-1: sentinel dispatch replaces the substring check — see the
|
||||
// parallel comment block in UpdateRenewalPolicy for the rationale.
|
||||
status := errToStatus(err)
|
||||
msg := "Failed to delete renewal policy"
|
||||
if status == http.StatusNotFound {
|
||||
msg = "Renewal policy not found"
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete renewal policy", requestID)
|
||||
ErrorWithRequestID(w, status, msg, requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// SCEPService defines the service interface for SCEP enrollment operations.
|
||||
@@ -171,11 +173,25 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result, err := h.svc.PKCSReq(r.Context(), csrPEM, challengePassword, transactionID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "challenge password") {
|
||||
ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID)
|
||||
// M-1 (P2): typed-sentinel dispatch replaces the pre-M-1 substring
|
||||
// branch `strings.Contains(err.Error(), "challenge password")`. The
|
||||
// service layer now wraps both challenge-password failure modes (server
|
||||
// misconfigured / client credential wrong) via `fmt.Errorf("%w: ...",
|
||||
// ErrUnauthenticated)`, so errors.Is walks the wrap chain without
|
||||
// depending on the exact wording of the error string. This also
|
||||
// corrects the HTTP status: pre-M-1 returned 403 Forbidden, but RFC
|
||||
// 7235 classifies this as 401 Unauthorized (authentication failure, not
|
||||
// authorization denial). The errToStatus doc block enumerates this as
|
||||
// the canonical ErrUnauthenticated call site.
|
||||
if errors.Is(err, service.ErrUnauthenticated) {
|
||||
ErrorWithRequestID(w, http.StatusUnauthorized, "Invalid challenge password", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Enrollment failed: %v", err), requestID)
|
||||
// F-002 redacted-500: every other enrollment failure (CSR parse errors,
|
||||
// issuer-layer failures, audit-layer failures) returns an opaque body
|
||||
// so we don't leak internal state through the SCEP response. The
|
||||
// logger already captured the real error at the service layer.
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Enrollment failed", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// mockSCEPService implements SCEPService for testing.
|
||||
@@ -214,9 +216,21 @@ func TestSCEP_PKIOperation_Success_RawCSR(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEP_PKIOperation_ChallengePasswordRejected pins the M-1 (P2) dispatch
|
||||
// contract: when the service wraps the failure via `fmt.Errorf("%w: ...",
|
||||
// service.ErrUnauthenticated)` the handler's errToStatus choke point must
|
||||
// return 401 Unauthorized, NOT 403 Forbidden.
|
||||
//
|
||||
// This is a deliberate semantic correction. Pre-M-1 the handler inspected
|
||||
// err.Error() for the "challenge password" substring and returned 403, which
|
||||
// misclassified the RFC 7235 condition (the caller presented no valid
|
||||
// application-layer credential — that is auth failure, not authorization
|
||||
// denial). The errToStatus doc explicitly cites this code path as the
|
||||
// canonical ErrUnauthenticated consumer; see handler/errors.go and the
|
||||
// symmetric M-1 comment block at handler/scep.go in the pkiOperation arm.
|
||||
func TestSCEP_PKIOperation_ChallengePasswordRejected(t *testing.T) {
|
||||
svc := &mockSCEPService{
|
||||
EnrollErr: errors.New("invalid challenge password"),
|
||||
EnrollErr: fmt.Errorf("%w: invalid challenge password", service.ErrUnauthenticated),
|
||||
}
|
||||
h := NewSCEPHandler(svc)
|
||||
|
||||
@@ -230,8 +244,8 @@ func TestSCEP_PKIOperation_ChallengePasswordRejected(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleSCEP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 Unauthorized (M-1 dispatch of service.ErrUnauthenticated), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
package handler
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
var (
|
||||
// Mock errors for testing
|
||||
ErrMockServiceFailed = errors.New("mock service error")
|
||||
ErrMockNotFound = errors.New("mock not found error")
|
||||
ErrMockUnauthorized = errors.New("mock unauthorized error")
|
||||
ErrMockConflict = errors.New("mock conflict error")
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// Mock errors for testing.
|
||||
//
|
||||
// M-1: Since the handler layer now classifies errors via the typed-sentinel
|
||||
// dispatch in [errToStatus] (errors.Is on service + repository sentinels rather
|
||||
// than substring matching on err.Error()), handler mocks MUST wrap the
|
||||
// appropriate generic sentinel so `errors.Is(err, service.ErrNotFound)` etc.
|
||||
// succeed. Using raw errors.New() breaks the dispatch and degrades every
|
||||
// mock-driven negative-path test to a 500 Internal Server Error — the same
|
||||
// silent-regression trap the migration was designed to eliminate.
|
||||
//
|
||||
// ErrMockServiceFailed deliberately stays untyped so it continues to exercise
|
||||
// the default 500 path.
|
||||
var (
|
||||
ErrMockServiceFailed = errors.New("mock service error")
|
||||
ErrMockNotFound = fmt.Errorf("%w: mock not found", service.ErrNotFound)
|
||||
ErrMockUnauthorized = fmt.Errorf("%w: mock unauthenticated", service.ErrUnauthenticated)
|
||||
ErrMockConflict = fmt.Errorf("%w: mock conflict", service.ErrConflict)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user