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 eventPage.LastPage != 2 || eventPage.TotalItems != 251 || len(eventPage.Data) != len(eventPage.Items) { t.Fatalf("event page should expose tabulator pagination metadata: %+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(), `/assets/chartjs/chart.umd.min.js`) { t.Fatalf("overview page should load local chart.js assets") } if !strings.Contains(recorder.Body.String(), `function mountDashboardChart(key, canvasId, config)`) { t.Fatalf("overview page should render the Chart.js dashboard helpers") } 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, pagination, and columns") { t.Fatalf("requests log page should expose the collapsible controls panel") } if !strings.Contains(queryLogBody, ``) || !strings.Contains(queryLogBody, ``) { t.Fatalf("requests log page should expose a method dropdown") } if !strings.Contains(queryLogBody, `