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?hours=24&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 || len(overview.TopIPsByEvents) != 1 { t.Fatalf("unexpected overview payload: %+v", overview) } recorder = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/overview?hours=24&limit=10&show_known_bots=false&show_allowed=false", nil) handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("unexpected filtered overview status: %d", recorder.Code) } if !app.lastOverviewOptions.ShowKnownBots || !app.lastOverviewOptions.ShowAllowed { t.Fatalf("overview should always use the unfiltered dashboard data: %+v", app.lastOverviewOptions) } recorder = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/events?hours=24&limit=250&page=2&source=main&method=GET&status=4xx&state=review&bot_filter=known&sort_by=status&sort_dir=asc", nil) handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("unexpected filtered events status: %d", recorder.Code) } var eventPage model.EventPage if err := json.Unmarshal(recorder.Body.Bytes(), &eventPage); err != nil { t.Fatalf("decode event page payload: %v", err) } if eventPage.Page != 2 || !eventPage.HasPrev { t.Fatalf("unexpected event page payload: %+v", eventPage) } if app.lastEventOptions.Offset != 250 || app.lastEventOptions.Source != "main" || app.lastEventOptions.Method != "GET" || app.lastEventOptions.StatusFilter != "4xx" || app.lastEventOptions.State != string(model.IPStateReview) || app.lastEventOptions.BotFilter != "known" || app.lastEventOptions.SortBy != "status" || app.lastEventOptions.SortDesc || app.lastEventOptions.ReviewOnly { t.Fatalf("event filter options were not forwarded correctly: %+v", app.lastEventOptions) } 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(), "Show known bots") { t.Fatalf("overview page should no longer expose the known bots toggle") } if !strings.Contains(recorder.Body.String(), "Requests Log") { t.Fatalf("overview page should link to the requests log") } if !strings.Contains(recorder.Body.String(), "Activity") { t.Fatalf("overview page should expose the activity chart") } if !strings.Contains(recorder.Body.String(), "Methods") { t.Fatalf("overview page should expose the methods chart") } if !strings.Contains(recorder.Body.String(), "Bots") { t.Fatalf("overview page should expose the bots chart") } if !strings.Contains(recorder.Body.String(), "Top bot IPs by events") { t.Fatalf("overview page should expose the top bot IPs by events block") } if !strings.Contains(recorder.Body.String(), "Top non-bot IPs by traffic") { t.Fatalf("overview page should expose the split top IP traffic block") } if !strings.Contains(recorder.Body.String(), "Top sources by events") { t.Fatalf("overview page should expose the top sources block") } if !strings.Contains(recorder.Body.String(), "Top URLs by events") { t.Fatalf("overview page should expose the top URLs block") } if !strings.Contains(recorder.Body.String(), "Loading…") { t.Fatalf("overview page should render stable loading placeholders") } if strings.Contains(recorder.Body.String(), "position: sticky") { t.Fatalf("overview page header should no longer be sticky") } if strings.Contains(recorder.Body.String(), "Review only") { t.Fatalf("overview page should not expose the review-only toggle anymore") } if strings.Contains(recorder.Body.String(), "Auto refresh") { t.Fatalf("overview page should not expose requests log controls") } recorder = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/queries", nil) handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusMovedPermanently { t.Fatalf("unexpected legacy query log redirect status: %d", recorder.Code) } if location := recorder.Header().Get("Location"); location != "/requests" { t.Fatalf("unexpected redirect location: %q", location) } recorder = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/requests", nil) handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("unexpected requests log page status: %d", recorder.Code) } queryLogBody := recorder.Body.String() if !strings.Contains(queryLogBody, "Filters, sorting, and pagination") { t.Fatalf("requests log page should expose the collapsible controls panel") } if !strings.Contains(queryLogBody, "Rows per page") { t.Fatalf("requests log page should expose pagination settings") } if !strings.Contains(queryLogBody, "Request") { t.Fatalf("requests log page should render the request table") } if !strings.Contains(queryLogBody, "Auto refresh") { t.Fatalf("requests log page should expose the auto refresh toggle") } if !strings.Contains(queryLogBody, "onclick=\"applySort('status')\"") { t.Fatalf("requests log page should expose clickable sortable columns") } if !strings.Contains(queryLogBody, "Source") || !strings.Contains(queryLogBody, "Bots") || !strings.Contains(queryLogBody, "HTTP status") { t.Fatalf("requests log page should expose source, bot, and status filters") } if !strings.Contains(queryLogBody, "Previous") || !strings.Contains(queryLogBody, "Next") { t.Fatalf("requests log page should expose pagination controls") } 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") } if strings.Contains(body, `refresh().then(() => investigate());`) { t.Fatalf("ip details page should not auto-refresh investigation on load") } if strings.Contains(body, "position: sticky") { t.Fatalf("ip details page header should no longer be sticky") } investigationIndex := strings.Index(body, "

Investigation

") decisionsIndex := strings.Index(body, "

Decisions

") requestsIndex := strings.Index(body, "

Requests from this IP

") if investigationIndex == -1 || decisionsIndex == -1 || requestsIndex == -1 { t.Fatalf("ip details page is missing expected sections") } if !(investigationIndex < decisionsIndex && decisionsIndex < requestsIndex) { t.Fatalf("expected Decisions block right after Investigation block") } } type stubApp struct { lastAction string lastOverviewOptions model.OverviewOptions lastEventOptions model.EventListOptions } func (s *stubApp) GetOverview(_ context.Context, _ time.Time, _ int, options model.OverviewOptions) (model.Overview, error) { s.lastOverviewOptions = options now := time.Now().UTC() return model.Overview{ TotalEvents: 1, TotalIPs: 1, BlockedIPs: 1, ActivityBuckets: []model.ActivityBucket{{ BucketStart: now.Add(-10 * time.Minute), TotalEvents: 1, Sources: []model.ActivitySourceCount{{ SourceName: "main", Events: 1, }}, }}, Methods: []model.MethodBreakdownRow{{Method: "GET", Events: 1}}, Bots: []model.BotBreakdownRow{{Key: "non_bot", Label: "Non-bot", Events: 1}}, TopIPsByEvents: []model.TopIPRow{{ IP: "203.0.113.10", Events: 3, TrafficBytes: 4096, LastSeenAt: now, Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true}, }}, TopBotIPsByEvents: []model.TopIPRow{{ IP: "203.0.113.10", Events: 3, TrafficBytes: 4096, LastSeenAt: now, Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true}, }}, TopNonBotIPsByEvents: []model.TopIPRow{{ IP: "198.51.100.20", Events: 2, TrafficBytes: 2048, LastSeenAt: now, }}, TopIPsByTraffic: []model.TopIPRow{{ IP: "203.0.113.10", Events: 3, TrafficBytes: 4096, LastSeenAt: now, Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true}, }}, TopBotIPsByTraffic: []model.TopIPRow{{ IP: "203.0.113.10", Events: 3, TrafficBytes: 4096, LastSeenAt: now, Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true}, }}, TopNonBotIPsByTraffic: []model.TopIPRow{{ IP: "198.51.100.20", Events: 2, TrafficBytes: 2048, LastSeenAt: now, }}, TopSources: []model.TopSourceRow{{ SourceName: "main", Events: 3, TrafficBytes: 4096, LastSeenAt: now, }}, TopURLs: []model.TopURLRow{{ Host: "example.test", URI: "/wp-login.php", Events: 3, TrafficBytes: 4096, LastSeenAt: now, }}, 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, Method: http.MethodGet, URI: "/wp-login.php", Host: "example.test", Status: http.StatusNotFound, Decision: model.DecisionActionBlock, CurrentState: model.IPStateBlocked, DecisionReason: "php_path", Actions: model.ActionAvailability{CanUnblock: true}, Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true}, }}, }, nil } func (s *stubApp) ListEvents(ctx context.Context, _ time.Time, limit int, options model.EventListOptions) ([]model.Event, error) { s.lastEventOptions = options overview, _ := s.GetOverview(ctx, time.Time{}, limit, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true}) items := overview.RecentEvents if limit > 1 { items = append(items, model.Event{ ID: 2, SourceName: "main", ClientIP: "198.51.100.20", OccurredAt: time.Now().UTC().Add(-time.Minute), Method: http.MethodPost, URI: "/xmlrpc.php", Host: "example.test", Status: http.StatusNotFound, CurrentState: model.IPStateReview, Actions: model.ActionAvailability{CanBlock: true}, }) } return items, nil } func (s *stubApp) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) { overview, _ := s.GetOverview(ctx, time.Time{}, limit, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true}) 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 }