package web import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model" ) func TestHandlerServesOverviewAndManualActions(t *testing.T) { t.Parallel() app := &stubApp{} handler := NewHandler(app) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/recent-ips?hours=24&limit=10", nil) handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("unexpected recent ip status: %d body=%s", recorder.Code, recorder.Body.String()) } var recentIPs []model.RecentIPRow if err := json.Unmarshal(recorder.Body.Bytes(), &recentIPs); err != nil { t.Fatalf("decode recent ips payload: %v", err) } if len(recentIPs) != 1 || recentIPs[0].IP != "203.0.113.10" { t.Fatalf("unexpected recent ips payload: %+v", recentIPs) } recorder = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/overview?limit=10", nil) handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("unexpected overview status: %d", recorder.Code) } var overview model.Overview if err := json.Unmarshal(recorder.Body.Bytes(), &overview); err != nil { t.Fatalf("decode overview payload: %v", err) } if overview.TotalEvents != 1 || len(overview.RecentIPs) != 1 { t.Fatalf("unexpected overview payload: %+v", overview) } recorder = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPost, "/api/ips/203.0.113.10/block", strings.NewReader(`{"reason":"test reason","actor":"tester"}`)) request.Header.Set("Content-Type", "application/json") handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("unexpected block status: %d body=%s", recorder.Code, recorder.Body.String()) } if app.lastAction != "block:203.0.113.10:tester:test reason" { t.Fatalf("unexpected recorded action: %q", app.lastAction) } recorder = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPost, "/api/ips/203.0.113.10/investigate", strings.NewReader(`{"actor":"tester"}`)) request.Header.Set("Content-Type", "application/json") handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("unexpected investigate status: %d body=%s", recorder.Code, recorder.Body.String()) } recorder = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/", nil) handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("unexpected overview page status: %d", recorder.Code) } if !strings.Contains(recorder.Body.String(), "Local-only review and enforcement console") { t.Fatalf("overview page did not render expected content") } if strings.Contains(recorder.Body.String(), "Recent events") { t.Fatalf("overview page should no longer render recent events block") } recorder = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/ips/203.0.113.10", nil) handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("unexpected ip details page status: %d", recorder.Code) } body := recorder.Body.String() if !strings.Contains(body, `data-ip="203.0.113.10"`) { t.Fatalf("ip details page did not expose expected data-ip attribute: %s", body) } if strings.Contains(body, `const ip = "\"203.0.113.10\"";`) { t.Fatalf("ip details page still renders a doubly quoted IP") } } type stubApp struct { lastAction string } func (s *stubApp) GetOverview(context.Context, int) (model.Overview, error) { now := time.Now().UTC() return model.Overview{ TotalEvents: 1, TotalIPs: 1, BlockedIPs: 1, RecentIPs: []model.IPState{{ IP: "203.0.113.10", State: model.IPStateBlocked, ManualOverride: model.ManualOverrideNone, TotalEvents: 1, LastSeenAt: now, }}, RecentEvents: []model.Event{{ ID: 1, SourceName: "main", ClientIP: "203.0.113.10", OccurredAt: now, Decision: model.DecisionActionBlock, CurrentState: model.IPStateBlocked, }}, }, nil } func (s *stubApp) ListEvents(ctx context.Context, limit int) ([]model.Event, error) { overview, _ := s.GetOverview(ctx, limit) return overview.RecentEvents, nil } func (s *stubApp) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) { overview, _ := s.GetOverview(ctx, limit) return overview.RecentIPs, nil } func (s *stubApp) ListRecentIPs(ctx context.Context, since time.Time, limit int) ([]model.RecentIPRow, error) { _ = ctx _ = since _ = limit now := time.Now().UTC() return []model.RecentIPRow{{ IP: "203.0.113.10", SourceName: "main", State: model.IPStateBlocked, Events: 3, LastSeenAt: now, Reason: "php_path", Actions: model.ActionAvailability{CanUnblock: true}, }}, nil } func (s *stubApp) GetIPDetails(context.Context, string) (model.IPDetails, error) { now := time.Now().UTC() return model.IPDetails{ State: model.IPState{ IP: "203.0.113.10", State: model.IPStateBlocked, ManualOverride: model.ManualOverrideNone, TotalEvents: 1, LastSeenAt: now, }, RecentEvents: []model.Event{{ID: 1, ClientIP: "203.0.113.10", OccurredAt: now, Decision: model.DecisionActionBlock}}, Decisions: []model.DecisionRecord{{ID: 1, IP: "203.0.113.10", Action: model.DecisionActionBlock, CreatedAt: now}}, BackendActions: []model.OPNsenseAction{{ID: 1, IP: "203.0.113.10", Action: "block", Result: "added", CreatedAt: now}}, OPNsense: model.OPNsenseStatus{Configured: true, Present: true, CheckedAt: now}, Actions: model.ActionAvailability{CanUnblock: true}, Investigation: &model.IPInvestigation{IP: "203.0.113.10", UpdatedAt: now}, }, nil } func (s *stubApp) InvestigateIP(context.Context, string) (model.IPDetails, error) { return s.GetIPDetails(context.Background(), "203.0.113.10") } func (s *stubApp) ForceBlock(_ context.Context, ip string, actor string, reason string) error { s.lastAction = "block:" + ip + ":" + actor + ":" + reason return nil } func (s *stubApp) ForceAllow(_ context.Context, ip string, actor string, reason string) error { s.lastAction = "allow:" + ip + ":" + actor + ":" + reason return nil } func (s *stubApp) ClearOverride(_ context.Context, ip string, actor string, reason string) error { s.lastAction = "reset:" + ip + ":" + actor + ":" + reason return nil }