mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:11:38 +00:00
cdc9d03d5b
Collapses CertificateService, RevocationSvc, and CAOperationsSvc to ctx-accepting method signatures. Removes context.Background() synthesis at 24 internal call sites across certificate.go, revocation_svc.go, and ca_operations.go. - Primary repo calls inherit request cancellation via the passed ctx. - Audit and notification dispatches use context.WithoutCancel(ctx) so they survive client disconnect. - Collapses TriggerRenewal/TriggerRenewalWithActor, TriggerDeployment/TriggerDeploymentWithActor, and RevokeCertificate/RevokeCertificateWithActor sibling pairs into single canonical ctx-accepting methods (decisions D-1, D-2). Handlers pass r.Context(). Mocks and tests updated to match new signatures. No HTTP surface change, no OpenAPI change. PR 1 of 6 in the M-2 remediation chain. Master green at this commit. Refs: certctl-audit-report.md M-2 (L143, L224)
540 lines
19 KiB
Go
540 lines
19 KiB
Go
package handler
|
||
|
||
// Adversarial query-parameter, request-body, and revocation-reason tests.
|
||
//
|
||
// These tests exercise the second boundary of the certificate handler:
|
||
//
|
||
// * Numeric pagination parsing (page, per_page, page_size)
|
||
// * Sort direction and field whitelist
|
||
// * Time-range filters (expires_before, expires_after, created_after, updated_after)
|
||
// * Cursor pagination
|
||
// * Sparse-field projection (?fields=...)
|
||
// * Request-body JSON parsing (create/update) — null, malformed, deep nesting,
|
||
// unicode, oversized
|
||
// * Revocation reason abuse
|
||
//
|
||
// The handler silently ignores malformed pagination values (it falls back to
|
||
// defaults) and ignores invalid RFC3339 time values. These tests lock in that
|
||
// behaviour so a future "fail-closed" change has to be deliberate.
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/shankar0123/certctl/internal/domain"
|
||
"github.com/shankar0123/certctl/internal/repository"
|
||
)
|
||
|
||
// buildListRequest constructs a GET /api/v1/certificates request with the
|
||
// given raw query string. We use raw query strings (not url.Values.Encode)
|
||
// so adversarial inputs like "page=abc&page=-1" or "%00" pass through
|
||
// unchanged.
|
||
func buildListRequest(rawQuery string) *http.Request {
|
||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||
req.URL.RawQuery = rawQuery
|
||
return req.WithContext(contextWithRequestID())
|
||
}
|
||
|
||
// TestListCertificates_PaginationAbuse verifies adversarial pagination values
|
||
// never produce a 500 and the handler always falls back to sane defaults.
|
||
func TestListCertificates_PaginationAbuse(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
rawQuery string
|
||
}{
|
||
{"negative_page", "page=-1"},
|
||
{"zero_page", "page=0"},
|
||
{"non_numeric_page", "page=abc"},
|
||
{"huge_page", "page=99999999999"},
|
||
{"int_overflow_page", "page=9223372036854775808"}, // int64 max + 1
|
||
{"negative_per_page", "per_page=-1"},
|
||
{"zero_per_page", "per_page=0"},
|
||
{"per_page_cap_at_500", "per_page=500"},
|
||
{"per_page_above_cap", "per_page=501"},
|
||
{"per_page_absurd", "per_page=1000000"},
|
||
{"non_numeric_per_page", "per_page=xyz"},
|
||
{"mixed_numeric_per_page", "per_page=10abc"},
|
||
{"negative_page_size", "page_size=-1"},
|
||
{"page_size_above_cap", "page_size=501"},
|
||
{"float_page", "page=1.5"},
|
||
{"exponent_page", "page=1e10"},
|
||
{"hex_page", "page=0xff"},
|
||
{"unicode_digits_page", "page=\u0661\u0662\u0663"}, // Arabic-Indic digits
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
t.Fatalf("panicked on %q: %v", tc.rawQuery, r)
|
||
}
|
||
}()
|
||
|
||
handler, mock := newCertHandlerWithMock()
|
||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||
// Sanity: page/perPage on the filter must never be negative
|
||
// and perPage must never exceed 500 after parsing.
|
||
if filter.Page < 1 {
|
||
t.Errorf("filter.Page=%d (must be >=1)", filter.Page)
|
||
}
|
||
if filter.PerPage < 1 || filter.PerPage > 500 {
|
||
t.Errorf("filter.PerPage=%d (must be in [1,500])", filter.PerPage)
|
||
}
|
||
return []domain.ManagedCertificate{}, 0, nil
|
||
}
|
||
|
||
w := httptest.NewRecorder()
|
||
handler.ListCertificates(w, buildListRequest(tc.rawQuery))
|
||
|
||
assertSafeResponse(t, w, "ListCertificates/"+tc.name)
|
||
if w.Code != http.StatusOK {
|
||
t.Errorf("%s: expected 200, got %d (body=%q)", tc.name, w.Code, w.Body.String())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestListCertificates_SortAbuse verifies the sort field (which feeds into a
|
||
// whitelist in the repository layer) handles adversarial input safely at the
|
||
// handler boundary. The handler accepts the raw value and forwards it; the
|
||
// repository is expected to whitelist it, but at THIS layer we just verify
|
||
// we don't crash or leak.
|
||
func TestListCertificates_SortAbuse(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
rawQuery string
|
||
}{
|
||
{"sql_injection_sort", "sort=notAfter;DROP TABLE managed_certificates--"},
|
||
{"sql_injection_or", "sort=notAfter' OR '1'='1"},
|
||
{"path_traversal_sort", "sort=../../etc/passwd"},
|
||
{"null_byte_sort", "sort=notAfter%00"},
|
||
{"unicode_sort", "sort=notAfter\u2010desc"},
|
||
{"leading_dash_only", "sort=-"},
|
||
{"leading_dashes", "sort=---notAfter"},
|
||
{"empty_sort", "sort="},
|
||
{"very_long_sort", "sort=" + strings.Repeat("a", 5000)},
|
||
{"sort_desc_flag", "sort=notAfter&sort_desc=true"},
|
||
{"conflicting_sort_desc", "sort=-notAfter&sort_desc=false"},
|
||
{"unknown_field", "sort=gibberish"},
|
||
{"shell_metacharacters_sort", "sort=notAfter;rm -rf /"},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
t.Fatalf("panicked on %q: %v", tc.rawQuery, r)
|
||
}
|
||
}()
|
||
|
||
handler, mock := newCertHandlerWithMock()
|
||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||
return []domain.ManagedCertificate{}, 0, nil
|
||
}
|
||
|
||
w := httptest.NewRecorder()
|
||
handler.ListCertificates(w, buildListRequest(tc.rawQuery))
|
||
|
||
assertSafeResponse(t, w, "ListCertificates/"+tc.name)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestListCertificates_FieldsAbuse verifies sparse field projection handles
|
||
// adversarial field lists safely.
|
||
func TestListCertificates_FieldsAbuse(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
rawQuery string
|
||
}{
|
||
{"sql_injection_fields", "fields=id,name' OR 1=1--"},
|
||
{"path_traversal_fields", "fields=../../etc/passwd"},
|
||
{"empty_fields", "fields="},
|
||
{"single_comma", "fields=,"},
|
||
{"trailing_comma", "fields=id,name,"},
|
||
{"leading_comma", "fields=,id,name"},
|
||
{"whitespace_fields", "fields= id , name "},
|
||
{"duplicate_fields", "fields=id,id,id,id,id"},
|
||
{"unknown_fields", "fields=totally_not_a_field"},
|
||
{"many_fields", "fields=" + strings.Repeat("x,", 200) + "id"},
|
||
{"unicode_fields", "fields=id,n\u00e4me"},
|
||
{"null_byte_fields", "fields=id%00name"},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
t.Fatalf("panicked on %q: %v", tc.rawQuery, r)
|
||
}
|
||
}()
|
||
|
||
handler, mock := newCertHandlerWithMock()
|
||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||
return []domain.ManagedCertificate{}, 0, nil
|
||
}
|
||
|
||
w := httptest.NewRecorder()
|
||
handler.ListCertificates(w, buildListRequest(tc.rawQuery))
|
||
|
||
assertSafeResponse(t, w, "ListCertificates/"+tc.name)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestListCertificates_TimeRangeAbuse verifies RFC3339 time-range filters
|
||
// handle malformed input by silently falling back to no filter (current
|
||
// behaviour).
|
||
func TestListCertificates_TimeRangeAbuse(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
rawQuery string
|
||
}{
|
||
{"invalid_expires_before", "expires_before=not-a-date"},
|
||
{"empty_expires_before", "expires_before="},
|
||
{"garbage_expires_before", "expires_before=%00%00"},
|
||
{"sql_injection_time", "expires_before=2026-01-01T00:00:00Z';DROP TABLE managed_certificates--"},
|
||
{"year_zero", "expires_before=0000-01-01T00:00:00Z"},
|
||
{"year_negative", "expires_before=-0001-01-01T00:00:00Z"},
|
||
{"year_huge", "expires_before=99999-12-31T23:59:59Z"},
|
||
{"invalid_month", "expires_before=2026-13-01T00:00:00Z"},
|
||
{"invalid_day", "expires_before=2026-02-30T00:00:00Z"},
|
||
{"valid_utc", "expires_before=2026-06-15T12:00:00Z"},
|
||
{"valid_with_offset", "expires_before=2026-06-15T12:00:00-07:00"},
|
||
{"unix_seconds_not_rfc3339", "expires_before=1767225600"},
|
||
{"all_four_filters", "expires_before=garbage&expires_after=garbage&created_after=garbage&updated_after=garbage"},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
t.Fatalf("panicked on %q: %v", tc.rawQuery, r)
|
||
}
|
||
}()
|
||
|
||
handler, mock := newCertHandlerWithMock()
|
||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||
return []domain.ManagedCertificate{}, 0, nil
|
||
}
|
||
|
||
w := httptest.NewRecorder()
|
||
handler.ListCertificates(w, buildListRequest(tc.rawQuery))
|
||
|
||
assertSafeResponse(t, w, "ListCertificates/"+tc.name)
|
||
if w.Code != http.StatusOK {
|
||
t.Errorf("%s: expected 200, got %d", tc.name, w.Code)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestListCertificates_CursorAbuse exercises cursor-based pagination with
|
||
// adversarial cursor tokens. The handler forwards the cursor to the
|
||
// repository; we verify no 500 at the boundary and that the response type
|
||
// switches correctly.
|
||
func TestListCertificates_CursorAbuse(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
cursor string
|
||
}{
|
||
{"empty_not_set", ""}, // special-cased: should return PagedResponse
|
||
{"garbage_cursor", "not-a-valid-cursor"},
|
||
{"base64_garbage", "dGhpcyBpcyBub3QgYSB2YWxpZCBjdXJzb3I="},
|
||
{"sql_injection_cursor", "2026-01-01T00:00:00Z:mc-001';DROP TABLE--"},
|
||
{"path_traversal_cursor", "../../etc/passwd"},
|
||
{"null_byte_cursor", "valid%00cursor"},
|
||
{"very_long_cursor", strings.Repeat("A", 8192)},
|
||
{"unicode_cursor", "2026-01-01T00:00:00Z:mc\u20100001"},
|
||
{"valid_looking_cursor", "2026-01-01T00:00:00.000000000Z:mc-001"},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
t.Fatalf("panicked on %q: %v", tc.cursor, r)
|
||
}
|
||
}()
|
||
|
||
handler, mock := newCertHandlerWithMock()
|
||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||
return []domain.ManagedCertificate{}, 0, nil
|
||
}
|
||
|
||
rawQuery := "cursor=" + url.QueryEscape(tc.cursor) + "&page_size=50"
|
||
if tc.cursor == "" {
|
||
rawQuery = "page=1&per_page=50"
|
||
}
|
||
w := httptest.NewRecorder()
|
||
handler.ListCertificates(w, buildListRequest(rawQuery))
|
||
|
||
assertSafeResponse(t, w, "ListCertificates/"+tc.name)
|
||
if w.Code != http.StatusOK {
|
||
t.Errorf("%s: expected 200, got %d", tc.name, w.Code)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestListCertificates_FilterInjection verifies the basic string filters
|
||
// (status, environment, owner_id, team_id, issuer_id, agent_id, profile_id)
|
||
// are forwarded as-is without causing any handler-layer failures. These go
|
||
// into parameterized SQL at the repo layer.
|
||
func TestListCertificates_FilterInjection(t *testing.T) {
|
||
filters := []string{
|
||
"status", "environment", "owner_id", "team_id",
|
||
"issuer_id", "agent_id", "profile_id",
|
||
}
|
||
payloads := []string{
|
||
"' OR 1=1--",
|
||
"'; DROP TABLE managed_certificates;--",
|
||
"../../etc/passwd",
|
||
strings.Repeat("A", 5000),
|
||
"\u2010hyphen",
|
||
"%00null",
|
||
}
|
||
|
||
for _, f := range filters {
|
||
for _, p := range payloads {
|
||
name := f + "__" + p
|
||
if len(name) > 80 {
|
||
name = name[:80]
|
||
}
|
||
t.Run(name, func(t *testing.T) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
t.Fatalf("panicked: %v", r)
|
||
}
|
||
}()
|
||
|
||
handler, mock := newCertHandlerWithMock()
|
||
mock.ListCertificatesWithFilterFn = func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||
return []domain.ManagedCertificate{}, 0, nil
|
||
}
|
||
|
||
rawQuery := f + "=" + url.QueryEscape(p)
|
||
w := httptest.NewRecorder()
|
||
handler.ListCertificates(w, buildListRequest(rawQuery))
|
||
|
||
assertSafeResponse(t, w, "ListCertificates/"+f)
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- Request body abuse (Tier 1D) ----------
|
||
|
||
// TestCreateCertificate_BodyAbuse sends adversarial JSON bodies to
|
||
// POST /api/v1/certificates. Every case must respond with 400 (not 500,
|
||
// not 200). This proves we reject malformed input before reaching the
|
||
// service layer.
|
||
func TestCreateCertificate_BodyAbuse(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
body string
|
||
}{
|
||
{"null_body", "null"},
|
||
{"empty_body", ""},
|
||
{"not_json", "not json at all"},
|
||
{"truncated_json", `{"common_name":"exa`},
|
||
{"unclosed_object", `{"common_name":"example.com"`},
|
||
{"array_not_object", `["example.com"]`},
|
||
{"number_not_object", `42`},
|
||
{"string_not_object", `"hello"`},
|
||
{"boolean_not_object", `true`},
|
||
{"duplicate_keys", `{"common_name":"evil.com","common_name":"example.com"}`},
|
||
{"unicode_bom", "\ufeff{\"common_name\":\"example.com\"}"},
|
||
{"deep_nesting", strings.Repeat("{\"x\":", 100) + "null" + strings.Repeat("}", 100)},
|
||
{"nested_array_bomb", `{"common_name":"x","sans":[[[[[[[[[[]]]]]]]]]]}`},
|
||
{"sql_injection_cn", `{"common_name":"'; DROP TABLE managed_certificates;--"}`},
|
||
{"empty_cn", `{"common_name":""}`},
|
||
{"null_cn", `{"common_name":null}`},
|
||
{"whitespace_cn", `{"common_name":" "}`},
|
||
{"cn_too_long", fmt.Sprintf(`{"common_name":%q}`, strings.Repeat("a", 500))},
|
||
{"cn_path_traversal", `{"common_name":"../../etc/passwd"}`},
|
||
{"cn_null_byte", "{\"common_name\":\"example\\u0000.com\"}"},
|
||
{"cn_newline", "{\"common_name\":\"example\\n.com\"}"},
|
||
{"cn_only_missing_others", `{"common_name":"example.com"}`},
|
||
{"extra_unknown_fields", `{"common_name":"example.com","__proto__":{"polluted":true},"eval":"alert(1)"}`},
|
||
{"unicode_homoglyph_cn", "{\"common_name\":\"ex\u0430mple.com\"}"}, // Cyrillic а
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
t.Fatalf("panicked on %q: %v", tc.name, r)
|
||
}
|
||
}()
|
||
|
||
handler, mock := newCertHandlerWithMock()
|
||
mock.CreateCertificateFn = func(_ context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||
// If we ever reach this, the handler accepted a malformed
|
||
// body. Return a sentinel that passes but flag it.
|
||
c := cert
|
||
c.ID = "mc-accepted"
|
||
return &c, nil
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", bytes.NewBufferString(tc.body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req = req.WithContext(contextWithRequestID())
|
||
w := httptest.NewRecorder()
|
||
handler.CreateCertificate(w, req)
|
||
|
||
assertSafeResponse(t, w, "CreateCertificate/"+tc.name)
|
||
// Must NOT be 201 — all these bodies should be rejected.
|
||
if w.Code == http.StatusCreated {
|
||
t.Errorf("%s: handler accepted malformed body (201) body=%q", tc.name, w.Body.String())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestCreateCertificate_HugeBody sends a 2 MiB JSON body. The body-limit
|
||
// middleware is not in this handler-unit test, so we just verify the handler
|
||
// doesn't OOM/panic on a large but well-formed body.
|
||
func TestCreateCertificate_HugeBody(t *testing.T) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
t.Fatalf("panicked on huge body: %v", r)
|
||
}
|
||
}()
|
||
|
||
// 2 MiB of SANs — well-formed JSON, technically valid, just huge.
|
||
var sb strings.Builder
|
||
sb.WriteString(`{"common_name":"example.com","owner_id":"o","team_id":"t","issuer_id":"iss","name":"n","renewal_policy_id":"rp","sans":[`)
|
||
for i := 0; i < 20000; i++ {
|
||
if i > 0 {
|
||
sb.WriteByte(',')
|
||
}
|
||
fmt.Fprintf(&sb, `"host%d.example.com"`, i)
|
||
}
|
||
sb.WriteString(`]}`)
|
||
|
||
handler, mock := newCertHandlerWithMock()
|
||
mock.CreateCertificateFn = func(_ context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||
c := cert
|
||
c.ID = "mc-huge"
|
||
return &c, nil
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", strings.NewReader(sb.String()))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req = req.WithContext(contextWithRequestID())
|
||
w := httptest.NewRecorder()
|
||
handler.CreateCertificate(w, req)
|
||
|
||
assertSafeResponse(t, w, "CreateCertificate/huge_body")
|
||
}
|
||
|
||
// ---------- Revocation reason abuse (Tier 1E) ----------
|
||
|
||
// TestRevokeCertificate_ReasonAbuse sends adversarial revocation reasons to
|
||
// POST /api/v1/certificates/{id}/revoke. The handler forwards the reason
|
||
// string to the service layer, which validates against RFC 5280. Errors
|
||
// from the service containing "invalid revocation reason" must map to 400,
|
||
// never 500.
|
||
func TestRevokeCertificate_ReasonAbuse(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
body string
|
||
}{
|
||
{"empty_reason", `{"reason":""}`},
|
||
{"null_reason", `{"reason":null}`},
|
||
{"nonexistent_reason", `{"reason":"totally made up"}`},
|
||
{"case_variant", `{"reason":"KEYCOMPROMISE"}`},
|
||
{"with_spaces", `{"reason":"key compromise"}`},
|
||
{"with_dashes", `{"reason":"key-compromise"}`},
|
||
{"mixed_case", `{"reason":"KeyCompromise"}`},
|
||
{"lowercase_valid", `{"reason":"keycompromise"}`},
|
||
{"unicode_homoglyph", "{\"reason\":\"keyCompr\u043emise\"}"},
|
||
{"sql_injection", `{"reason":"keyCompromise';DROP TABLE revocations--"}`},
|
||
{"very_long", fmt.Sprintf(`{"reason":%q}`, strings.Repeat("a", 10000))},
|
||
{"integer_reason", `{"reason":1}`},
|
||
{"array_reason", `{"reason":["keyCompromise"]}`},
|
||
{"object_reason", `{"reason":{"code":1}}`},
|
||
{"extra_fields", `{"reason":"keyCompromise","admin":true,"bypass":true}`},
|
||
{"no_body", ``},
|
||
{"malformed_json", `{"reason":`},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
t.Fatalf("panicked on %q: %v", tc.name, r)
|
||
}
|
||
}()
|
||
|
||
handler, mock := newCertHandlerWithMock()
|
||
// The mock always returns "invalid revocation reason" so we
|
||
// verify the handler's errMsg→status mapping turns it into a 400.
|
||
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
|
||
// The service uses domain.IsValidRevocationReason. If we got
|
||
// through to here with something bogus, simulate a real
|
||
// service error.
|
||
return fmt.Errorf("invalid revocation reason: %q", reason)
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-001/revoke", bytes.NewBufferString(tc.body))
|
||
req.URL.Path = "/api/v1/certificates/mc-001/revoke"
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req = req.WithContext(contextWithRequestID())
|
||
w := httptest.NewRecorder()
|
||
handler.RevokeCertificate(w, req)
|
||
|
||
assertSafeResponse(t, w, "RevokeCertificate/"+tc.name)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestRevokeCertificate_AlreadyRevoked locks in the specific error->status
|
||
// mapping for "already revoked". The handler uses substring matching on the
|
||
// service error message, which is fragile — this test catches regressions.
|
||
func TestRevokeCertificate_AlreadyRevoked(t *testing.T) {
|
||
handler, mock := newCertHandlerWithMock()
|
||
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
|
||
return fmt.Errorf("cannot revoke: certificate is already revoked")
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-001/revoke", strings.NewReader(`{"reason":"keyCompromise"}`))
|
||
req.URL.Path = "/api/v1/certificates/mc-001/revoke"
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req = req.WithContext(contextWithRequestID())
|
||
w := httptest.NewRecorder()
|
||
handler.RevokeCertificate(w, req)
|
||
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("expected 400 for already-revoked, got %d (body=%q)", w.Code, w.Body.String())
|
||
}
|
||
assertSafeResponse(t, w, "RevokeCertificate/already_revoked")
|
||
}
|
||
|
||
// TestRevokeCertificate_NotFound verifies 404 mapping.
|
||
func TestRevokeCertificate_NotFound(t *testing.T) {
|
||
handler, mock := newCertHandlerWithMock()
|
||
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
|
||
return fmt.Errorf("certificate not found")
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-missing/revoke", strings.NewReader(`{"reason":"keyCompromise"}`))
|
||
req.URL.Path = "/api/v1/certificates/mc-missing/revoke"
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req = req.WithContext(contextWithRequestID())
|
||
w := httptest.NewRecorder()
|
||
handler.RevokeCertificate(w, req)
|
||
|
||
if w.Code != http.StatusNotFound {
|
||
t.Errorf("expected 404 for not-found, got %d (body=%q)", w.Code, w.Body.String())
|
||
}
|
||
assertSafeResponse(t, w, "RevokeCertificate/not_found")
|
||
}
|