Files
certctl/internal/api/handler/errors_test.go
T
shankar0123 36e722ba12 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'.
2026-04-24 00:35:12 +00:00

121 lines
5.5 KiB
Go

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)
}
}