mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:11:30 +00:00
596d86a206
Adds continuous TLS endpoint health monitoring that closes the deploy→verify→monitor loop. After M25 verifies a deployment succeeded once, M48 continuously confirms it stays healthy. Key components: - Shared `internal/tlsprobe/` package extracted from network scanner for reuse - Health status state machine: healthy → degraded (2 failures) → down (5 failures), plus cert_mismatch when served fingerprint differs from expected - 8th scheduler loop (60s tick, per-endpoint configurable intervals) - PostgreSQL migration 000011: endpoint_health_checks + endpoint_health_history tables - 8 REST API endpoints (CRUD, history, acknowledge, summary) - Health Monitor GUI page with summary bar, status table, create modal, auto-refresh - 38 new tests (5 tlsprobe + 11 domain + 10 service + 8 handler + 4 frontend) - All coverage thresholds maintained (service 68%, handler 83%, domain 87%, middleware 63%) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
306 lines
7.6 KiB
Go
306 lines
7.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
"github.com/shankar0123/certctl/internal/repository"
|
|
)
|
|
|
|
// mockHealthCheckSvc implements HealthCheckServicer for testing.
|
|
type mockHealthCheckSvc struct {
|
|
createErr error
|
|
getErr error
|
|
updateErr error
|
|
deleteErr error
|
|
listErr error
|
|
getHistoryErr error
|
|
acknowledgeErr error
|
|
getSummaryErr error
|
|
checks map[string]*domain.EndpointHealthCheck
|
|
summary *domain.HealthCheckSummary
|
|
}
|
|
|
|
func newMockHealthCheckSvc() *mockHealthCheckSvc {
|
|
return &mockHealthCheckSvc{
|
|
checks: make(map[string]*domain.EndpointHealthCheck),
|
|
summary: &domain.HealthCheckSummary{
|
|
Healthy: 1,
|
|
Degraded: 0,
|
|
Down: 0,
|
|
CertMismatch: 0,
|
|
Unknown: 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (m *mockHealthCheckSvc) Create(ctx context.Context, check *domain.EndpointHealthCheck) error {
|
|
if m.createErr != nil {
|
|
return m.createErr
|
|
}
|
|
check.ID = "hc-created-1"
|
|
m.checks[check.ID] = check
|
|
return nil
|
|
}
|
|
|
|
func (m *mockHealthCheckSvc) Get(ctx context.Context, id string) (*domain.EndpointHealthCheck, error) {
|
|
if m.getErr != nil {
|
|
return nil, m.getErr
|
|
}
|
|
if check, ok := m.checks[id]; ok {
|
|
return check, nil
|
|
}
|
|
return nil, errors.New("not found")
|
|
}
|
|
|
|
func (m *mockHealthCheckSvc) Update(ctx context.Context, check *domain.EndpointHealthCheck) error {
|
|
if m.updateErr != nil {
|
|
return m.updateErr
|
|
}
|
|
m.checks[check.ID] = check
|
|
return nil
|
|
}
|
|
|
|
func (m *mockHealthCheckSvc) Delete(ctx context.Context, id string) error {
|
|
if m.deleteErr != nil {
|
|
return m.deleteErr
|
|
}
|
|
delete(m.checks, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockHealthCheckSvc) List(ctx context.Context, filter *repository.HealthCheckFilter) ([]*domain.EndpointHealthCheck, int, error) {
|
|
if m.listErr != nil {
|
|
return nil, 0, m.listErr
|
|
}
|
|
checks := make([]*domain.EndpointHealthCheck, 0, len(m.checks))
|
|
for _, check := range m.checks {
|
|
checks = append(checks, check)
|
|
}
|
|
return checks, len(checks), nil
|
|
}
|
|
|
|
func (m *mockHealthCheckSvc) GetHistory(ctx context.Context, healthCheckID string, limit int) ([]*domain.HealthHistoryEntry, error) {
|
|
if m.getHistoryErr != nil {
|
|
return nil, m.getHistoryErr
|
|
}
|
|
return make([]*domain.HealthHistoryEntry, 0), nil
|
|
}
|
|
|
|
func (m *mockHealthCheckSvc) AcknowledgeIncident(ctx context.Context, id string, actor string) error {
|
|
if m.acknowledgeErr != nil {
|
|
return m.acknowledgeErr
|
|
}
|
|
if check, ok := m.checks[id]; ok {
|
|
check.Acknowledged = true
|
|
check.AcknowledgedBy = actor
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockHealthCheckSvc) GetSummary(ctx context.Context) (*domain.HealthCheckSummary, error) {
|
|
if m.getSummaryErr != nil {
|
|
return nil, m.getSummaryErr
|
|
}
|
|
return m.summary, nil
|
|
}
|
|
|
|
// Tests
|
|
|
|
func TestListHealthChecks_Success(t *testing.T) {
|
|
svc := newMockHealthCheckSvc()
|
|
svc.checks["hc-1"] = &domain.EndpointHealthCheck{
|
|
ID: "hc-1",
|
|
Endpoint: "api.example.com:443",
|
|
Status: domain.HealthStatusHealthy,
|
|
}
|
|
handler := NewHealthCheckHandler(svc)
|
|
|
|
req := httptest.NewRequest("GET", "/api/v1/health-checks", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListHealthChecks(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 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)
|
|
}
|
|
|
|
if resp.Total != 1 {
|
|
t.Errorf("Expected 1 health check, got %d", resp.Total)
|
|
}
|
|
}
|
|
|
|
func TestListHealthChecks_MethodNotAllowed(t *testing.T) {
|
|
handler := NewHealthCheckHandler(newMockHealthCheckSvc())
|
|
|
|
req := httptest.NewRequest("POST", "/api/v1/health-checks", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ListHealthChecks(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("Expected status 405, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetHealthCheck_Success(t *testing.T) {
|
|
svc := newMockHealthCheckSvc()
|
|
check := &domain.EndpointHealthCheck{
|
|
ID: "hc-1",
|
|
Endpoint: "api.example.com:443",
|
|
Status: domain.HealthStatusHealthy,
|
|
}
|
|
svc.checks["hc-1"] = check
|
|
handler := NewHealthCheckHandler(svc)
|
|
|
|
req := httptest.NewRequest("GET", "/api/v1/health-checks/hc-1", nil)
|
|
req.SetPathValue("id", "hc-1")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetHealthCheck(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
var resp domain.EndpointHealthCheck
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.ID != "hc-1" {
|
|
t.Errorf("Expected ID hc-1, got %s", resp.ID)
|
|
}
|
|
}
|
|
|
|
func TestGetHealthCheck_NotFound(t *testing.T) {
|
|
handler := NewHealthCheckHandler(newMockHealthCheckSvc())
|
|
|
|
req := httptest.NewRequest("GET", "/api/v1/health-checks/nonexistent", nil)
|
|
req.SetPathValue("id", "nonexistent")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetHealthCheck(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("Expected status 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCreateHealthCheck_Success(t *testing.T) {
|
|
svc := newMockHealthCheckSvc()
|
|
handler := NewHealthCheckHandler(svc)
|
|
|
|
check := domain.EndpointHealthCheck{
|
|
Endpoint: "web.example.com:443",
|
|
Enabled: true,
|
|
}
|
|
body, _ := json.Marshal(check)
|
|
|
|
req := httptest.NewRequest("POST", "/api/v1/health-checks", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.CreateHealthCheck(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("Expected status 201, got %d", w.Code)
|
|
}
|
|
|
|
var resp domain.EndpointHealthCheck
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.Endpoint != "web.example.com:443" {
|
|
t.Errorf("Expected endpoint web.example.com:443, got %s", resp.Endpoint)
|
|
}
|
|
}
|
|
|
|
func TestDeleteHealthCheck_Success(t *testing.T) {
|
|
svc := newMockHealthCheckSvc()
|
|
svc.checks["hc-1"] = &domain.EndpointHealthCheck{
|
|
ID: "hc-1",
|
|
Endpoint: "api.example.com:443",
|
|
}
|
|
handler := NewHealthCheckHandler(svc)
|
|
|
|
req := httptest.NewRequest("DELETE", "/api/v1/health-checks/hc-1", nil)
|
|
req.SetPathValue("id", "hc-1")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.DeleteHealthCheck(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("Expected status 204, got %d", w.Code)
|
|
}
|
|
|
|
if _, ok := svc.checks["hc-1"]; ok {
|
|
t.Fatal("Expected check to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestAcknowledgeHealthCheck_Success(t *testing.T) {
|
|
svc := newMockHealthCheckSvc()
|
|
svc.checks["hc-1"] = &domain.EndpointHealthCheck{
|
|
ID: "hc-1",
|
|
Endpoint: "api.example.com:443",
|
|
Status: domain.HealthStatusDown,
|
|
}
|
|
handler := NewHealthCheckHandler(svc)
|
|
|
|
req := httptest.NewRequest("POST", "/api/v1/health-checks/hc-1/acknowledge", bytes.NewReader([]byte(`{"actor":"user@example.com"}`)))
|
|
req.SetPathValue("id", "hc-1")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.AcknowledgeHealthCheck(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("Expected status 204, got %d", w.Code)
|
|
}
|
|
|
|
if !svc.checks["hc-1"].Acknowledged {
|
|
t.Fatal("Expected check to be acknowledged")
|
|
}
|
|
}
|
|
|
|
func TestGetHealthCheckSummary_Success(t *testing.T) {
|
|
svc := newMockHealthCheckSvc()
|
|
svc.summary = &domain.HealthCheckSummary{
|
|
Healthy: 3,
|
|
Degraded: 1,
|
|
Down: 0,
|
|
CertMismatch: 0,
|
|
Unknown: 1,
|
|
}
|
|
handler := NewHealthCheckHandler(svc)
|
|
|
|
req := httptest.NewRequest("GET", "/api/v1/health-checks/summary", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.GetHealthCheckSummary(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
var resp domain.HealthCheckSummary
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.Healthy != 3 {
|
|
t.Errorf("Expected 3 healthy checks, got %d", resp.Healthy)
|
|
}
|
|
}
|