mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 23:48:52 +00:00
fix: return 409 on duplicate issuer name, improve error handling and onboarding defaults
Closes #7. The issuer create/update handlers swallowed all service errors as generic 500s. Now differentiates: 409 for UNIQUE constraint violations, 400 for unsupported issuer type, 404 for not-found on update, 500 for unknown errors. Adds structured error logging via slog. OnboardingWizard now pre-populates config field defaults when a type is selected (matching IssuersPage behavior), preventing empty required fields from causing silent failures. install-agent.sh hardened for curl|bash usage: --agent-id flag, =value syntax, /dev/tty stdin reopening, proper stderr routing in download_binary, non-interactive install examples in help text, and updated wizard commands. Adds adversarial security tests for EST, path traversal, and query injection handlers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
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"
|
||||
"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(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(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(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(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(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(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(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(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(id string, reason 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(id string, reason 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(id string, reason 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")
|
||||
}
|
||||
Reference in New Issue
Block a user