mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:01:36 +00:00
36e722ba12
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'.
121 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|