Files
uptop/internal/server/server_test.go
T
lerko 8f17deba67
CI / test (pull_request) Successful in 2m39s
CI / lint (pull_request) Successful in 1m6s
CI / vulncheck (pull_request) Successful in 46s
chore: migrate module path to lerkolabs org
Move Go module from gitea.lerkolabs.com/lerko/uptop to
gitea.lerkolabs.com/lerkolabs/uptop. Updates all imports,
go.mod, goreleaser owner, and README links.
2026-05-29 14:22:49 -04:00

561 lines
16 KiB
Go

package server
import (
"bytes"
"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() 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) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
return nil, nil
}
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) 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)
}
}