package server import ( "bytes" "encoding/json" "fmt" "net" "net/http" "sync" "testing" "time" "gitea.lerkolabs.com/lerko/uptop/internal/models" "gitea.lerkolabs.com/lerko/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() error { return nil } func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil } func (m *mockStore) AddSite(models.Site) error { return nil } func (m *mockStore) UpdateSite(models.Site) error { return nil } func (m *mockStore) UpdateSitePaused(int, bool) error { return nil } func (m *mockStore) DeleteSite(int) error { return nil } func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { return m.alerts, nil } func (m *mockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil } func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil } func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil } func (m *mockStore) DeleteAlert(int) error { return nil } func (m *mockStore) GetAllUsers() ([]models.User, error) { return nil, nil } func (m *mockStore) AddUser(string, string, string) error { return nil } func (m *mockStore) UpdateUser(int, string, string, string) error { return nil } func (m *mockStore) DeleteUser(int) error { return nil } func (m *mockStore) SaveCheck(int, int64, bool) error { return nil } func (m *mockStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error { return nil } func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) { return nil, nil } func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil } func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) { return models.AlertConfig{}, nil } func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil } func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) { return 0, nil } func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil } func (m *mockStore) UpdateNodeLastSeen(string) error { return nil } func (m *mockStore) DeleteNode(string) error { return nil } func (m *mockStore) SaveLog(string) error { return nil } func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil } func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) { return nil, nil } func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil } func (m *mockStore) EndMaintenanceWindow(int) error { return nil } func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil } func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil } func (m *mockStore) GetPreference(string) (string, error) { return "", nil } func (m *mockStore) SetPreference(string, string) error { return nil } func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil } func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil } func (m *mockStore) Close() error { return nil } func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{ Sites: m.sites, Alerts: m.alerts, }, nil } func (m *mockStore) ImportData(data models.Backup) error { m.mu.Lock() defer m.mu.Unlock() m.importedData = &data return nil } func (m *mockStore) RegisterNode(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(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() ([]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_TokensStripped(t *testing.T) { ts := newTestServer(t, "secret", true) // Inject a site with a token into engine state ts.engine.UpdateSiteConfig(models.Site{ID: 1, Name: "test", Type: "push", Token: "secret-token", Status: "UP"}) // Need to inject directly since UpdateSiteConfig only updates existing func() { ts.engine.RecordHeartbeat("unused") // just to exercise, won't match }() 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) } var state map[string]models.Site json.NewDecoder(resp.Body).Decode(&state) for _, site := range state { if site.Token != "" { t.Error("expected token stripped from status JSON response") } } } 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) } }