mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:41:31 +00:00
0e06f6c4fc
Closes findings P3-1 and P3-2 from the 2026-05-05 CLI/API/MCP↔GUI parity
audit (cowork/cli-gui-parity-audit-2026-05-05/RESULTS.md). Both findings
flagged hidden defaults that the CLI was sending without exposing them
to operators: `force=false` baked into every renew payload, and a silent
fallback to `reason="unspecified"` whenever --reason was omitted.
P3-1 — promote --force on `certs renew` (full end-to-end plumbing)
The pre-2026-05-05 CLI sent `{"force": false}` in the renew body. The
API handler never decoded it — a textbook "lying field" per the
operator's CLAUDE.md "complete path, not the easy path" rule: the body
field stored a value, claimed to do something, and silently did nothing
because the wire never reached the consumer. Adding a --force flag that
also went unread would have created another lying field.
This commit takes the complete path:
service.CertificateService.TriggerRenewal grew a `force bool` parameter
(internal/service/certificate.go). When force=true, the
RenewalInProgress block is overridden so operators can recover stuck
in-flight renewals where a previous job hung without releasing the
status flag. Archived and Expired remain terminal blockers regardless
of force — those are semantic dead-ends that --force should not paper
over (archived = decommissioned, expired = issue a new cert instead of
renewing a dead one).
handler.CertificateHandler.TriggerRenewal parses force from
?force=true (or ?force=1) query param, OR {"force": true} JSON body,
whichever the client picks. Defaults to false. Passes through to the
service.
internal/cli/client.go::RenewCertificate(id, force bool) sends
?force=true on the URL when --force is set. The historical hardcoded
`{"force": false}` body is gone — no more lying field.
cmd/cli/main.go dispatches `certs renew <id> [--force]` (ID-first
flag-second convention matches the existing `agents retire <id>
[--force]`).
P3-2 — require --reason on `certs revoke` (Option A: strict refusal)
The pre-2026-05-05 CLI dropped to `--reason unspecified` whenever the
operator omitted the flag. Compliance reporting (RFC 5280 §5.3.1, PCI-
DSS §3.6, HIPAA §164.312) relies on the reason code being meaningful;
silent fallback defeats the audit trail because every revocation looks
identical.
cmd/cli/main.go dispatch refuses to send when --reason is empty,
prints the canonical RFC 5280 §5.3.1 reason-code menu, and exits
non-zero.
internal/cli/client.go exposes ValidRevokeReasons() returning the
canonical camelCase list (unspecified, keyCompromise, caCompromise,
affiliationChanged, superseded, cessationOfOperation, certificateHold,
removeFromCRL, privilegeWithdrawn, aaCompromise) and
NormalizeRevokeReason() that accepts both camelCase and snake_case
inputs and normalises to the canonical wire form. Off-list reasons
are rejected at dispatch with the menu re-printed.
Test pins:
internal/cli/client_test.go::TestClient_RenewCertificate_ForceFlag —
--force=true sends ?force=true with empty body; --force=false sends
no query and no body.
internal/cli/client_test.go::TestNormalizeRevokeReason +
TestValidRevokeReasons — canonical-camelCase + snake_case + reject-
off-enum behaviour.
cmd/cli/dispatch_test.go::TestHandleCerts_Revoke_RequiresReason +
TestHandleCerts_Revoke_RejectsUnknownReason +
TestHandleCerts_Renew_ForceFlag — dispatch-layer pins for the same
contracts.
internal/api/handler/certificate_handler_test.go::TestTriggerRenewal_
ForceQueryParam — query-param passthrough (no-flag, force=true,
force=1, force=false) flows through to the service-layer parameter.
internal/service/certificate_test.go::TestTriggerRenewal_
ForceOverridesInProgress — force=false preserves the
RenewalInProgress block; force=true clears it.
Existing TestTriggerRenewal_Archived extended to assert force=true
still blocks Archived (terminal-state guarantee).
Docs: docs/reference/cli.md updated with the --force example for renew
and the strict --reason semantics for revoke (including snake_case
input acceptance).
Acceptance gate (verified):
- go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/...
./cmd/mcp-server/... clean.
- go vet ./... clean.
- go test -short -count=1 ./... pass repo-wide.
- bash scripts/ci-guards/openapi-handler-parity.sh clean
(router 178, OpenAPI 144, exceptions 36 — unchanged; we add
parameter parsing, not routes).
- gofmt -l clean.
1825 lines
59 KiB
Go
1825 lines
59 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ocsp"
|
|
|
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
// MockCertificateService is a mock implementation of CertificateService interface.
|
|
type MockCertificateService struct {
|
|
ListCertificatesFn func(ctx context.Context, status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error)
|
|
ListCertificatesWithFilterFn func(ctx context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error)
|
|
GetCertificateFn func(ctx context.Context, id string) (*domain.ManagedCertificate, error)
|
|
CreateCertificateFn func(ctx context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
|
|
UpdateCertificateFn func(ctx context.Context, id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
|
|
ArchiveCertificateFn func(ctx context.Context, id string) error
|
|
GetCertificateVersionsFn func(ctx context.Context, certID string, page, perPage int) ([]domain.CertificateVersion, int64, error)
|
|
TriggerRenewalFn func(ctx context.Context, certID string, actor string, force bool) error
|
|
TriggerDeploymentFn func(ctx context.Context, certID string, targetID string, actor string) error
|
|
RevokeCertificateFn func(ctx context.Context, certID string, reason string, actor string) error
|
|
GetRevokedCertificatesFn func(ctx context.Context) ([]*domain.CertificateRevocation, error)
|
|
GenerateDERCRLFn func(ctx context.Context, issuerID string) ([]byte, error)
|
|
GetOCSPResponseFn func(ctx context.Context, issuerID string, serialHex string) ([]byte, error)
|
|
GetOCSPResponseWithNonceFn func(ctx context.Context, issuerID string, serialHex string, nonce []byte) ([]byte, error)
|
|
GetCertificateDeploymentsFn func(ctx context.Context, certID string) ([]domain.DeploymentTarget, error)
|
|
}
|
|
|
|
func (m *MockCertificateService) ListCertificates(ctx context.Context, status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
|
|
if m.ListCertificatesFn != nil {
|
|
return m.ListCertificatesFn(ctx, status, environment, ownerID, teamID, issuerID, page, perPage)
|
|
}
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) GetCertificate(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
|
|
if m.GetCertificateFn != nil {
|
|
return m.GetCertificateFn(ctx, id)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) CreateCertificate(ctx context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
|
if m.CreateCertificateFn != nil {
|
|
return m.CreateCertificateFn(ctx, cert)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) UpdateCertificate(ctx context.Context, id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
|
if m.UpdateCertificateFn != nil {
|
|
return m.UpdateCertificateFn(ctx, id, cert)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) ArchiveCertificate(ctx context.Context, id string) error {
|
|
if m.ArchiveCertificateFn != nil {
|
|
return m.ArchiveCertificateFn(ctx, id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockCertificateService) GetCertificateVersions(ctx context.Context, certID string, page, perPage int) ([]domain.CertificateVersion, int64, error) {
|
|
if m.GetCertificateVersionsFn != nil {
|
|
return m.GetCertificateVersionsFn(ctx, certID, page, perPage)
|
|
}
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) TriggerRenewal(ctx context.Context, certID string, actor string, force bool) error {
|
|
if m.TriggerRenewalFn != nil {
|
|
return m.TriggerRenewalFn(ctx, certID, actor, force)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockCertificateService) TriggerDeployment(ctx context.Context, certID string, targetID string, actor string) error {
|
|
if m.TriggerDeploymentFn != nil {
|
|
return m.TriggerDeploymentFn(ctx, certID, targetID, actor)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockCertificateService) RevokeCertificate(ctx context.Context, certID string, reason string, actor string) error {
|
|
if m.RevokeCertificateFn != nil {
|
|
return m.RevokeCertificateFn(ctx, certID, reason, actor)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockCertificateService) GetRevokedCertificates(ctx context.Context) ([]*domain.CertificateRevocation, error) {
|
|
if m.GetRevokedCertificatesFn != nil {
|
|
return m.GetRevokedCertificatesFn(ctx)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) GenerateDERCRL(ctx context.Context, issuerID string) ([]byte, error) {
|
|
if m.GenerateDERCRLFn != nil {
|
|
return m.GenerateDERCRLFn(ctx, issuerID)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) GetOCSPResponse(ctx context.Context, issuerID string, serialHex string) ([]byte, error) {
|
|
if m.GetOCSPResponseFn != nil {
|
|
return m.GetOCSPResponseFn(ctx, issuerID, serialHex)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// GetOCSPResponseWithNonce — production hardening II Phase 1.
|
|
// Falls through to the legacy GetOCSPResponseFn when a per-test
|
|
// nonce-aware override isn't set, mirroring the behavior of the
|
|
// real CertificateService where the nonce-less variant is just a
|
|
// nil-nonce wrapper around the nonce-aware path.
|
|
func (m *MockCertificateService) GetOCSPResponseWithNonce(ctx context.Context, issuerID string, serialHex string, nonce []byte) ([]byte, error) {
|
|
if m.GetOCSPResponseWithNonceFn != nil {
|
|
return m.GetOCSPResponseWithNonceFn(ctx, issuerID, serialHex, nonce)
|
|
}
|
|
if m.GetOCSPResponseFn != nil {
|
|
return m.GetOCSPResponseFn(ctx, issuerID, serialHex)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) ListCertificatesWithFilter(ctx context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
if m.ListCertificatesWithFilterFn != nil {
|
|
return m.ListCertificatesWithFilterFn(ctx, filter)
|
|
}
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) GetCertificateDeployments(ctx context.Context, certID string) ([]domain.DeploymentTarget, error) {
|
|
if m.GetCertificateDeploymentsFn != nil {
|
|
return m.GetCertificateDeploymentsFn(ctx, certID)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// Helper function to create context with request ID.
|
|
func contextWithRequestID() context.Context {
|
|
return context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-123")
|
|
}
|
|
|
|
// Test ListCertificates - success case
|
|
func TestListCertificates_Success(t *testing.T) {
|
|
cert1 := domain.ManagedCertificate{
|
|
ID: "mc-prod-001",
|
|
Name: "Production Cert",
|
|
CommonName: "example.com",
|
|
Status: domain.CertificateStatusActive,
|
|
Environment: "prod",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
cert2 := domain.ManagedCertificate{
|
|
ID: "mc-prod-002",
|
|
Name: "API Cert",
|
|
CommonName: "api.example.com",
|
|
Status: domain.CertificateStatusActive,
|
|
Environment: "prod",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
if filter.Page == 1 && filter.PerPage == 50 {
|
|
return []domain.ManagedCertificate{cert1, cert2}, 2, nil
|
|
}
|
|
return nil, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?page=1&per_page=50", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
|
|
var response PagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if response.Total != 2 {
|
|
t.Errorf("expected total 2, got %d", response.Total)
|
|
}
|
|
if response.Page != 1 {
|
|
t.Errorf("expected page 1, got %d", response.Page)
|
|
}
|
|
if response.PerPage != 50 {
|
|
t.Errorf("expected per_page 50, got %d", response.PerPage)
|
|
}
|
|
}
|
|
|
|
// Test ListCertificates - with filters
|
|
func TestListCertificates_WithFilters(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
if filter.Status == "Active" && filter.Environment == "prod" {
|
|
return []domain.ManagedCertificate{}, 0, nil
|
|
}
|
|
return nil, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?status=Active&environment=prod&page=1&per_page=25", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test ListCertificates - invalid method
|
|
func TestListCertificates_MethodNotAllowed(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test ListCertificates - service error
|
|
func TestListCertificates_ServiceError(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
return nil, 0, ErrMockServiceFailed
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test GetCertificate - success case
|
|
func TestGetCertificate_Success(t *testing.T) {
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-prod-001",
|
|
Name: "Production Cert",
|
|
CommonName: "example.com",
|
|
Status: domain.CertificateStatusActive,
|
|
Environment: "prod",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
mock := &MockCertificateService{
|
|
GetCertificateFn: func(_ context.Context, id string) (*domain.ManagedCertificate, error) {
|
|
if id == "mc-prod-001" {
|
|
return cert, nil
|
|
}
|
|
return nil, ErrMockNotFound
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-prod-001", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCertificate(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
|
|
var response domain.ManagedCertificate
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if response.ID != "mc-prod-001" {
|
|
t.Errorf("expected ID mc-prod-001, got %s", response.ID)
|
|
}
|
|
}
|
|
|
|
// Test GetCertificate - not found
|
|
func TestGetCertificate_NotFound(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GetCertificateFn: func(_ context.Context, id string) (*domain.ManagedCertificate, error) {
|
|
return nil, ErrMockNotFound
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/nonexistent", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCertificate(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test GetCertificate - empty ID
|
|
func TestGetCertificate_EmptyID(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCertificate(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test CreateCertificate - success case
|
|
func TestCreateCertificate_Success(t *testing.T) {
|
|
now := time.Now()
|
|
created := &domain.ManagedCertificate{
|
|
ID: "mc-prod-001",
|
|
Name: "Production Cert",
|
|
CommonName: "example.com",
|
|
Status: domain.CertificateStatusPending,
|
|
Environment: "prod",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
mock := &MockCertificateService{
|
|
CreateCertificateFn: func(_ context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
|
return created, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
certBody := domain.ManagedCertificate{
|
|
Name: "Production Cert",
|
|
CommonName: "example.com",
|
|
OwnerID: "o-alice",
|
|
TeamID: "t-platform",
|
|
IssuerID: "iss-local",
|
|
RenewalPolicyID: "rp-standard",
|
|
}
|
|
body, _ := json.Marshal(certBody)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", bytes.NewReader(body))
|
|
req = req.WithContext(contextWithRequestID())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.CreateCertificate(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status %d, got %d", http.StatusCreated, w.Code)
|
|
}
|
|
|
|
var response domain.ManagedCertificate
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if response.ID != "mc-prod-001" {
|
|
t.Errorf("expected ID mc-prod-001, got %s", response.ID)
|
|
}
|
|
}
|
|
|
|
// Test CreateCertificate - invalid request body
|
|
func TestCreateCertificate_InvalidBody(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", bytes.NewReader([]byte("invalid json")))
|
|
req = req.WithContext(contextWithRequestID())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.CreateCertificate(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test CreateCertificate - service error
|
|
func TestCreateCertificate_ServiceError(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
CreateCertificateFn: func(_ context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
|
return nil, ErrMockServiceFailed
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
certBody := domain.ManagedCertificate{
|
|
Name: "Production Cert",
|
|
CommonName: "example.com",
|
|
OwnerID: "o-alice",
|
|
TeamID: "t-platform",
|
|
IssuerID: "iss-local",
|
|
RenewalPolicyID: "rp-standard",
|
|
}
|
|
body, _ := json.Marshal(certBody)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", bytes.NewReader(body))
|
|
req = req.WithContext(contextWithRequestID())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.CreateCertificate(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
|
}
|
|
}
|
|
|
|
// TestCreateCertificate_MissingRequiredField_Returns400 pins the C-001 handler
|
|
// contract: handler MUST reject a create payload that omits any of the five
|
|
// required fields (name, common_name, owner_id, team_id, issuer_id,
|
|
// renewal_policy_id) with HTTP 400 before the service is invoked. The mock
|
|
// service here would succeed if called; every subtest proving 400 therefore
|
|
// proves the handler guard fires.
|
|
func TestCreateCertificate_MissingRequiredField_Returns400(t *testing.T) {
|
|
baseBody := map[string]interface{}{
|
|
"name": "API Prod",
|
|
"common_name": "api.example.com",
|
|
"owner_id": "o-alice",
|
|
"team_id": "t-platform",
|
|
"issuer_id": "iss-local",
|
|
"renewal_policy_id": "rp-standard",
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
missingField string
|
|
}{
|
|
{"missing name", "name"},
|
|
{"missing common_name", "common_name"},
|
|
{"missing owner_id", "owner_id"},
|
|
{"missing team_id", "team_id"},
|
|
{"missing issuer_id", "issuer_id"},
|
|
{"missing renewal_policy_id", "renewal_policy_id"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body := make(map[string]interface{}, len(baseBody))
|
|
for k, v := range baseBody {
|
|
body[k] = v
|
|
}
|
|
delete(body, tc.missingField)
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
mock := &MockCertificateService{
|
|
CreateCertificateFn: func(_ context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
|
// Would succeed if handler guard did not fire.
|
|
cert.ID = "mc-would-be-created"
|
|
return &cert, nil
|
|
},
|
|
}
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", bytes.NewReader(bodyBytes))
|
|
req = req.WithContext(contextWithRequestID())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.CreateCertificate(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("%s: expected 400, got %d — body=%s", tc.name, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test UpdateCertificate - success case
|
|
func TestUpdateCertificate_Success(t *testing.T) {
|
|
updated := &domain.ManagedCertificate{
|
|
ID: "mc-prod-001",
|
|
Name: "Updated Cert",
|
|
CommonName: "example.com",
|
|
Status: domain.CertificateStatusActive,
|
|
Environment: "prod",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
mock := &MockCertificateService{
|
|
UpdateCertificateFn: func(_ context.Context, id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
|
if id == "mc-prod-001" {
|
|
return updated, nil
|
|
}
|
|
return nil, ErrMockNotFound
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
certBody := domain.ManagedCertificate{
|
|
Name: "Updated Cert",
|
|
}
|
|
body, _ := json.Marshal(certBody)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/certificates/mc-prod-001", bytes.NewReader(body))
|
|
req = req.WithContext(contextWithRequestID())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.UpdateCertificate(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
|
|
var response domain.ManagedCertificate
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if response.Name != "Updated Cert" {
|
|
t.Errorf("expected name 'Updated Cert', got %s", response.Name)
|
|
}
|
|
}
|
|
|
|
// Test UpdateCertificate - invalid body
|
|
func TestUpdateCertificate_InvalidBody(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/certificates/mc-prod-001", bytes.NewReader([]byte("invalid")))
|
|
req = req.WithContext(contextWithRequestID())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.UpdateCertificate(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test ArchiveCertificate - success case
|
|
func TestArchiveCertificate_Success(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
ArchiveCertificateFn: func(_ context.Context, id string) error {
|
|
if id == "mc-prod-001" {
|
|
return nil
|
|
}
|
|
return ErrMockNotFound
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/mc-prod-001", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ArchiveCertificate(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected status %d, got %d", http.StatusNoContent, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test ArchiveCertificate - not found
|
|
func TestArchiveCertificate_NotFound(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
ArchiveCertificateFn: func(_ context.Context, id string) error {
|
|
return ErrMockNotFound
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/nonexistent", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ArchiveCertificate(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test GetCertificateVersions - success case
|
|
func TestGetCertificateVersions_Success(t *testing.T) {
|
|
ver1 := domain.CertificateVersion{
|
|
ID: "cv-001",
|
|
CertificateID: "mc-prod-001",
|
|
SerialNumber: "ABC123",
|
|
FingerprintSHA256: "abc123...",
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().AddDate(0, 0, 365),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
mock := &MockCertificateService{
|
|
GetCertificateVersionsFn: func(_ context.Context, certID string, page, perPage int) ([]domain.CertificateVersion, int64, error) {
|
|
if certID == "mc-prod-001" {
|
|
return []domain.CertificateVersion{ver1}, 1, nil
|
|
}
|
|
return nil, 0, ErrMockNotFound
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-prod-001/versions?page=1&per_page=50", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCertificateVersions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
|
|
var response PagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if response.Total != 1 {
|
|
t.Errorf("expected total 1, got %d", response.Total)
|
|
}
|
|
}
|
|
|
|
// Test GetCertificateVersions - not found
|
|
func TestGetCertificateVersions_NotFound(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GetCertificateVersionsFn: func(_ context.Context, certID string, page, perPage int) ([]domain.CertificateVersion, int64, error) {
|
|
return nil, 0, ErrMockNotFound
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/nonexistent/versions", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCertificateVersions(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test TriggerRenewal - success case
|
|
func TestTriggerRenewal_Success(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
TriggerRenewalFn: func(_ context.Context, certID string, _ string, _ bool) error {
|
|
if certID == "mc-prod-001" {
|
|
return nil
|
|
}
|
|
return ErrMockNotFound
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/renew", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.TriggerRenewal(w, req)
|
|
|
|
if w.Code != http.StatusAccepted {
|
|
t.Errorf("expected status %d, got %d", http.StatusAccepted, w.Code)
|
|
}
|
|
|
|
var response map[string]string
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if response["status"] != "renewal_triggered" {
|
|
t.Errorf("expected status 'renewal_triggered', got %s", response["status"])
|
|
}
|
|
}
|
|
|
|
// Test TriggerRenewal - service error
|
|
func TestTriggerRenewal_ServiceError(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
TriggerRenewalFn: func(_ context.Context, certID string, _ string, _ bool) error {
|
|
return ErrMockServiceFailed
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/renew", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.TriggerRenewal(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
|
}
|
|
}
|
|
|
|
// TestTriggerRenewal_ForceQueryParam pins the 2026-05-05 parity-defaults-cleanup
|
|
// (P3-1) wire: ?force=true on the renew URL flows through to the service-layer
|
|
// `force bool` parameter so operators can override the RenewalInProgress block.
|
|
func TestTriggerRenewal_ForceQueryParam(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
query string
|
|
wantForce bool
|
|
}{
|
|
{"no-flag", "", false},
|
|
{"force-true", "?force=true", true},
|
|
{"force-1", "?force=1", true},
|
|
{"force-false", "?force=false", false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var gotForce bool
|
|
mock := &MockCertificateService{
|
|
TriggerRenewalFn: func(_ context.Context, _ string, _ string, force bool) error {
|
|
gotForce = force
|
|
return nil
|
|
},
|
|
}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost,
|
|
"/api/v1/certificates/mc-prod-001/renew"+tc.query, nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
handler.TriggerRenewal(w, req)
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("status: got %d want %d", w.Code, http.StatusAccepted)
|
|
}
|
|
if gotForce != tc.wantForce {
|
|
t.Errorf("force passthrough: got %v want %v", gotForce, tc.wantForce)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test TriggerDeployment - success case
|
|
func TestTriggerDeployment_Success(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
TriggerDeploymentFn: func(_ context.Context, certID string, targetID string, _ string) error {
|
|
if certID == "mc-prod-001" {
|
|
return nil
|
|
}
|
|
return ErrMockNotFound
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
deployReq := map[string]string{"target_id": "t-nginx-001"}
|
|
body, _ := json.Marshal(deployReq)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/deploy", bytes.NewReader(body))
|
|
req = req.WithContext(contextWithRequestID())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.TriggerDeployment(w, req)
|
|
|
|
if w.Code != http.StatusAccepted {
|
|
t.Errorf("expected status %d, got %d", http.StatusAccepted, w.Code)
|
|
}
|
|
|
|
var response map[string]string
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if response["status"] != "deployment_triggered" {
|
|
t.Errorf("expected status 'deployment_triggered', got %s", response["status"])
|
|
}
|
|
}
|
|
|
|
// Test TriggerDeployment - without target ID
|
|
func TestTriggerDeployment_NoTargetID(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
TriggerDeploymentFn: func(_ context.Context, certID string, targetID string, _ string) error {
|
|
// Should accept empty targetID (deploy to all)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/deploy", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.TriggerDeployment(w, req)
|
|
|
|
if w.Code != http.StatusAccepted {
|
|
t.Errorf("expected status %d, got %d", http.StatusAccepted, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test ListCertificates - invalid page parameter
|
|
func TestListCertificates_InvalidPageParam(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
// Should default to page 1
|
|
if filter.Page == 1 {
|
|
return []domain.ManagedCertificate{}, 0, nil
|
|
}
|
|
return nil, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?page=invalid&per_page=50", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
}
|
|
|
|
// Test ListCertificates - per_page exceeds max
|
|
func TestListCertificates_PerPageExceedsMax(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
// Should cap perPage at 500
|
|
if filter.PerPage == 50 { // defaults to 50 if > 500
|
|
return []domain.ManagedCertificate{}, 0, nil
|
|
}
|
|
return nil, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?per_page=1000", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
}
|
|
|
|
// === Revocation Handler Tests ===
|
|
|
|
func TestRevokeCertificate_Handler_Success(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error {
|
|
if certID != "mc-prod-001" {
|
|
t.Errorf("expected certID mc-prod-001, got %s", certID)
|
|
}
|
|
if reason != "keyCompromise" {
|
|
t.Errorf("expected reason keyCompromise, got %s", reason)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
body := `{"reason":"keyCompromise"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/revoke", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeCertificate(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
|
|
var resp map[string]string
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp["status"] != "revoked" {
|
|
t.Errorf("expected status 'revoked', got %s", resp["status"])
|
|
}
|
|
}
|
|
|
|
func TestRevokeCertificate_Handler_NoBody(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error {
|
|
// Empty reason is OK — service defaults to "unspecified"
|
|
return nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/revoke", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeCertificate(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRevokeCertificate_Handler_AlreadyRevoked(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error {
|
|
return fmt.Errorf("certificate is already revoked")
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
body := `{"reason":"keyCompromise"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/revoke", bytes.NewBufferString(body))
|
|
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 status %d, got %d", http.StatusBadRequest, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRevokeCertificate_Handler_NotFound(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error {
|
|
return fmt.Errorf("failed to fetch certificate: not found: %w", ErrMockNotFound)
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/nonexistent/revoke", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeCertificate(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRevokeCertificate_Handler_InvalidReason(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error {
|
|
return fmt.Errorf("invalid revocation reason: badReason")
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
body := `{"reason":"badReason"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/revoke", bytes.NewBufferString(body))
|
|
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 status %d, got %d", http.StatusBadRequest, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRevokeCertificate_Handler_InvalidBody(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/revoke", bytes.NewBufferString("{invalid json"))
|
|
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 status %d, got %d", http.StatusBadRequest, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRevokeCertificate_Handler_MethodNotAllowed(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-prod-001/revoke", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeCertificate(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRevokeCertificate_Handler_EmptyID(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates//revoke", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeCertificate(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRevokeCertificate_Handler_CannotRevokeArchived(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error {
|
|
return fmt.Errorf("cannot revoke archived certificate")
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-archived/revoke", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeCertificate(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRevokeCertificate_Handler_ServerError(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error {
|
|
return fmt.Errorf("database connection lost")
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/revoke", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeCertificate(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
|
}
|
|
}
|
|
|
|
// === CRL and OCSP Handler Tests (RFC 5280 / RFC 6960, served under /.well-known/pki/) ===
|
|
//
|
|
// M-006 relocated these endpoints from /api/v1/crl* and /api/v1/ocsp/* to the
|
|
// RFC-compliant /.well-known/pki/ namespace and deleted the non-standard JSON
|
|
// CRL endpoint. The DER-encoded X.509 CRL (application/pkix-crl) and the
|
|
// DER-encoded OCSP response (application/ocsp-response) are the only wire
|
|
// formats certctl supports for revocation data.
|
|
|
|
func TestGetDERCRL_Success(t *testing.T) {
|
|
derCRLData := []byte{0x30, 0x82, 0x01, 0x00} // Mock DER CRL bytes
|
|
mock := &MockCertificateService{
|
|
GenerateDERCRLFn: func(_ context.Context, issuerID string) ([]byte, error) {
|
|
if issuerID == "iss-local" {
|
|
return derCRLData, nil
|
|
}
|
|
return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/crl/iss-local", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetDERCRL(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
|
|
// Verify response is DER data
|
|
responseBody := w.Body.Bytes()
|
|
if len(responseBody) == 0 {
|
|
t.Error("expected non-empty response body")
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/pkix-crl" {
|
|
t.Errorf("expected Content-Type application/pkix-crl, got %q", ct)
|
|
}
|
|
}
|
|
|
|
func TestGetDERCRL_IssuerNotFound(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GenerateDERCRLFn: func(_ context.Context, issuerID string) ([]byte, error) {
|
|
return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/crl/nonexistent", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetDERCRL(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetDERCRL_NotSupported(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GenerateDERCRLFn: func(_ context.Context, issuerID string) ([]byte, error) {
|
|
return nil, fmt.Errorf("issuer does not support CRL generation")
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/crl/iss-acme", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetDERCRL(w, req)
|
|
|
|
// Service should return an error; handler routes to appropriate status
|
|
if w.Code == http.StatusOK {
|
|
t.Errorf("expected error status, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetDERCRL_MethodNotAllowed(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/crl/iss-local", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetDERCRL(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleOCSP_Success(t *testing.T) {
|
|
ocspResponseBytes := []byte{0x30, 0x82, 0x02, 0x00} // Mock OCSP response
|
|
mock := &MockCertificateService{
|
|
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
|
|
if issuerID == "iss-local" && serialHex == "12345" {
|
|
return ocspResponseBytes, nil
|
|
}
|
|
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/ocsp/iss-local/12345", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.HandleOCSP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
|
|
responseBody := w.Body.Bytes()
|
|
if len(responseBody) == 0 {
|
|
t.Error("expected non-empty OCSP response body")
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/ocsp-response" {
|
|
t.Errorf("expected Content-Type application/ocsp-response, got %q", ct)
|
|
}
|
|
}
|
|
|
|
func TestHandleOCSP_MissingSerial(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/ocsp/iss-local/", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.HandleOCSP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleOCSP_IssuerNotFound(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
|
|
return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/ocsp/nonexistent/ABC123", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.HandleOCSP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleOCSP_CertNotFound(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
|
|
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/ocsp/iss-local/UNKNOWN", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.HandleOCSP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleOCSP_MethodNotAllowed(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local/12345", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.HandleOCSP(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
|
}
|
|
}
|
|
|
|
// === Phase-4 POST OCSP (RFC 6960 §A.1.1) Tests ===
|
|
|
|
// buildOCSPRequest constructs a binary DER-encoded OCSPRequest body
|
|
// for testing the POST handler. The same shape is what production
|
|
// clients (Firefox, OpenSSL, cert-manager) send.
|
|
func buildOCSPRequest(t *testing.T, serial *big.Int) []byte {
|
|
t.Helper()
|
|
// Build a minimal issuer cert + leaf cert pair so ocsp.CreateRequest
|
|
// has the SubjectPublicKeyInfo + serial it needs.
|
|
caKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
caTpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(0xCA),
|
|
Subject: pkix.Name{CommonName: "Test Issuer"},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
IsCA: true,
|
|
BasicConstraintsValid: true,
|
|
}
|
|
caDER, err := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
|
|
if err != nil {
|
|
t.Fatalf("create CA: %v", err)
|
|
}
|
|
caCert, _ := x509.ParseCertificate(caDER)
|
|
|
|
leafTpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: "leaf.example.com"},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
}
|
|
leafKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
leafDER, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, caKey)
|
|
if err != nil {
|
|
t.Fatalf("create leaf: %v", err)
|
|
}
|
|
leafCert, _ := x509.ParseCertificate(leafDER)
|
|
|
|
body, err := ocsp.CreateRequest(leafCert, caCert, &ocsp.RequestOptions{Hash: crypto.SHA256})
|
|
if err != nil {
|
|
t.Fatalf("create OCSP request: %v", err)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func TestHandleOCSPPost_Success(t *testing.T) {
|
|
wantSerial := big.NewInt(0xDEADBEEF)
|
|
expectedHex := fmt.Sprintf("%x", wantSerial)
|
|
|
|
mock := &MockCertificateService{
|
|
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
|
|
if issuerID != "iss-local" {
|
|
return nil, fmt.Errorf("unexpected issuer %q", issuerID)
|
|
}
|
|
if serialHex != expectedHex {
|
|
return nil, fmt.Errorf("unexpected serial %q (want %q)", serialHex, expectedHex)
|
|
}
|
|
return []byte{0x30, 0x82, 0x02, 0x00}, nil
|
|
},
|
|
}
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
body := buildOCSPRequest(t, wantSerial)
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/ocsp-request")
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.HandleOCSPPost(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/ocsp-response" {
|
|
t.Errorf("Content-Type = %q, want application/ocsp-response", ct)
|
|
}
|
|
}
|
|
|
|
func TestHandleOCSPPost_RejectsNonPostMethod(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/ocsp/iss-local", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
handler.HandleOCSPPost(w, req)
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("got %d, want 405", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleOCSPPost_RejectsWrongContentType(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader([]byte("garbage")))
|
|
req.Header.Set("Content-Type", "text/plain")
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
handler.HandleOCSPPost(w, req)
|
|
if w.Code != http.StatusUnsupportedMediaType {
|
|
t.Errorf("got %d, want 415", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleOCSPPost_AcceptsMissingContentType(t *testing.T) {
|
|
// Real-world tolerance: some clients omit the header entirely.
|
|
// Validation falls through to ocsp.ParseRequest which will reject
|
|
// a non-OCSP body with a 400.
|
|
body := buildOCSPRequest(t, big.NewInt(1))
|
|
mock := &MockCertificateService{
|
|
GetOCSPResponseFn: func(_ context.Context, _, _ string) ([]byte, error) {
|
|
return []byte{0x30, 0x82}, nil
|
|
},
|
|
}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader(body))
|
|
// Intentionally NOT setting Content-Type.
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
handler.HandleOCSPPost(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("got %d, want 200 with missing Content-Type (body=%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleOCSPPost_RejectsMalformedBody(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader([]byte("not-an-ocsp-request")))
|
|
req.Header.Set("Content-Type", "application/ocsp-request")
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
handler.HandleOCSPPost(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("got %d, want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleOCSPPost_RejectsMissingIssuer(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
body := buildOCSPRequest(t, big.NewInt(1))
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/ocsp-request")
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
handler.HandleOCSPPost(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("got %d, want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleOCSPPost_PropagatesNotFound(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GetOCSPResponseFn: func(_ context.Context, _, _ string) ([]byte, error) {
|
|
return nil, fmt.Errorf("certificate not found")
|
|
},
|
|
}
|
|
handler := NewCertificateHandler(mock)
|
|
body := buildOCSPRequest(t, big.NewInt(1))
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/ocsp-request")
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
handler.HandleOCSPPost(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("got %d, want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
// === M20 Enhanced Query API Tests ===
|
|
|
|
// TestListCertificates_SortParam tests sort parameter parsing and passing to service.
|
|
func TestListCertificates_SortParam(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
// Handler strips the '-' prefix and sets SortDesc = true
|
|
if filter.Sort != "notAfter" || !filter.SortDesc {
|
|
t.Errorf("expected sort=notAfter desc=true, got sort=%s desc=%v", filter.Sort, filter.SortDesc)
|
|
}
|
|
return []domain.ManagedCertificate{}, 0, nil
|
|
},
|
|
}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?sort=-notAfter", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestListCertificates_SortParam_Ascending tests sort parameter without '-' prefix (ascending).
|
|
func TestListCertificates_SortParam_Ascending(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
if filter.Sort != "createdAt" || filter.SortDesc {
|
|
t.Errorf("expected sort=createdAt desc=false, got sort=%s desc=%v", filter.Sort, filter.SortDesc)
|
|
}
|
|
return []domain.ManagedCertificate{}, 0, nil
|
|
},
|
|
}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?sort=createdAt", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestListCertificates_TimeRangeFilters tests time-range filter parsing.
|
|
func TestListCertificates_TimeRangeFilters(t *testing.T) {
|
|
before := time.Now().AddDate(0, 0, 90)
|
|
after := time.Now().AddDate(0, 0, -90)
|
|
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
if filter.ExpiresBefore == nil {
|
|
t.Error("expected ExpiresBefore to be set")
|
|
}
|
|
if filter.ExpiresAfter == nil {
|
|
t.Error("expected ExpiresAfter to be set")
|
|
}
|
|
return []domain.ManagedCertificate{}, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
url := fmt.Sprintf("/api/v1/certificates?expires_before=%s&expires_after=%s",
|
|
before.Format(time.RFC3339), after.Format(time.RFC3339))
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestListCertificates_CreatedAfterFilter tests created_after filter parsing.
|
|
func TestListCertificates_CreatedAfterFilter(t *testing.T) {
|
|
past := time.Now().AddDate(-1, 0, 0)
|
|
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
if filter.CreatedAfter == nil {
|
|
t.Error("expected CreatedAfter to be set")
|
|
}
|
|
return []domain.ManagedCertificate{}, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
url := fmt.Sprintf("/api/v1/certificates?created_after=%s", past.Format(time.RFC3339))
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestListCertificates_CursorPagination tests cursor-based pagination response.
|
|
func TestListCertificates_CursorPagination(t *testing.T) {
|
|
cert := domain.ManagedCertificate{
|
|
ID: "mc-cursor-test-1",
|
|
CommonName: "cursor.example.com",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
return []domain.ManagedCertificate{cert}, 1, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?cursor=abc123&page_size=10", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var resp CursorPagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.NextCursor == "" {
|
|
t.Error("expected NextCursor to be populated with cursor pagination")
|
|
}
|
|
if resp.PageSize != 10 {
|
|
t.Errorf("expected PageSize=10, got %d", resp.PageSize)
|
|
}
|
|
}
|
|
|
|
// TestListCertificates_SparseFields tests field filtering in response.
|
|
func TestListCertificates_SparseFields(t *testing.T) {
|
|
cert := domain.ManagedCertificate{
|
|
ID: "mc-sparse-test-1",
|
|
Name: "Sparse Test Cert",
|
|
CommonName: "sparse.example.com",
|
|
Environment: "staging",
|
|
Status: domain.CertificateStatusActive,
|
|
}
|
|
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
if len(filter.Fields) != 2 {
|
|
t.Errorf("expected 2 fields, got %d", len(filter.Fields))
|
|
}
|
|
return []domain.ManagedCertificate{cert}, 1, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?fields=id,common_name", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var resp PagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
// Response data should have sparse fields applied
|
|
data, ok := resp.Data.([]interface{})
|
|
if !ok || len(data) == 0 {
|
|
t.Fatal("expected data array in response")
|
|
}
|
|
|
|
certMap, ok := data[0].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("expected cert object in response")
|
|
}
|
|
|
|
// Check that requested fields are present
|
|
if _, ok := certMap["id"]; !ok {
|
|
t.Error("expected 'id' field in filtered response")
|
|
}
|
|
if _, ok := certMap["common_name"]; !ok {
|
|
t.Error("expected 'common_name' field in filtered response")
|
|
}
|
|
}
|
|
|
|
// TestListCertificates_ProfileFilter tests profile_id filter.
|
|
func TestListCertificates_ProfileFilter(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
if filter.ProfileID != "prof-standard" {
|
|
t.Errorf("expected ProfileID=prof-standard, got %s", filter.ProfileID)
|
|
}
|
|
return []domain.ManagedCertificate{}, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?profile_id=prof-standard", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestListCertificates_AgentIDFilter tests agent_id filter.
|
|
func TestListCertificates_AgentIDFilter(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
if filter.AgentID != "agent-prod-001" {
|
|
t.Errorf("expected AgentID=agent-prod-001, got %s", filter.AgentID)
|
|
}
|
|
return []domain.ManagedCertificate{}, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?agent_id=agent-prod-001", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestListCertificates_CombinedFilters tests multiple filters together.
|
|
func TestListCertificates_CombinedFilters(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
ListCertificatesWithFilterFn: func(_ context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
|
if filter.Status != "Active" || filter.Environment != "production" || filter.ProfileID != "prof-standard" {
|
|
t.Error("expected all filters to be set")
|
|
}
|
|
return []domain.ManagedCertificate{}, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?status=Active&environment=production&profile_id=prof-standard", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListCertificates(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestGetCertificateDeployments_Success tests retrieving deployments for a certificate.
|
|
func TestGetCertificateDeployments_Success(t *testing.T) {
|
|
deployments := []domain.DeploymentTarget{
|
|
{
|
|
ID: "t-nginx-prod-1",
|
|
Name: "NGINX Production",
|
|
Type: "NGINX",
|
|
Config: json.RawMessage(`{"cert_path": "/etc/nginx/ssl/cert.pem"}`),
|
|
},
|
|
{
|
|
ID: "t-haproxy-prod-1",
|
|
Name: "HAProxy Production",
|
|
Type: "HAProxy",
|
|
Config: json.RawMessage(`{"pem_path": "/etc/haproxy/ssl/cert.pem"}`),
|
|
},
|
|
}
|
|
|
|
mock := &MockCertificateService{
|
|
GetCertificateDeploymentsFn: func(_ context.Context, certID string) ([]domain.DeploymentTarget, error) {
|
|
if certID != "mc-prod-001" {
|
|
return nil, ErrMockNotFound
|
|
}
|
|
return deployments, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-prod-001/deployments", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCertificateDeployments(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if data, ok := resp["data"].([]interface{}); !ok || len(data) != 2 {
|
|
t.Errorf("expected 2 deployments in response")
|
|
}
|
|
|
|
if total, ok := resp["total"].(float64); !ok || int(total) != 2 {
|
|
t.Errorf("expected total=2, got %v", resp["total"])
|
|
}
|
|
}
|
|
|
|
// TestGetCertificateDeployments_NotFound tests 404 for nonexistent certificate.
|
|
func TestGetCertificateDeployments_NotFound(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GetCertificateDeploymentsFn: func(_ context.Context, certID string) ([]domain.DeploymentTarget, error) {
|
|
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-nonexistent/deployments", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCertificateDeployments(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestGetCertificateDeployments_Empty tests successful response with no deployments.
|
|
func TestGetCertificateDeployments_Empty(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GetCertificateDeploymentsFn: func(_ context.Context, certID string) ([]domain.DeploymentTarget, error) {
|
|
if certID == "mc-no-deployments" {
|
|
return []domain.DeploymentTarget{}, nil
|
|
}
|
|
return nil, ErrMockNotFound
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-no-deployments/deployments", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCertificateDeployments(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if total, ok := resp["total"].(float64); !ok || int(total) != 0 {
|
|
t.Errorf("expected total=0, got %v", resp["total"])
|
|
}
|
|
}
|
|
|
|
// TestGetCertificateDeployments_MethodNotAllowed tests 405 for non-GET requests.
|
|
func TestGetCertificateDeployments_MethodNotAllowed(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/deployments", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCertificateDeployments(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected 405, got %d", w.Code)
|
|
}
|
|
}
|