mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:11:30 +00:00
5cd9e890f4
Implements core revocation infrastructure: POST /api/v1/certificates/{id}/revoke
with all 8 RFC 5280 reason codes, JSON-formatted CRL at GET /api/v1/crl, webhook
and email revocation notifications, best-effort issuer notification, and immutable
revocation audit trail. Includes 48 new tests across service, handler, integration,
and domain layers (600+ total). Fixes 3 pre-existing test bugs (team_test error
matching, agent_group delete status code, team handler per_page validation).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1045 lines
31 KiB
Go
1045 lines
31 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// MockCertificateService is a mock implementation of CertificateService interface.
|
|
type MockCertificateService struct {
|
|
ListCertificatesFn func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error)
|
|
GetCertificateFn func(id string) (*domain.ManagedCertificate, error)
|
|
CreateCertificateFn func(cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
|
|
UpdateCertificateFn func(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
|
|
ArchiveCertificateFn func(id string) error
|
|
GetCertificateVersionsFn func(certID string, page, perPage int) ([]domain.CertificateVersion, int64, error)
|
|
TriggerRenewalFn func(certID string) error
|
|
TriggerDeploymentFn func(certID string, targetID string) error
|
|
RevokeCertificateFn func(certID string, reason string) error
|
|
GetRevokedCertificatesFn func() ([]*domain.CertificateRevocation, error)
|
|
}
|
|
|
|
func (m *MockCertificateService) ListCertificates(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
|
|
if m.ListCertificatesFn != nil {
|
|
return m.ListCertificatesFn(status, environment, ownerID, teamID, issuerID, page, perPage)
|
|
}
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) GetCertificate(id string) (*domain.ManagedCertificate, error) {
|
|
if m.GetCertificateFn != nil {
|
|
return m.GetCertificateFn(id)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) CreateCertificate(cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
|
if m.CreateCertificateFn != nil {
|
|
return m.CreateCertificateFn(cert)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) UpdateCertificate(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
|
if m.UpdateCertificateFn != nil {
|
|
return m.UpdateCertificateFn(id, cert)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) ArchiveCertificate(id string) error {
|
|
if m.ArchiveCertificateFn != nil {
|
|
return m.ArchiveCertificateFn(id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockCertificateService) GetCertificateVersions(certID string, page, perPage int) ([]domain.CertificateVersion, int64, error) {
|
|
if m.GetCertificateVersionsFn != nil {
|
|
return m.GetCertificateVersionsFn(certID, page, perPage)
|
|
}
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MockCertificateService) TriggerRenewal(certID string) error {
|
|
if m.TriggerRenewalFn != nil {
|
|
return m.TriggerRenewalFn(certID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockCertificateService) TriggerDeployment(certID string, targetID string) error {
|
|
if m.TriggerDeploymentFn != nil {
|
|
return m.TriggerDeploymentFn(certID, targetID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockCertificateService) RevokeCertificate(certID string, reason string) error {
|
|
if m.RevokeCertificateFn != nil {
|
|
return m.RevokeCertificateFn(certID, reason)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockCertificateService) GetRevokedCertificates() ([]*domain.CertificateRevocation, error) {
|
|
if m.GetRevokedCertificatesFn != nil {
|
|
return m.GetRevokedCertificatesFn()
|
|
}
|
|
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{
|
|
ListCertificatesFn: func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
|
|
if page == 1 && 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{
|
|
ListCertificatesFn: func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
|
|
if status == "Active" && 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{
|
|
ListCertificatesFn: func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, 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(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(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(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",
|
|
}
|
|
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(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",
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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(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(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(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.StatusInternalServerError {
|
|
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, 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(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(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(certID 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(certID 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(certID string, targetID 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(certID string, targetID 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{
|
|
ListCertificatesFn: func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
|
|
// Should default to page 1
|
|
if 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{
|
|
ListCertificatesFn: func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
|
|
// Should cap perPage at 500
|
|
if 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(certID string, reason 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(certID string, reason 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(certID string, reason 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(certID string, reason string) error {
|
|
return fmt.Errorf("failed to fetch certificate: not found")
|
|
},
|
|
}
|
|
|
|
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(certID string, reason 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(certID string, reason 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(certID string, reason 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 Handler Tests ===
|
|
|
|
func TestGetCRL_Success(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GetRevokedCertificatesFn: func() ([]*domain.CertificateRevocation, error) {
|
|
return []*domain.CertificateRevocation{
|
|
{
|
|
ID: "rev-1",
|
|
CertificateID: "cert-1",
|
|
SerialNumber: "ABC123",
|
|
Reason: "keyCompromise",
|
|
RevokedAt: time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC),
|
|
},
|
|
{
|
|
ID: "rev-2",
|
|
CertificateID: "cert-2",
|
|
SerialNumber: "DEF456",
|
|
Reason: "superseded",
|
|
RevokedAt: time.Date(2026, 3, 21, 14, 30, 0, 0, time.UTC),
|
|
},
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/crl", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCRL(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
|
|
if resp["version"] != float64(1) {
|
|
t.Errorf("expected version 1, got %v", resp["version"])
|
|
}
|
|
if resp["total"] != float64(2) {
|
|
t.Errorf("expected total 2, got %v", resp["total"])
|
|
}
|
|
|
|
entries, ok := resp["entries"].([]interface{})
|
|
if !ok {
|
|
t.Fatal("expected entries to be an array")
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Errorf("expected 2 entries, got %d", len(entries))
|
|
}
|
|
|
|
entry1 := entries[0].(map[string]interface{})
|
|
if entry1["serial_number"] != "ABC123" {
|
|
t.Errorf("expected serial ABC123, got %v", entry1["serial_number"])
|
|
}
|
|
if entry1["revocation_reason"] != "keyCompromise" {
|
|
t.Errorf("expected reason keyCompromise, got %v", entry1["revocation_reason"])
|
|
}
|
|
}
|
|
|
|
func TestGetCRL_Empty(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GetRevokedCertificatesFn: func() ([]*domain.CertificateRevocation, error) {
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/crl", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCRL(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp["total"] != float64(0) {
|
|
t.Errorf("expected total 0, got %v", resp["total"])
|
|
}
|
|
}
|
|
|
|
func TestGetCRL_ServiceError(t *testing.T) {
|
|
mock := &MockCertificateService{
|
|
GetRevokedCertificatesFn: func() ([]*domain.CertificateRevocation, error) {
|
|
return nil, fmt.Errorf("revocation repository not configured")
|
|
},
|
|
}
|
|
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/crl", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCRL(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetCRL_MethodNotAllowed(t *testing.T) {
|
|
mock := &MockCertificateService{}
|
|
handler := NewCertificateHandler(mock)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/crl", nil)
|
|
req = req.WithContext(contextWithRequestID())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetCRL(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
|
}
|
|
}
|