Files
certctl/internal/api/handler/stats_handler_test.go
T
shankar0123 03472072b8 test + docs: close 12 test gaps (~250 new tests) and expand testing guide to 34 parts
Implements all P0-P2 test gaps from docs/test-gap-prompt.md:
- Deployment service tests (20), target service tests (18), scheduler tests (8)
- Agent binary tests (48), CSR renewal tests (8), short-lived cert tests (7)
- Domain model tests (25), context cancellation tests (9), concurrency tests (7)
- Handler negative-path tests (23 across 5 files)
- Frontend error handling tests (86) and API client tests (7)

Expands testing-guide.md from 28 to 34 parts covering certificate export,
S/MIME/EKU, OCSP/DER CRL, body size limits, Apache/HAProxy connectors,
and sub-CA mode. Fixes stale profile count (4->5) and updates sign-off table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 17:57:25 -04:00

431 lines
13 KiB
Go

package handler
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// MockStatsService implements both StatsService and MetricsService.
type MockStatsService struct {
GetDashboardSummaryFn func(ctx context.Context) (interface{}, error)
GetCertificatesByStatusFn func(ctx context.Context) (interface{}, error)
GetExpirationTimelineFn func(ctx context.Context, days int) (interface{}, error)
GetJobStatsFn func(ctx context.Context, days int) (interface{}, error)
GetIssuanceRateFn func(ctx context.Context, days int) (interface{}, error)
}
func (m *MockStatsService) GetDashboardSummary(ctx context.Context) (interface{}, error) {
if m.GetDashboardSummaryFn != nil {
return m.GetDashboardSummaryFn(ctx)
}
return map[string]int64{"total_certificates": 0}, nil
}
func (m *MockStatsService) GetCertificatesByStatus(ctx context.Context) (interface{}, error) {
if m.GetCertificatesByStatusFn != nil {
return m.GetCertificatesByStatusFn(ctx)
}
return []interface{}{}, nil
}
func (m *MockStatsService) GetExpirationTimeline(ctx context.Context, days int) (interface{}, error) {
if m.GetExpirationTimelineFn != nil {
return m.GetExpirationTimelineFn(ctx, days)
}
return []interface{}{}, nil
}
func (m *MockStatsService) GetJobStats(ctx context.Context, days int) (interface{}, error) {
if m.GetJobStatsFn != nil {
return m.GetJobStatsFn(ctx, days)
}
return []interface{}{}, nil
}
func (m *MockStatsService) GetIssuanceRate(ctx context.Context, days int) (interface{}, error) {
if m.GetIssuanceRateFn != nil {
return m.GetIssuanceRateFn(ctx, days)
}
return []interface{}{}, nil
}
func TestGetDashboardSummary_Success(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/summary", nil)
w := httptest.NewRecorder()
h.GetDashboardSummary(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestGetDashboardSummary_MethodNotAllowed(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/summary", nil)
w := httptest.NewRecorder()
h.GetDashboardSummary(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestGetDashboardSummary_ServiceError(t *testing.T) {
mock := &MockStatsService{
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
return nil, fmt.Errorf("db error")
},
}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/summary", nil)
w := httptest.NewRecorder()
h.GetDashboardSummary(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
func TestGetCertificatesByStatus_Success(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/certificates-by-status", nil)
w := httptest.NewRecorder()
h.GetCertificatesByStatus(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestGetExpirationTimeline_Success(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/expiration-timeline?days=60", nil)
w := httptest.NewRecorder()
h.GetExpirationTimeline(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestGetExpirationTimeline_DefaultDays(t *testing.T) {
mock := &MockStatsService{
GetExpirationTimelineFn: func(ctx context.Context, days int) (interface{}, error) {
if days != 30 {
t.Errorf("expected default 30 days, got %d", days)
}
return []interface{}{}, nil
},
}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/expiration-timeline", nil)
w := httptest.NewRecorder()
h.GetExpirationTimeline(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestGetJobTrends_Success(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/job-trends?days=14", nil)
w := httptest.NewRecorder()
h.GetJobTrends(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestGetIssuanceRate_Success(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/issuance-rate?days=7", nil)
w := httptest.NewRecorder()
h.GetIssuanceRate(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestGetMetrics_Success(t *testing.T) {
mock := &MockStatsService{
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
return &DashboardSummary{
TotalCertificates: 10,
ExpiringCertificates: 2,
ExpiredCertificates: 1,
RevokedCertificates: 0,
ActiveAgents: 3,
TotalAgents: 5,
PendingJobs: 1,
FailedJobs: 0,
CompleteJobs: 8,
}, nil
},
}
h := NewMetricsHandler(mock, time.Now())
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
w := httptest.NewRecorder()
h.GetMetrics(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestGetMetrics_MethodNotAllowed(t *testing.T) {
mock := &MockStatsService{}
h := NewMetricsHandler(mock, time.Now())
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", nil)
w := httptest.NewRecorder()
h.GetMetrics(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestGetMetrics_ServiceError(t *testing.T) {
mock := &MockStatsService{
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
return nil, fmt.Errorf("db error")
},
}
h := NewMetricsHandler(mock, time.Now())
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
w := httptest.NewRecorder()
h.GetMetrics(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
// --- Prometheus metrics endpoint tests ---
func TestGetPrometheusMetrics_Success(t *testing.T) {
mock := &MockStatsService{
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
return &DashboardSummary{
TotalCertificates: 25,
ExpiringCertificates: 3,
ExpiredCertificates: 2,
RevokedCertificates: 1,
ActiveAgents: 4,
TotalAgents: 6,
PendingJobs: 2,
FailedJobs: 1,
CompleteJobs: 15,
}, nil
},
}
h := NewMetricsHandler(mock, time.Now().Add(-1*time.Hour))
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/prometheus", nil)
w := httptest.NewRecorder()
h.GetPrometheusMetrics(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
contentType := w.Header().Get("Content-Type")
if contentType != "text/plain; version=0.0.4; charset=utf-8" {
t.Errorf("expected Prometheus content type, got %q", contentType)
}
body := w.Body.String()
// Check metric lines are present
expected := []string{
"certctl_certificate_total 25",
"certctl_certificate_active 19",
"certctl_certificate_expiring_soon 3",
"certctl_certificate_expired 2",
"certctl_certificate_revoked 1",
"certctl_agent_total 6",
"certctl_agent_online 4",
"certctl_job_pending 2",
"certctl_job_completed_total 15",
"certctl_job_failed_total 1",
"# TYPE certctl_certificate_total gauge",
"# TYPE certctl_job_completed_total counter",
"# HELP certctl_uptime_seconds",
"# TYPE certctl_uptime_seconds gauge",
}
for _, exp := range expected {
if !containsLine(body, exp) {
t.Errorf("expected body to contain %q", exp)
}
}
}
func TestGetPrometheusMetrics_MethodNotAllowed(t *testing.T) {
mock := &MockStatsService{}
h := NewMetricsHandler(mock, time.Now())
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics/prometheus", nil)
w := httptest.NewRecorder()
h.GetPrometheusMetrics(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestGetPrometheusMetrics_ServiceError(t *testing.T) {
mock := &MockStatsService{
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
return nil, fmt.Errorf("db error")
},
}
h := NewMetricsHandler(mock, time.Now())
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/prometheus", nil)
w := httptest.NewRecorder()
h.GetPrometheusMetrics(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
func TestGetPrometheusMetrics_ZeroValues(t *testing.T) {
mock := &MockStatsService{
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
return &DashboardSummary{}, nil
},
}
h := NewMetricsHandler(mock, time.Now())
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/prometheus", nil)
w := httptest.NewRecorder()
h.GetPrometheusMetrics(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !containsLine(body, "certctl_certificate_total 0") {
t.Error("expected zero value for certificate_total")
}
if !containsLine(body, "certctl_job_pending 0") {
t.Error("expected zero value for job_pending")
}
}
// containsLine checks if the text contains the given substring.
func containsLine(text, substr string) bool {
return strings.Contains(text, substr)
}
// Test GetCertificatesByStatus - method not allowed
func TestGetCertificatesByStatus_MethodNotAllowed(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/certificates-by-status", nil)
w := httptest.NewRecorder()
h.GetCertificatesByStatus(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
// Test GetCertificatesByStatus - service error
func TestGetCertificatesByStatus_ServiceError(t *testing.T) {
mock := &MockStatsService{
GetCertificatesByStatusFn: func(ctx context.Context) (interface{}, error) {
return nil, fmt.Errorf("db error")
},
}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/certificates-by-status", nil)
w := httptest.NewRecorder()
h.GetCertificatesByStatus(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
// Test GetExpirationTimeline - method not allowed
func TestGetExpirationTimeline_MethodNotAllowed(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/expiration-timeline", nil)
w := httptest.NewRecorder()
h.GetExpirationTimeline(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
// Test GetExpirationTimeline - service error
func TestGetExpirationTimeline_ServiceError(t *testing.T) {
mock := &MockStatsService{
GetExpirationTimelineFn: func(ctx context.Context, days int) (interface{}, error) {
return nil, fmt.Errorf("db error")
},
}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/expiration-timeline?days=30", nil)
w := httptest.NewRecorder()
h.GetExpirationTimeline(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
// Test GetJobTrends - method not allowed
func TestGetJobTrends_MethodNotAllowed(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/job-trends", nil)
w := httptest.NewRecorder()
h.GetJobTrends(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
// Test GetJobTrends - service error
func TestGetJobTrends_ServiceError(t *testing.T) {
mock := &MockStatsService{
GetJobStatsFn: func(ctx context.Context, days int) (interface{}, error) {
return nil, fmt.Errorf("db error")
},
}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/job-trends?days=14", nil)
w := httptest.NewRecorder()
h.GetJobTrends(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
// Test GetIssuanceRate - method not allowed
func TestGetIssuanceRate_MethodNotAllowed(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/issuance-rate", nil)
w := httptest.NewRecorder()
h.GetIssuanceRate(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
// Test GetIssuanceRate - service error
func TestGetIssuanceRate_ServiceError(t *testing.T) {
mock := &MockStatsService{
GetIssuanceRateFn: func(ctx context.Context, days int) (interface{}, error) {
return nil, fmt.Errorf("db error")
},
}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/issuance-rate?days=7", nil)
w := httptest.NewRecorder()
h.GetIssuanceRate(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}