mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 13:38:56 +00:00
feat(M48): continuous TLS health monitoring — endpoint state machine, shared tlsprobe, 8 API endpoints, GUI
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>
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user