You've already forked caddy-opnsense-blocker
436 lines
17 KiB
Go
436 lines
17 KiB
Go
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(), "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, `<select id="source-filter">`) || !strings.Contains(queryLogBody, `<option value="main">main</option>`) {
|
|
t.Fatalf("requests log page should expose a source dropdown with configured sources")
|
|
}
|
|
if !strings.Contains(queryLogBody, `<select id="method-filter">`) || !strings.Contains(queryLogBody, `<option value="POST">POST</option>`) {
|
|
t.Fatalf("requests log page should expose a method dropdown")
|
|
}
|
|
if !strings.Contains(queryLogBody, `<select id="status-filter">`) || !strings.Contains(queryLogBody, `<option value="4xx">4xx</option>`) {
|
|
t.Fatalf("requests log page should expose a structured HTTP status dropdown")
|
|
}
|
|
if !strings.Contains(queryLogBody, "Rows per page") {
|
|
t.Fatalf("requests log page should expose pagination settings")
|
|
}
|
|
if !strings.Contains(queryLogBody, `<option value="25">25</option>`) {
|
|
t.Fatalf("requests log page should expose the 25 rows per page option")
|
|
}
|
|
if !strings.Contains(queryLogBody, `id="column-source"`) || !strings.Contains(queryLogBody, `id="column-reason"`) {
|
|
t.Fatalf("requests log page should expose column visibility controls")
|
|
}
|
|
if !strings.Contains(queryLogBody, `id="requests-table"`) {
|
|
t.Fatalf("requests log page should render the tabulator mount point")
|
|
}
|
|
if !strings.Contains(queryLogBody, "Auto refresh") {
|
|
t.Fatalf("requests log page should expose the auto refresh toggle")
|
|
}
|
|
if !strings.Contains(queryLogBody, `/assets/tabulator/tabulator.min.js`) || !strings.Contains(queryLogBody, `/assets/tabulator/tabulator_midnight.min.css`) {
|
|
t.Fatalf("requests log page should load local tabulator assets")
|
|
}
|
|
if !strings.Contains(queryLogBody, `.status-code`) || strings.Contains(queryLogBody, `.status-pill`) {
|
|
t.Fatalf("requests log page should use the simplified status styling")
|
|
}
|
|
if !strings.Contains(queryLogBody, `.tabulator .tabulator-tableholder .tabulator-table { min-width: 100% !important; width: 100% !important; }`) {
|
|
t.Fatalf("requests log page should keep the tabulator body at full width")
|
|
}
|
|
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")
|
|
}
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodGet, "/assets/tabulator/tabulator.min.js", nil)
|
|
handler.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK || recorder.Body.Len() == 0 {
|
|
t.Fatalf("tabulator asset should be served locally: status=%d len=%d", recorder.Code, recorder.Body.Len())
|
|
}
|
|
|
|
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, "<h2>Investigation</h2>")
|
|
decisionsIndex := strings.Index(body, "<h2>Decisions</h2>")
|
|
requestsIndex := strings.Index(body, "<h2>Requests from this IP</h2>")
|
|
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) CountEvents(context.Context, time.Time, model.EventListOptions) (int64, error) {
|
|
return 251, nil
|
|
}
|
|
|
|
func (s *stubApp) ListSourceNames() []string {
|
|
return []string{"gitea", "main"}
|
|
}
|
|
|
|
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
|
|
}
|