70a83a1da9
Every Store interface method (except Close) now takes context.Context as first parameter. All 54 db.Query/Exec/QueryRow calls in SQLStore replaced with their *Context variants. DB operations now respect cancellation and deadlines. Context sources by caller: - Engine dbWriter/poll/pruner: engine ctx from Start() - HTTP handlers: r.Context() - config.Apply/Export: caller-provided ctx - TUI/main.go init: context.Background() RunCheck and all sub-checks (HTTP/ping/port/DNS) accept parent ctx. HTTP checks now inherit shutdown cancellation instead of rooting in context.Background(). dbWrite.exec takes ctx so the writer goroutine can cancel stuck DB operations. DeleteSite/ImportData use BeginTx(ctx) instead of Begin().
723 lines
22 KiB
Go
723 lines
22 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
)
|
|
|
|
// --- Mock Store ---
|
|
|
|
type mockStore struct {
|
|
mu sync.Mutex
|
|
sites []models.Site
|
|
alerts []models.AlertConfig
|
|
nodes map[string]models.ProbeNode
|
|
importedData *models.Backup
|
|
registeredNodes []models.ProbeNode
|
|
maintWindows []models.MaintenanceWindow
|
|
}
|
|
|
|
func newMockStore() *mockStore {
|
|
return &mockStore{
|
|
nodes: make(map[string]models.ProbeNode),
|
|
}
|
|
}
|
|
|
|
func (m *mockStore) Init(_ context.Context) error { return nil }
|
|
func (m *mockStore) GetSites(_ context.Context) ([]models.Site, error) { return m.sites, nil }
|
|
func (m *mockStore) AddSite(_ context.Context, _ models.Site) error { return nil }
|
|
func (m *mockStore) UpdateSite(_ context.Context, _ models.Site) error { return nil }
|
|
func (m *mockStore) UpdateSitePaused(_ context.Context, _ int, _ bool) error { return nil }
|
|
func (m *mockStore) DeleteSite(_ context.Context, _ int) error { return nil }
|
|
func (m *mockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) {
|
|
return m.alerts, nil
|
|
}
|
|
func (m *mockStore) GetAlert(_ context.Context, _ int) (models.AlertConfig, error) {
|
|
return models.AlertConfig{}, nil
|
|
}
|
|
func (m *mockStore) AddAlert(_ context.Context, _ string, _ string, _ map[string]string) error {
|
|
return nil
|
|
}
|
|
func (m *mockStore) UpdateAlert(_ context.Context, _ int, _ string, _ string, _ map[string]string) error {
|
|
return nil
|
|
}
|
|
func (m *mockStore) DeleteAlert(_ context.Context, _ int) error { return nil }
|
|
func (m *mockStore) GetAllUsers(_ context.Context) ([]models.User, error) { return nil, nil }
|
|
func (m *mockStore) AddUser(_ context.Context, _ string, _ string, _ string) error { return nil }
|
|
func (m *mockStore) UpdateUser(_ context.Context, _ int, _ string, _ string, _ string) error {
|
|
return nil
|
|
}
|
|
func (m *mockStore) DeleteUser(_ context.Context, _ int) error { return nil }
|
|
func (m *mockStore) SaveCheck(_ context.Context, _ int, _ int64, _ bool) error { return nil }
|
|
func (m *mockStore) SaveCheckFromNode(_ context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error {
|
|
return nil
|
|
}
|
|
func (m *mockStore) LoadAllHistory(_ context.Context, _ int) (map[int][]models.CheckRecord, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockStore) GetSiteByName(_ context.Context, _ string) (models.Site, error) {
|
|
return models.Site{}, nil
|
|
}
|
|
func (m *mockStore) GetAlertByName(_ context.Context, _ string) (models.AlertConfig, error) {
|
|
return models.AlertConfig{}, nil
|
|
}
|
|
func (m *mockStore) AddSiteReturningID(_ context.Context, _ models.Site) (int, error) { return 0, nil }
|
|
func (m *mockStore) AddAlertReturningID(_ context.Context, _ string, _ string, _ map[string]string) (int, error) {
|
|
return 0, nil
|
|
}
|
|
func (m *mockStore) GetAllNodes(_ context.Context) ([]models.ProbeNode, error) { return nil, nil }
|
|
func (m *mockStore) UpdateNodeLastSeen(_ context.Context, _ string) error { return nil }
|
|
func (m *mockStore) DeleteNode(_ context.Context, _ string) error { return nil }
|
|
func (m *mockStore) LoadAlertHealth(_ context.Context) (map[int]models.AlertHealthRecord, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockStore) SaveAlertHealth(_ context.Context, _ models.AlertHealthRecord) error { return nil }
|
|
func (m *mockStore) SaveLog(_ context.Context, _ string) error { return nil }
|
|
func (m *mockStore) PruneLogs(_ context.Context) error { return nil }
|
|
func (m *mockStore) PruneCheckHistory(_ context.Context) error { return nil }
|
|
func (m *mockStore) PruneStateChanges(_ context.Context) error { return nil }
|
|
func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil }
|
|
func (m *mockStore) GetAllMaintenanceWindows(_ context.Context, _ int) ([]models.MaintenanceWindow, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockStore) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error {
|
|
return nil
|
|
}
|
|
func (m *mockStore) EndMaintenanceWindow(_ context.Context, _ int) error { return nil }
|
|
func (m *mockStore) DeleteMaintenanceWindow(_ context.Context, _ int) error { return nil }
|
|
func (m *mockStore) PruneExpiredMaintenanceWindows(_ context.Context, _ time.Duration) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
func (m *mockStore) IsMonitorInMaintenance(_ context.Context, _ int) (bool, error) { return false, nil }
|
|
func (m *mockStore) GetPreference(_ context.Context, _ string) (string, error) { return "", nil }
|
|
func (m *mockStore) SetPreference(_ context.Context, _ string, _ string) error { return nil }
|
|
func (m *mockStore) SaveStateChange(_ context.Context, _ int, _ string, _ string, _ string) error {
|
|
return nil
|
|
}
|
|
func (m *mockStore) GetStateChanges(_ context.Context, _ int, _ int) ([]models.StateChange, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockStore) GetStateChangesSince(_ context.Context, _ int, _ time.Time) ([]models.StateChange, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockStore) Close() error { return nil }
|
|
|
|
func (m *mockStore) ExportData(_ context.Context) (models.Backup, error) {
|
|
return models.Backup{
|
|
Sites: m.sites,
|
|
Alerts: m.alerts,
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockStore) ImportData(_ context.Context, data models.Backup) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.importedData = &data
|
|
return nil
|
|
}
|
|
|
|
func (m *mockStore) RegisterNode(_ context.Context, node models.ProbeNode) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.registeredNodes = append(m.registeredNodes, node)
|
|
m.nodes[node.ID] = node
|
|
return nil
|
|
}
|
|
|
|
func (m *mockStore) GetNode(_ context.Context, id string) (models.ProbeNode, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if n, ok := m.nodes[id]; ok {
|
|
return n, nil
|
|
}
|
|
return models.ProbeNode{}, fmt.Errorf("not found")
|
|
}
|
|
|
|
func (m *mockStore) GetActiveMaintenanceWindows(_ context.Context) ([]models.MaintenanceWindow, error) {
|
|
return m.maintWindows, nil
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func freePort() int {
|
|
ln, _ := net.Listen("tcp", "127.0.0.1:0")
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
ln.Close()
|
|
return port
|
|
}
|
|
|
|
type testServer struct {
|
|
baseURL string
|
|
srv *http.Server
|
|
store *mockStore
|
|
engine *monitor.Engine
|
|
}
|
|
|
|
func newTestServer(t *testing.T, clusterKey string, enableStatus bool) *testServer {
|
|
t.Helper()
|
|
ms := newMockStore()
|
|
eng := monitor.NewEngine(ms)
|
|
port := freePort()
|
|
|
|
srv := Start(ServerConfig{
|
|
Port: port,
|
|
EnableStatus: enableStatus,
|
|
Title: "Test Status",
|
|
ClusterKey: clusterKey,
|
|
}, ms, eng)
|
|
|
|
ts := &testServer{
|
|
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
|
srv: srv,
|
|
store: ms,
|
|
engine: eng,
|
|
}
|
|
|
|
// Wait for server to be ready
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
resp, err := http.Get(ts.baseURL + "/api/health")
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
srv.Close()
|
|
})
|
|
|
|
return ts
|
|
}
|
|
|
|
func authReq(method, url, secret string, body []byte) (*http.Response, error) {
|
|
var req *http.Request
|
|
var err error
|
|
if body != nil {
|
|
req, err = http.NewRequest(method, url, bytes.NewReader(body))
|
|
} else {
|
|
req, err = http.NewRequest(method, url, nil)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if secret != "" {
|
|
req.Header.Set("X-Upkeep-Secret", secret)
|
|
}
|
|
return http.DefaultClient.Do(req)
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
func TestCheckSecret(t *testing.T) {
|
|
if !checkSecret("mykey", "mykey") {
|
|
t.Error("expected match")
|
|
}
|
|
if checkSecret("mykey", "wrong") {
|
|
t.Error("expected no match")
|
|
}
|
|
if checkSecret("", "key") {
|
|
t.Error("expected no match for empty got")
|
|
}
|
|
}
|
|
|
|
// --- Push Heartbeat ---
|
|
|
|
func TestPush_MissingToken(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
resp, err := http.Get(ts.baseURL + "/api/push")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 400 {
|
|
t.Errorf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestPush_InvalidToken(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
resp, err := http.Get(ts.baseURL + "/api/push?token=bad")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 404 {
|
|
t.Errorf("expected 404, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- Health ---
|
|
|
|
func TestHealth_NoSecret(t *testing.T) {
|
|
ts := newTestServer(t, "", false)
|
|
resp, err := http.Get(ts.baseURL + "/api/health")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200 with no cluster key, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestHealth_ValidSecret(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
resp, err := authReq("GET", ts.baseURL+"/api/health", "secret", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestHealth_WrongSecret(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
resp, err := authReq("GET", ts.baseURL+"/api/health", "wrong", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- Backup Export ---
|
|
|
|
func TestExport_Unauthorized_NoKey(t *testing.T) {
|
|
ts := newTestServer(t, "", false)
|
|
resp, err := http.Get(ts.baseURL + "/api/backup/export")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Errorf("expected 401 when no cluster key configured, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestExport_Unauthorized_WrongKey(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "wrong", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestExport_Success(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
ts.store.sites = []models.Site{{ID: 1, Name: "example", URL: "http://example.com"}}
|
|
|
|
resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "secret", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var backup models.Backup
|
|
json.NewDecoder(resp.Body).Decode(&backup)
|
|
if len(backup.Sites) != 1 {
|
|
t.Errorf("expected 1 site, got %d", len(backup.Sites))
|
|
}
|
|
}
|
|
|
|
// --- Backup Import ---
|
|
|
|
func TestImport_MethodNotAllowed(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
resp, err := authReq("GET", ts.baseURL+"/api/backup/import", "secret", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 405 {
|
|
t.Errorf("expected 405, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestImport_Unauthorized(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
body, _ := json.Marshal(models.Backup{})
|
|
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "wrong", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestImport_Success(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
backup := models.Backup{
|
|
Sites: []models.Site{{Name: "imported", URL: "http://example.com"}},
|
|
}
|
|
body, _ := json.Marshal(backup)
|
|
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
ts.store.mu.Lock()
|
|
defer ts.store.mu.Unlock()
|
|
if ts.store.importedData == nil {
|
|
t.Error("expected import data to be stored")
|
|
}
|
|
}
|
|
|
|
func TestImport_InvalidJSON(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", []byte("not json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 400 {
|
|
t.Errorf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- Probe Registration ---
|
|
|
|
func TestProbeRegister_Success(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
body, _ := json.Marshal(map[string]string{
|
|
"id": "node-1", "name": "US East", "region": "us-east",
|
|
})
|
|
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
ts.store.mu.Lock()
|
|
defer ts.store.mu.Unlock()
|
|
if len(ts.store.registeredNodes) != 1 {
|
|
t.Errorf("expected 1 registered node, got %d", len(ts.store.registeredNodes))
|
|
}
|
|
if ts.store.registeredNodes[0].ID != "node-1" {
|
|
t.Errorf("expected node-1, got %s", ts.store.registeredNodes[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestProbeRegister_MissingID(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
body, _ := json.Marshal(map[string]string{"name": "test"})
|
|
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 400 {
|
|
t.Errorf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestProbeRegister_Unauthorized(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
body, _ := json.Marshal(map[string]string{"id": "node-1"})
|
|
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "wrong", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- Probe Results ---
|
|
|
|
func TestProbeResults_Success(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
body, _ := json.Marshal(map[string]any{
|
|
"node_id": "node-1",
|
|
"results": []map[string]any{
|
|
{"site_id": 1, "latency_ns": 5000000, "is_up": true},
|
|
},
|
|
})
|
|
resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestProbeResults_MissingNodeID(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
body, _ := json.Marshal(map[string]any{
|
|
"results": []map[string]any{},
|
|
})
|
|
resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 400 {
|
|
t.Errorf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- Status Page ---
|
|
|
|
func TestStatusPage_Enabled(t *testing.T) {
|
|
ts := newTestServer(t, "secret", true)
|
|
resp, err := http.Get(ts.baseURL + "/status")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestStatusJSON_PublicDTOOnly(t *testing.T) {
|
|
ts := newTestServer(t, "secret", true)
|
|
|
|
// Seed a push monitor (no network IO) through the store and start the
|
|
// engine so its poll loop loads it into live state — the path real sites
|
|
// take. The old version of this test injected via UpdateSiteConfig, which
|
|
// no-ops for unknown IDs, so it asserted over zero sites and passed
|
|
// against a server that leaked tokens.
|
|
ts.store.sites = []models.Site{{
|
|
ID: 1, Name: "test", Type: "push", Token: "secret-token",
|
|
Hostname: "internal-host", LastError: "internal failure detail", AlertID: 3,
|
|
}}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ts.engine.Start(ctx)
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
ts.engine.Stop()
|
|
})
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) && len(ts.engine.GetLiveState()) == 0 {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if len(ts.engine.GetLiveState()) == 0 {
|
|
t.Fatal("engine never loaded the seeded site")
|
|
}
|
|
|
|
resp, err := http.Get(ts.baseURL + "/status/json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
// Decode raw so absent struct fields can't mask leaked JSON keys.
|
|
var state map[string]map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(state) != 1 {
|
|
t.Fatalf("expected 1 site in status JSON, got %d", len(state))
|
|
}
|
|
for _, site := range state {
|
|
if site["Name"] != "test" {
|
|
t.Errorf("expected Name to be public, got %v", site["Name"])
|
|
}
|
|
for _, leaked := range []string{"Token", "LastError", "Hostname", "Port", "DNSServer", "AlertID", "AcceptedCodes", "Interval"} {
|
|
if _, ok := site[leaked]; ok {
|
|
t.Errorf("status JSON leaks internal field %q", leaked)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStatusJSON_MaintenanceOverride(t *testing.T) {
|
|
ts := newTestServer(t, "secret", true)
|
|
ts.store.maintWindows = []models.MaintenanceWindow{
|
|
{ID: 1, MonitorID: 0, Type: "maintenance", StartTime: time.Now().Add(-1 * time.Hour)},
|
|
}
|
|
|
|
resp, err := http.Get(ts.baseURL + "/status/json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestStatusPage_Disabled(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
resp, err := http.Get(ts.baseURL + "/status")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 404 {
|
|
t.Errorf("expected 404 when status disabled, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- Probe Assignments ---
|
|
|
|
func TestProbeAssignments_Success(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
resp, err := authReq("GET", ts.baseURL+"/api/probe/assignments", "secret", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var result map[string][]models.Site
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
if _, ok := result["sites"]; !ok {
|
|
t.Error("expected 'sites' key in response")
|
|
}
|
|
}
|
|
|
|
func TestProbeAssignments_Unauthorized(t *testing.T) {
|
|
ts := newTestServer(t, "secret", false)
|
|
resp, err := authReq("GET", ts.baseURL+"/api/probe/assignments", "wrong", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- Security: X-Forwarded-For trusted-proxy handling ---
|
|
|
|
func mustCIDR(t *testing.T, s string) *net.IPNet {
|
|
t.Helper()
|
|
_, n, err := net.ParseCIDR(s)
|
|
if err != nil {
|
|
t.Fatalf("ParseCIDR(%q): %v", s, err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestClientIP_TrustedProxyHandling(t *testing.T) {
|
|
trusted := []*net.IPNet{mustCIDR(t, "10.0.0.0/8")}
|
|
|
|
tests := []struct {
|
|
name string
|
|
remoteAddr string
|
|
xff string
|
|
trusted []*net.IPNet
|
|
want string
|
|
}{
|
|
{"no trusted proxies ignores XFF", "203.0.113.9:5000", "1.2.3.4", nil, "203.0.113.9"},
|
|
{"untrusted peer ignores XFF", "203.0.113.9:5000", "1.2.3.4", trusted, "203.0.113.9"},
|
|
{"trusted peer honors XFF", "10.0.0.5:5000", "1.2.3.4", trusted, "1.2.3.4"},
|
|
{"trusted peer, rightmost-untrusted hop", "10.0.0.5:5000", "1.2.3.4, 10.0.0.9", trusted, "1.2.3.4"},
|
|
{"trusted peer, no XFF falls back to peer", "10.0.0.5:5000", "", trusted, "10.0.0.5"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
r.RemoteAddr = tt.remoteAddr
|
|
if tt.xff != "" {
|
|
r.Header.Set("X-Forwarded-For", tt.xff)
|
|
}
|
|
if got := clientIP(r, tt.trusted); got != tt.want {
|
|
t.Errorf("clientIP = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A spoofed, rotating X-Forwarded-For from an untrusted peer must NOT bypass
|
|
// the limiter: all requests key on the real RemoteAddr, so the bucket trips.
|
|
func TestRateLimit_SpoofedXFFCannotBypass(t *testing.T) {
|
|
rl := NewRateLimiter(60, nil) // no trusted proxies
|
|
allowed := 0
|
|
for i := 0; i < 200; i++ {
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
r.RemoteAddr = "203.0.113.9:5000"
|
|
r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i%256))
|
|
if rl.Allow(clientIP(r, rl.trusted)) {
|
|
allowed++
|
|
}
|
|
}
|
|
if allowed > 60 {
|
|
t.Errorf("spoofed XFF bypassed limiter: %d/200 allowed (burst is 60)", allowed)
|
|
}
|
|
}
|
|
|
|
func TestRateLimit_VisitorMapBounded(t *testing.T) {
|
|
rl := NewRateLimiter(60, nil)
|
|
for i := 0; i < maxVisitors+500; i++ {
|
|
rl.Allow(fmt.Sprintf("10.1.%d.%d", i/256, i%256))
|
|
}
|
|
rl.mu.Lock()
|
|
n := len(rl.visitors)
|
|
rl.mu.Unlock()
|
|
if n > maxVisitors {
|
|
t.Errorf("visitor map exceeded cap: %d > %d", n, maxVisitors)
|
|
}
|
|
}
|
|
|
|
// --- Security: export redaction allowlist ---
|
|
|
|
func TestRedactByProvider(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
typ string
|
|
in map[string]string
|
|
redacted []string // keys expected to be ***REDACTED***
|
|
kept []string // keys expected to survive verbatim
|
|
}{
|
|
{"discord url is secret", "discord", map[string]string{"url": "https://discord.com/api/webhooks/1/abc"}, []string{"url"}, nil},
|
|
{"opsgenie api_key redacted, priority kept", "opsgenie", map[string]string{"api_key": "k", "priority": "P1", "eu": "true"}, []string{"api_key"}, []string{"priority", "eu"}},
|
|
{"email creds redacted, routing kept", "email", map[string]string{"host": "smtp.x.com", "port": "587", "to": "a@x.com", "from": "b@x.com", "user": "u", "pass": "p"}, []string{"user", "pass"}, []string{"host", "port", "to", "from"}},
|
|
{"telegram token redacted, chat_id kept", "telegram", map[string]string{"token": "123:ABC", "chat_id": "42"}, []string{"token"}, []string{"chat_id"}},
|
|
{"unknown provider redacts everything", "mystery", map[string]string{"anything": "x"}, []string{"anything"}, nil},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
out := models.RedactAlertSettings(tt.typ, tt.in)
|
|
for _, k := range tt.redacted {
|
|
if out[k] != "***REDACTED***" {
|
|
t.Errorf("key %q: expected redacted, got %q", k, out[k])
|
|
}
|
|
}
|
|
for _, k := range tt.kept {
|
|
if out[k] != tt.in[k] {
|
|
t.Errorf("key %q: expected kept %q, got %q", k, tt.in[k], out[k])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|