mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:31:39 +00:00
3d15a3e5af
Production hardening II Phase 1.
The OCSP responder previously ignored the request's nonce extension
entirely, leaving relying parties vulnerable to replay attacks. RFC
6960 §4.4.1 defines the OPTIONAL id-pkix-ocsp-nonce extension (OID
1.3.6.1.5.5.7.48.1.2): when present in the request, the responder
MUST echo the same value in the response; when absent, no nonce in
the response (back-compat with relying parties that don't send one).
NEW internal/service/ocsp_nonce.go: ParseOCSPRequestNonce walks raw
DER (golang.org/x/crypto/ocsp.Request doesn't expose the request's
extensions field — the library only exposes IssuerNameHash +
IssuerKeyHash + SerialNumber). Returns one of three states:
- (nil, false, nil) — no nonce extension in request
- (nonce, true, nil) — well-formed nonce, ≤ MaxOCSPNonceLength (32)
- (nil, false, ErrOCSPNonceMalformed) — empty or oversized
NEW internal/service/ocsp_counters.go: sync/atomic counter table for
OCSP request lifecycle (request_get/post, request_success/invalid,
nonce_echoed, nonce_malformed, rate_limited, ...). Mirrors the EST/
SCEP counter pattern; Phase 8 wires these into /metrics/prometheus.
CertSrv types extended:
- internal/connector/issuer/interface.go::OCSPSignRequest gains
Nonce []byte field.
- internal/service/renewal.go::OCSPSignRequest (the service-layer
duplicate used by ca_operations.go) gains the same field.
- internal/service/issuer_adapter.go bridges the two.
Service path: CAOperationsSvc.GetOCSPResponseWithNonce(ctx, issuerID,
serialHex, nonce) is the new entry point that plumbs the nonce
through every signing site (good / revoked / unknown / short-lived).
The legacy GetOCSPResponse becomes a nil-nonce wrapper for back-
compat — every existing caller (tests, the GET handler) sees no
behavior change.
CertificateService gains the same WithNonce variant; the handler
interface adds it to the contract. MockCertificateService in tests
extended with the new method (delegates to the legacy fn when no
override is set, so existing tests that don't care about the nonce
keep working).
Local issuer's SignOCSPResponse appends the id-pkix-ocsp-nonce
extension (non-Critical per RFC 6960 §4.4) to the response template's
ExtraExtensions when req.Nonce != nil. The extnValue is the nonce
bytes wrapped in an OCTET STRING per RFC 6960 §4.4.1.
POST OCSP handler (HandleOCSPPost):
- After ocsp.ParseRequest succeeds, calls ParseOCSPRequestNonce on
the raw body to extract the optional nonce.
- On ErrOCSPNonceMalformed (empty or > 32 bytes): writes an
'unauthorized' OCSP response (status 6 per RFC 6960 §2.3) using
the canonical ocsp.UnauthorizedErrorResponse from x/crypto/ocsp.
Does NOT echo malicious bytes back.
- On well-formed nonce: passes it through GetOCSPResponseWithNonce.
- On no nonce: nil passed through; back-compat preserved.
GET OCSP handler unchanged — the GET form has no body to carry a
nonce extension.
6 new tests in internal/service/ocsp_nonce_test.go pin every
documented failure mode + the 32-byte boundary. The test fixture
builds an OCSPRequest via golang.org/x/crypto/ocsp.CreateRequest then
splices in a [2] EXPLICIT Extensions element by hand (the library
doesn't expose extension construction either).
Pre-commit verification: gofmt clean, go vet clean across affected
packages, go test -short -count=1 green for service/ + handler/ +
connector/issuer/local/. No new env vars introduced (Phase 1 is
always-on per RFC; no operator opt-out).
1787 lines
58 KiB
Go
1787 lines
58 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/shankar0123/certctl/internal/api/middleware"
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
"github.com/shankar0123/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) 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) error {
|
|
if m.TriggerRenewalFn != nil {
|
|
return m.TriggerRenewalFn(ctx, certID, actor)
|
|
}
|
|
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) 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) 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|