2
Files
caddy-opnsense-blocker/internal/web/handler_test.go

187 lines
6.3 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?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
}