Pulse/internal/api/router_integration_test.go
2026-03-18 16:06:30 +00:00

1560 lines
43 KiB
Go

package api_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
gorillaws "github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/api"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
internalws "github.com/rcourtman/pulse-go-rewrite/internal/websocket"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
type integrationServer struct {
server *httptest.Server
monitor *monitoring.Monitor
hub *internalws.Hub
config *config.Config
}
func newIntegrationServer(t *testing.T) *integrationServer {
return newIntegrationServerWithConfig(t, nil)
}
func newIntegrationServerWithConfig(t *testing.T, customize func(*config.Config)) *integrationServer {
t.Helper()
t.Setenv("PULSE_MOCK_MODE", "true")
mock.SetEnabled(true)
tmpDir := t.TempDir()
cfg := &config.Config{
ConfigPath: tmpDir,
DataPath: tmpDir,
DemoMode: false,
AllowedOrigins: "*",
EnvOverrides: make(map[string]bool),
}
if customize != nil {
customize(cfg)
}
var monitor *monitoring.Monitor
hub := internalws.NewHub(func(orgID string) interface{} {
if monitor == nil {
return models.StateSnapshot{}
}
return monitor.BuildFrontendState()
})
go hub.Run()
var err error
monitor, err = monitoring.New(cfg)
if err != nil {
t.Fatalf("failed to create monitor: %v", err)
}
monitor.SetMockMode(true)
hub.SetStateGetter(func(orgID string) interface{} {
return monitor.BuildFrontendState()
})
version := readRuntimeVersion(t)
if version == "" {
version = "dev"
}
router := api.NewRouter(cfg, monitor, nil, hub, func() error {
monitor.SyncAlertState()
return nil
}, version, nil)
srv := newIPv4HTTPServer(t, router.Handler())
t.Cleanup(func() {
srv.Close()
if monitor != nil {
monitor.StopDiscoveryService()
monitor.Stop()
}
if hub != nil {
hub.Stop()
}
mock.SetEnabled(false)
})
return &integrationServer{
server: srv,
monitor: monitor,
hub: hub,
config: cfg,
}
}
func TestHealthEndpoint(t *testing.T) {
srv := newIntegrationServer(t)
res, err := http.Get(srv.server.URL + "/api/health")
if err != nil {
t.Fatalf("health request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", res.StatusCode, http.StatusOK)
}
var payload map[string]any
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode health response: %v", err)
}
if payload["status"] != "healthy" {
t.Fatalf("expected status=healthy, got %v", payload["status"])
}
dependencies, ok := payload["dependencies"].(map[string]any)
if !ok {
t.Fatalf("expected dependencies map in health response, got %#v", payload["dependencies"])
}
if dependencies["monitor"] != true {
t.Fatalf("expected monitor dependency to be true, got %#v", dependencies["monitor"])
}
if dependencies["scheduler"] != true {
t.Fatalf("expected scheduler dependency to be true, got %#v", dependencies["scheduler"])
}
if dependencies["websocket"] != true {
t.Fatalf("expected websocket dependency to be true, got %#v", dependencies["websocket"])
}
}
func TestVersionEndpointUsesRepoVersion(t *testing.T) {
srv := newIntegrationServer(t)
releaseVersion := readVersionFile(t)
runtimeVersion := readRuntimeVersion(t)
res, err := http.Get(srv.server.URL + "/api/version")
if err != nil {
t.Fatalf("version request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", res.StatusCode, http.StatusOK)
}
var payload map[string]any
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode version response: %v", err)
}
actual, ok := payload["version"].(string)
if !ok {
t.Fatalf("version field missing or not a string: %v", payload["version"])
}
if strings.HasPrefix(actual, "0.0.0-") {
// Development builds normalize to 0.0.0-<branch>[...], which is expected.
return
}
normalizedActual := normalizeVersion(actual)
if releaseVersion != "" && normalizedActual == normalizeVersion(releaseVersion) {
return
}
if normalizedActual == normalizeVersion(runtimeVersion) {
return
}
t.Fatalf("expected version to match release %q or runtime %q, got %s", releaseVersion, runtimeVersion, actual)
}
func TestAlertAcknowledge_AllowsPrintableAlertIDs(t *testing.T) {
srv := newIntegrationServer(t)
// This ID includes parentheses which were rejected in v5 RC builds due to overly strict
// validation. The request should make it to the alert manager, which returns 404 because
// the alert does not exist in this test environment.
alertID := "docker(host)-container-unhealthy"
body := bytes.NewBufferString(`{"alertIdentifier":"` + alertID + `"}`)
req, err := http.NewRequest(http.MethodPost, srv.server.URL+"/api/alerts/acknowledge", body)
if err != nil {
t.Fatalf("create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("ack request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNotFound {
body, _ := io.ReadAll(res.Body)
t.Fatalf("unexpected status: got %d want %d, body=%s", res.StatusCode, http.StatusNotFound, string(body))
}
}
func TestStateEndpointReturnsMockData(t *testing.T) {
srv := newIntegrationServer(t)
res, err := http.Get(srv.server.URL + "/api/state")
if err != nil {
t.Fatalf("state request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", res.StatusCode, http.StatusOK)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read state response: %v", err)
}
var snapshot map[string]any
if err := json.Unmarshal(body, &snapshot); err != nil {
t.Fatalf("unmarshal state response: %v", err)
}
if _, ok := snapshot["nodes"]; ok {
t.Fatalf("state response should not include legacy nodes key: %s", string(body))
}
hasNonLegacyData := false
for _, key := range []string{"resources", "connectedInfrastructure"} {
if values, ok := snapshot[key].([]any); ok && len(values) > 0 {
hasNonLegacyData = true
break
}
}
if !hasNonLegacyData {
t.Fatalf("expected non-legacy state data (resources/kubernetesClusters/pbs/pmg), got: %s", string(body))
}
}
func TestRecoveryRollupsEndpointReturnsMockData(t *testing.T) {
srv := newIntegrationServer(t)
res, err := http.Get(srv.server.URL + "/api/recovery/rollups?limit=500")
if err != nil {
t.Fatalf("rollups request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Fatalf("unexpected status: got %d want %d, body=%s", res.StatusCode, http.StatusOK, string(body))
}
var payload struct {
Data []map[string]any `json:"data"`
Meta map[string]any `json:"meta"`
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read rollups response: %v", err)
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("unmarshal rollups response: %v", err)
}
if len(payload.Data) == 0 {
t.Fatalf("expected rollups data, got none: %s", string(body))
}
hasK8s := false
hasTrueNAS := false
for _, item := range payload.Data {
raw, ok := item["providers"]
if !ok {
continue
}
arr, ok := raw.([]any)
if !ok {
continue
}
for _, v := range arr {
s, _ := v.(string)
if s == "kubernetes" {
hasK8s = true
}
if s == "truenas" {
hasTrueNAS = true
}
}
}
if !hasK8s || !hasTrueNAS {
t.Fatalf("expected rollups to include kubernetes and truenas providers, got k8s=%v truenas=%v", hasK8s, hasTrueNAS)
}
}
func TestProtectedEndpointsRequireAuthentication(t *testing.T) {
passwordHash, err := internalauth.HashPassword("supersecret")
if err != nil {
t.Fatalf("hash password: %v", err)
}
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
cfg.AuthUser = "admin"
cfg.AuthPass = passwordHash
})
client := &http.Client{}
endpoints := []struct {
method string
path string
}{
{"GET", "/api/state"},
{"GET", "/api/storage/test-storage"},
{"GET", "/api/updates/status"},
{"POST", "/api/updates/apply"},
{"GET", "/api/alerts/active"},
{"GET", "/api/notifications/email"},
}
for _, ep := range endpoints {
req, err := http.NewRequest(ep.method, srv.server.URL+ep.path, nil)
if err != nil {
t.Fatalf("build request for %s %s: %v", ep.method, ep.path, err)
}
res, err := client.Do(req)
if err != nil {
t.Fatalf("request for %s %s failed: %v", ep.method, ep.path, err)
}
_ = res.Body.Close()
if res.StatusCode != http.StatusUnauthorized && res.StatusCode != http.StatusForbidden {
t.Fatalf("expected 401/403 for %s %s, got %d", ep.method, ep.path, res.StatusCode)
}
}
}
func TestAPIOnlyModeRequiresToken(t *testing.T) {
const rawToken = "apitoken-test-1234567890"
tokenRecord, err := config.NewAPITokenRecord(rawToken, "test token", []string{config.ScopeMonitoringRead})
if err != nil {
t.Fatalf("create token record: %v", err)
}
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
cfg.AuthUser = ""
cfg.AuthPass = ""
cfg.APITokens = []config.APITokenRecord{*tokenRecord}
})
client := &http.Client{}
// Without token should be rejected.
req, err := http.NewRequest("GET", srv.server.URL+"/api/state", nil)
if err != nil {
t.Fatalf("build unauthenticated request: %v", err)
}
res, err := client.Do(req)
if err != nil {
t.Fatalf("unauthenticated request failed: %v", err)
}
_ = res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 without API token, got %d", res.StatusCode)
}
// With the correct token should succeed.
req, err = http.NewRequest("GET", srv.server.URL+"/api/state", nil)
if err != nil {
t.Fatalf("build authenticated request: %v", err)
}
req.Header.Set("X-API-Token", rawToken)
res, err = client.Do(req)
if err != nil {
t.Fatalf("authenticated request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with valid API token, got %d", res.StatusCode)
}
}
func TestServerInfoEndpointReportsDevelopment(t *testing.T) {
srv := newIntegrationServer(t)
res, err := http.Get(srv.server.URL + "/api/server/info")
if err != nil {
t.Fatalf("server info request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", res.StatusCode, http.StatusOK)
}
var payload map[string]any
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode server info response: %v", err)
}
isDev, ok := payload["isDevelopment"].(bool)
if !ok {
t.Fatalf("isDevelopment missing or not bool: %v", payload["isDevelopment"])
}
if !isDev {
t.Fatalf("expected development mode to be true")
}
version, ok := payload["version"].(string)
if !ok {
t.Fatalf("version missing or not string: %v", payload["version"])
}
if version == "" {
t.Fatalf("expected non-empty version string")
}
}
func TestRecoveryPointsEndpointReturnsMockData(t *testing.T) {
srv := newIntegrationServer(t)
res, err := http.Get(srv.server.URL + "/api/recovery/points?limit=500")
if err != nil {
t.Fatalf("recovery points request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Fatalf("unexpected status: got %d want %d; body=%s", res.StatusCode, http.StatusOK, string(body))
}
var payload struct {
Data []struct {
Provider string `json:"provider"`
} `json:"data"`
Meta struct {
Total int `json:"total"`
} `json:"meta"`
}
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode recovery points response: %v", err)
}
if payload.Meta.Total <= 0 {
t.Fatalf("expected meta.total > 0, got %d", payload.Meta.Total)
}
var hasK8s, hasTrueNAS bool
for _, p := range payload.Data {
switch p.Provider {
case "kubernetes":
hasK8s = true
case "truenas":
hasTrueNAS = true
}
}
if !hasK8s {
t.Fatalf("expected at least one kubernetes recovery point in response")
}
if !hasTrueNAS {
t.Fatalf("expected at least one truenas recovery point in response")
}
}
func TestServerInfoEndpointMethodNotAllowed(t *testing.T) {
srv := newIntegrationServer(t)
req, err := http.NewRequest(http.MethodPost, srv.server.URL+"/api/server/info", nil)
if err != nil {
t.Fatalf("create request failed: %v", err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("server info POST request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("unexpected status: got %d want %d", res.StatusCode, http.StatusMethodNotAllowed)
}
}
func TestHealthEndpointMethodNotAllowed(t *testing.T) {
srv := newIntegrationServer(t)
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
req, err := http.NewRequest(method, srv.server.URL+"/api/health", nil)
if err != nil {
t.Fatalf("create request failed: %v", err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("health %s request failed: %v", method, err)
}
res.Body.Close()
if res.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("%s: unexpected status: got %d want %d", method, res.StatusCode, http.StatusMethodNotAllowed)
}
}
}
func TestHealthEndpointHeadAllowed(t *testing.T) {
srv := newIntegrationServer(t)
req, err := http.NewRequest(http.MethodHead, srv.server.URL+"/api/health", nil)
if err != nil {
t.Fatalf("create request failed: %v", err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("health HEAD request failed: %v", err)
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("HEAD: unexpected status: got %d want %d", res.StatusCode, http.StatusOK)
}
}
func TestConfigNodesUsesMockTopology(t *testing.T) {
srv := newIntegrationServer(t)
res, err := http.Get(srv.server.URL + "/api/config/nodes")
if err != nil {
t.Fatalf("config nodes request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", res.StatusCode, http.StatusOK)
}
var nodes []map[string]any
if err := json.NewDecoder(res.Body).Decode(&nodes); err != nil {
t.Fatalf("decode config nodes response: %v", err)
}
if len(nodes) == 0 {
t.Fatalf("expected at least one mock node definition")
}
}
func TestMockModeToggleEndpoint(t *testing.T) {
srv := newIntegrationServer(t)
if !mock.IsMockEnabled() {
t.Fatalf("mock mode should be enabled at start of test")
}
disablePayload := bytes.NewBufferString(`{"enabled": false}`)
res, err := http.Post(srv.server.URL+"/api/system/mock-mode", "application/json", disablePayload)
if err != nil {
t.Fatalf("disable mock mode request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("unexpected status disabling mock mode: got %d want %d", res.StatusCode, http.StatusOK)
}
var response struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(res.Body).Decode(&response); err != nil {
t.Fatalf("decode mock mode response: %v", err)
}
if response.Enabled {
t.Fatalf("expected mock mode to be disabled")
}
if mock.IsMockEnabled() {
t.Fatalf("mock mode global flag not disabled")
}
enablePayload := bytes.NewBufferString(`{"enabled": true}`)
resEnable, err := http.Post(srv.server.URL+"/api/system/mock-mode", "application/json", enablePayload)
if err != nil {
t.Fatalf("enable mock mode request failed: %v", err)
}
defer resEnable.Body.Close()
if resEnable.StatusCode != http.StatusOK {
t.Fatalf("unexpected status enabling mock mode: got %d want %d", resEnable.StatusCode, http.StatusOK)
}
if err := json.NewDecoder(resEnable.Body).Decode(&response); err != nil {
t.Fatalf("decode enable response: %v", err)
}
if !response.Enabled {
t.Fatalf("expected mock mode to be enabled after re-enable call")
}
}
func TestAuthenticatedEndpointsRequireToken(t *testing.T) {
const apiToken = "test-token"
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
record, err := config.NewAPITokenRecord(apiToken, "Integration test token", nil)
if err != nil {
t.Fatalf("create API token record: %v", err)
}
cfg.APITokens = []config.APITokenRecord{*record}
cfg.SortAPITokens()
hashedPass, err := internalauth.HashPassword("super-secure-pass")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg.AuthUser = "admin"
cfg.AuthPass = hashedPass
})
url := srv.server.URL + "/api/config/nodes"
// Without token -> unauthorized
res, err := http.Get(url)
if err != nil {
t.Fatalf("unauthenticated request failed: %v", err)
}
res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", res.StatusCode)
}
// With wrong token -> still unauthorized
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("create request: %v", err)
}
req.Header.Set("X-API-Token", "wrong-token")
res, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request with wrong token failed: %v", err)
}
res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 with wrong token, got %d", res.StatusCode)
}
// With correct token -> success
req, err = http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("create authenticated request: %v", err)
}
req.Header.Set("X-API-Token", apiToken)
res, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("authenticated request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with valid token, got %d", res.StatusCode)
}
// Admin endpoint should reject without token and accept with token
postURL := srv.server.URL + "/api/config/nodes"
req, err = http.NewRequest(http.MethodPost, postURL, bytes.NewBufferString("{}"))
if err != nil {
t.Fatalf("create POST request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
res, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unauthenticated POST failed: %v", err)
}
res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 for POST without token, got %d", res.StatusCode)
}
req, err = http.NewRequest(http.MethodPost, postURL, bytes.NewBufferString("{}"))
if err != nil {
t.Fatalf("create authenticated POST request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Token", apiToken)
res, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("authenticated POST failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode == http.StatusUnauthorized {
t.Fatalf("expected POST to require auth but got 401 even with valid token")
}
if res.StatusCode != http.StatusBadRequest && res.StatusCode != http.StatusForbidden && res.StatusCode != http.StatusOK {
t.Fatalf("unexpected status for authenticated POST: %d", res.StatusCode)
}
}
func TestAPITokenQueryAndHeaderAuth(t *testing.T) {
const apiToken = "query-token-1234567890"
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
record, err := config.NewAPITokenRecord(apiToken, "Query token test", nil)
if err != nil {
t.Fatalf("create API token record: %v", err)
}
cfg.APITokens = []config.APITokenRecord{*record}
cfg.SortAPITokens()
})
// Query-string tokens must be rejected for regular HTTP requests to prevent
// token leakage via logs, referrer headers, and browser history.
queryURL := srv.server.URL + "/api/state?token=" + apiToken
res, err := http.Get(queryURL)
if err != nil {
t.Fatalf("query parameter request failed: %v", err)
}
res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 when query-string token used without WebSocket upgrade, got %d", res.StatusCode)
}
// Query-string tokens must be accepted for WebSocket upgrade requests.
wsReq, err := http.NewRequest(http.MethodGet, srv.server.URL+"/api/state?token="+apiToken, nil)
if err != nil {
t.Fatalf("create websocket upgrade request: %v", err)
}
wsReq.Header.Set("Upgrade", "websocket")
wsReq.Header.Set("Connection", "Upgrade")
wsRes, err := http.DefaultClient.Do(wsReq)
if err != nil {
t.Fatalf("websocket upgrade request failed: %v", err)
}
wsRes.Body.Close()
// The server won't complete the WebSocket handshake (no real upgrader),
// but the auth layer should accept the token — anything other than 401 is fine.
if wsRes.StatusCode == http.StatusUnauthorized {
t.Fatalf("expected query-string token to be accepted on WebSocket upgrade, got 401")
}
// Header-based token auth must still work for regular requests.
req, err := http.NewRequest(http.MethodGet, srv.server.URL+"/api/state", nil)
if err != nil {
t.Fatalf("create header-auth request: %v", err)
}
req.Header.Set("X-API-Token", apiToken)
res, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("header-auth request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with header token, got %d", res.StatusCode)
}
}
func TestRecoveryEndpointRequiresDirectLoopback(t *testing.T) {
srv := newIntegrationServer(t)
generateBody := strings.NewReader(`{"action":"generate_token"}`)
req, err := http.NewRequest(http.MethodPost, srv.server.URL+"/api/security/recovery", generateBody)
if err != nil {
t.Fatalf("create generate token request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("generate token request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200 generating token from loopback, got %d", res.StatusCode)
}
forwardedBody := strings.NewReader(`{"action":"generate_token"}`)
reqForwarded, err := http.NewRequest(http.MethodPost, srv.server.URL+"/api/security/recovery", forwardedBody)
if err != nil {
t.Fatalf("create forwarded request: %v", err)
}
reqForwarded.Header.Set("Content-Type", "application/json")
reqForwarded.Header.Set("X-Forwarded-For", "198.51.100.42")
resForwarded, err := http.DefaultClient.Do(reqForwarded)
if err != nil {
t.Fatalf("forwarded recovery request failed: %v", err)
}
defer resForwarded.Body.Close()
if resForwarded.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 when forwarded headers present, got %d", resForwarded.StatusCode)
}
}
func TestWebSocketSendsInitialState(t *testing.T) {
srv := newIntegrationServer(t)
wsURL := "ws" + strings.TrimPrefix(srv.server.URL, "http") + "/ws?org_id=default"
conn, _, err := gorillaws.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("websocket dial failed: %v", err)
}
defer conn.Close()
readMsg := func() (string, map[string]any) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatalf("set deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read message: %v", err)
}
var msg map[string]any
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("decode message: %v", err)
}
typeVal, _ := msg["type"].(string)
payload := map[string]any{}
if raw, ok := msg["data"].(map[string]any); ok {
payload = raw
}
return typeVal, payload
}
msgType, _ := readMsg()
if msgType != "welcome" {
t.Fatalf("expected welcome message, got %q", msgType)
}
msgType, payload := readMsg()
if msgType != "initialState" {
t.Fatalf("expected initialState message, got %q", msgType)
}
legacyKeys := []string{
"nodes",
"vms",
"containers",
"dockerHosts",
"hosts",
"storage",
}
for _, key := range legacyKeys {
if _, ok := payload[key]; ok {
t.Fatalf("initial state should not include legacy key %q", key)
}
}
// Broadcast an additional state update and ensure clients receive it
state := srv.monitor.BuildFrontendState()
srv.hub.BroadcastState(state)
msgType, payload = readMsg()
if msgType != "rawData" {
t.Fatalf("expected rawData broadcast, got %q", msgType)
}
for _, key := range legacyKeys {
if _, ok := payload[key]; ok {
t.Fatalf("broadcast payload should not include legacy key %q", key)
}
}
}
func TestWebsocketPayloadContractShape(t *testing.T) {
srv := newIntegrationServer(t)
wsURL := "ws" + strings.TrimPrefix(srv.server.URL, "http") + "/ws?org_id=default"
conn, _, err := gorillaws.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("websocket dial failed: %v", err)
}
defer conn.Close()
readMsg := func() (string, map[string]any) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatalf("set deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read message: %v", err)
}
var msg map[string]any
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("decode message: %v", err)
}
typeVal, _ := msg["type"].(string)
payload := map[string]any{}
if raw, ok := msg["data"].(map[string]any); ok {
payload = raw
}
return typeVal, payload
}
readType := func(expected string) map[string]any {
t.Helper()
for i := 0; i < 6; i++ {
msgType, payload := readMsg()
if msgType == expected {
return payload
}
}
t.Fatalf("timed out waiting for %q websocket message", expected)
return nil
}
readType("welcome")
readType("initialState")
contractState := models.StateFrontend{
Resources: []models.ResourceFrontend{
{
ID: "resource-1",
Type: "node",
Name: "node-1",
DisplayName: "Node 1",
PlatformID: "platform-1",
PlatformType: "proxmox",
SourceType: "pve",
Status: "online",
LastSeen: 1,
},
},
ConnectedInfrastructure: []models.ConnectedInfrastructureItemFrontend{
{
ID: "resource-1",
Name: "Node 1",
Status: "active",
Surfaces: []models.ConnectedInfrastructureSurfaceFrontend{
{ID: "agent:host-1", Kind: "agent", Label: "Host telemetry"},
},
},
},
}
srv.hub.BroadcastState(contractState)
payload := readType("rawData")
requiredArrayKeys := []string{"resources", "connectedInfrastructure"}
for _, key := range requiredArrayKeys {
val, ok := payload[key]
if !ok {
t.Fatalf("websocket payload missing required %q key", key)
}
if values, ok := val.([]any); !ok || len(values) == 0 {
t.Fatalf("websocket payload key %q must be a non-empty array, got %T (%v)", key, val, val)
}
}
legacyKeys := []string{
"nodes",
"vms",
"containers",
"dockerHosts",
"hosts",
"storage",
"removedDockerHosts",
"removedHostAgents",
"removedKubernetesClusters",
}
for _, key := range legacyKeys {
if _, ok := payload[key]; ok {
t.Fatalf("expected websocket payload to exclude legacy key %q", key)
}
}
if _, ok := payload["backups"]; ok {
t.Fatalf("websocket payload must not include legacy backups map")
}
}
func TestWebsocketPayloadUsesCanonicalStateContract(t *testing.T) {
srv := newIntegrationServer(t)
wsURL := "ws" + strings.TrimPrefix(srv.server.URL, "http") + "/ws?org_id=default"
conn, _, err := gorillaws.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("websocket dial failed: %v", err)
}
defer conn.Close()
readMsg := func() (string, map[string]any) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatalf("set deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read message: %v", err)
}
var msg map[string]any
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("decode message: %v", err)
}
typeVal, _ := msg["type"].(string)
payload := map[string]any{}
if raw, ok := msg["data"].(map[string]any); ok {
payload = raw
}
return typeVal, payload
}
readType := func(expected string) map[string]any {
t.Helper()
for i := 0; i < 6; i++ {
msgType, payload := readMsg()
if msgType == expected {
return payload
}
}
t.Fatalf("timed out waiting for %q websocket message", expected)
return nil
}
readType("welcome")
readType("initialState")
testState := models.StateFrontend{
Resources: []models.ResourceFrontend{
{
ID: "resource-1",
Type: "node",
Name: "node-1",
DisplayName: "Node 1",
PlatformID: "platform-1",
PlatformType: "proxmox",
SourceType: "pve",
Status: "online",
LastSeen: 1,
},
},
ConnectedInfrastructure: []models.ConnectedInfrastructureItemFrontend{
{
ID: "resource-1",
Name: "Node 1",
Status: "active",
Surfaces: []models.ConnectedInfrastructureSurfaceFrontend{
{ID: "agent:host-1", Kind: "agent", Label: "Host telemetry"},
},
},
},
}
srv.hub.BroadcastState(testState)
canonicalPayload := readType("rawData")
legacyKeys := []string{
"nodes",
"vms",
"containers",
"dockerHosts",
"hosts",
"storage",
"removedDockerHosts",
"removedHostAgents",
"removedKubernetesClusters",
}
for _, key := range legacyKeys {
if _, ok := canonicalPayload[key]; ok {
t.Fatalf("expected canonical websocket payload to exclude %s", key)
}
}
resources, ok := canonicalPayload["resources"].([]any)
if !ok || len(resources) == 0 {
t.Fatalf("expected resources in canonical websocket payload: %v", canonicalPayload["resources"])
}
if connectedInfra, ok := canonicalPayload["connectedInfrastructure"].([]any); !ok || len(connectedInfra) == 0 {
t.Fatalf(
"expected connectedInfrastructure in canonical websocket payload: %v",
canonicalPayload["connectedInfrastructure"],
)
}
if _, ok := canonicalPayload["backups"]; ok {
t.Fatalf("expected canonical websocket payload to omit backups")
}
}
func TestSessionCookieAllowsAuthenticatedAccess(t *testing.T) {
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
hashedPass, err := internalauth.HashPassword("super-secure-pass")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg.AuthUser = "admin"
cfg.AuthPass = hashedPass
})
noCookieResp, err := http.Get(srv.server.URL + "/api/config/nodes")
if err != nil {
t.Fatalf("unauthenticated request failed: %v", err)
}
noCookieResp.Body.Close()
if noCookieResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 without session, got %d", noCookieResp.StatusCode)
}
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("create cookie jar: %v", err)
}
client := &http.Client{Jar: jar}
body, err := json.Marshal(map[string]string{
"username": "admin",
"password": "super-secure-pass",
})
if err != nil {
t.Fatalf("marshal login payload: %v", err)
}
loginReq, err := http.NewRequest(http.MethodPost, srv.server.URL+"/api/login", bytes.NewReader(body))
if err != nil {
t.Fatalf("create login request: %v", err)
}
loginReq.Header.Set("Content-Type", "application/json")
loginResp, err := client.Do(loginReq)
if err != nil {
t.Fatalf("login request failed: %v", err)
}
loginResp.Body.Close()
if loginResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 on login, got %d", loginResp.StatusCode)
}
sessionURL, _ := url.Parse(srv.server.URL)
cookies := jar.Cookies(sessionURL)
var hasSessionCookie bool
for _, c := range cookies {
if c.Name == "pulse_session" && c.Value != "" {
hasSessionCookie = true
break
}
}
if !hasSessionCookie {
t.Fatalf("expected pulse_session cookie after login")
}
authedResp, err := client.Get(srv.server.URL + "/api/config/nodes")
if err != nil {
t.Fatalf("authenticated request failed: %v", err)
}
defer authedResp.Body.Close()
if authedResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with session cookie, got %d", authedResp.StatusCode)
}
}
func TestRevokedAPITokenImmediatelyLosesAccess(t *testing.T) {
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
hashedPass, err := internalauth.HashPassword("super-secure-pass")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg.AuthUser = "admin"
cfg.AuthPass = hashedPass
})
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("create cookie jar: %v", err)
}
sessionClient := &http.Client{Jar: jar}
tokenClient := &http.Client{}
loginBody, err := json.Marshal(map[string]string{
"username": "admin",
"password": "super-secure-pass",
})
if err != nil {
t.Fatalf("marshal login payload: %v", err)
}
loginReq, err := http.NewRequest(http.MethodPost, srv.server.URL+"/api/login", bytes.NewReader(loginBody))
if err != nil {
t.Fatalf("create login request: %v", err)
}
loginReq.Header.Set("Content-Type", "application/json")
loginResp, err := sessionClient.Do(loginReq)
if err != nil {
t.Fatalf("login request failed: %v", err)
}
loginResp.Body.Close()
if loginResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 on login, got %d", loginResp.StatusCode)
}
sessionURL, err := url.Parse(srv.server.URL)
if err != nil {
t.Fatalf("parse session URL: %v", err)
}
var csrfToken string
for _, cookie := range jar.Cookies(sessionURL) {
if cookie.Name == "pulse_csrf" {
csrfToken = cookie.Value
break
}
}
if csrfToken == "" {
t.Fatalf("expected pulse_csrf cookie after login")
}
createReq, err := http.NewRequest(
http.MethodPost,
srv.server.URL+"/api/security/tokens",
bytes.NewBufferString(`{"name":"revocation-proof","scopes":["monitoring:read"]}`),
)
if err != nil {
t.Fatalf("create token request: %v", err)
}
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("X-CSRF-Token", csrfToken)
createResp, err := sessionClient.Do(createReq)
if err != nil {
t.Fatalf("create token request failed: %v", err)
}
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(createResp.Body)
t.Fatalf("expected 200 creating token, got %d: %s", createResp.StatusCode, string(body))
}
var tokenPayload struct {
Token string `json:"token"`
Record struct {
ID string `json:"id"`
} `json:"record"`
}
if err := json.NewDecoder(createResp.Body).Decode(&tokenPayload); err != nil {
t.Fatalf("decode token create response: %v", err)
}
if tokenPayload.Token == "" || tokenPayload.Record.ID == "" {
t.Fatalf("expected token and record ID, got %+v", tokenPayload)
}
assertStateAuth := func(headerName, headerValue string, wantStatus int) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, srv.server.URL+"/api/state", nil)
if err != nil {
t.Fatalf("create state request: %v", err)
}
req.Header.Set(headerName, headerValue)
res, err := tokenClient.Do(req)
if err != nil {
t.Fatalf("state request failed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != wantStatus {
body, _ := io.ReadAll(res.Body)
t.Fatalf("expected %d for %s auth, got %d: %s", wantStatus, headerName, res.StatusCode, string(body))
}
}
assertStateAuth("X-API-Token", tokenPayload.Token, http.StatusOK)
assertStateAuth("Authorization", "Bearer "+tokenPayload.Token, http.StatusOK)
deleteReq, err := http.NewRequest(
http.MethodDelete,
srv.server.URL+"/api/security/tokens/"+tokenPayload.Record.ID,
nil,
)
if err != nil {
t.Fatalf("create delete request: %v", err)
}
deleteReq.Header.Set("X-CSRF-Token", csrfToken)
deleteResp, err := sessionClient.Do(deleteReq)
if err != nil {
t.Fatalf("delete token request failed: %v", err)
}
deleteResp.Body.Close()
if deleteResp.StatusCode != http.StatusNoContent {
t.Fatalf("expected 204 deleting token, got %d", deleteResp.StatusCode)
}
assertStateAuth("X-API-Token", tokenPayload.Token, http.StatusUnauthorized)
assertStateAuth("Authorization", "Bearer "+tokenPayload.Token, http.StatusUnauthorized)
}
func TestLimitedAPITokenCannotCreateBroaderToken(t *testing.T) {
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
hashedPass, err := internalauth.HashPassword("super-secure-pass")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg.AuthUser = "admin"
cfg.AuthPass = hashedPass
})
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("create cookie jar: %v", err)
}
sessionClient := &http.Client{Jar: jar}
tokenClient := &http.Client{}
loginBody, err := json.Marshal(map[string]string{
"username": "admin",
"password": "super-secure-pass",
})
if err != nil {
t.Fatalf("marshal login payload: %v", err)
}
loginReq, err := http.NewRequest(http.MethodPost, srv.server.URL+"/api/login", bytes.NewReader(loginBody))
if err != nil {
t.Fatalf("create login request: %v", err)
}
loginReq.Header.Set("Content-Type", "application/json")
loginResp, err := sessionClient.Do(loginReq)
if err != nil {
t.Fatalf("login request failed: %v", err)
}
loginResp.Body.Close()
if loginResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 on login, got %d", loginResp.StatusCode)
}
sessionURL, err := url.Parse(srv.server.URL)
if err != nil {
t.Fatalf("parse session URL: %v", err)
}
var csrfToken string
for _, cookie := range jar.Cookies(sessionURL) {
if cookie.Name == "pulse_csrf" {
csrfToken = cookie.Value
break
}
}
if csrfToken == "" {
t.Fatalf("expected pulse_csrf cookie after login")
}
createLimitedReq, err := http.NewRequest(
http.MethodPost,
srv.server.URL+"/api/security/tokens",
bytes.NewBufferString(`{"name":"limited-token","scopes":["settings:write"]}`),
)
if err != nil {
t.Fatalf("create limited token request: %v", err)
}
createLimitedReq.Header.Set("Content-Type", "application/json")
createLimitedReq.Header.Set("X-CSRF-Token", csrfToken)
createLimitedResp, err := sessionClient.Do(createLimitedReq)
if err != nil {
t.Fatalf("limited token creation request failed: %v", err)
}
defer createLimitedResp.Body.Close()
if createLimitedResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(createLimitedResp.Body)
t.Fatalf("expected 200 creating limited token, got %d: %s", createLimitedResp.StatusCode, string(body))
}
var limitedTokenPayload struct {
Token string `json:"token"`
}
if err := json.NewDecoder(createLimitedResp.Body).Decode(&limitedTokenPayload); err != nil {
t.Fatalf("decode limited token create response: %v", err)
}
if limitedTokenPayload.Token == "" {
t.Fatalf("expected limited token value in response")
}
assertScopeEscalationDenied := func(name, body, wantFragment string) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, srv.server.URL+"/api/security/tokens", bytes.NewBufferString(body))
if err != nil {
t.Fatalf("create denied token request %s: %v", name, err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+limitedTokenPayload.Token)
res, err := tokenClient.Do(req)
if err != nil {
t.Fatalf("denied token request %s failed: %v", name, err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusForbidden {
body, _ := io.ReadAll(res.Body)
t.Fatalf("expected 403 for %s, got %d: %s", name, res.StatusCode, string(body))
}
payload, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read denied response for %s: %v", name, err)
}
if !strings.Contains(string(payload), wantFragment) {
t.Fatalf("expected %s response to contain %q, got %q", name, wantFragment, string(payload))
}
}
assertScopeEscalationDenied(
"explicit broader scope",
`{"name":"broader-token","scopes":["settings:write","monitoring:read"]}`,
`Cannot grant scope "monitoring:read"`,
)
assertScopeEscalationDenied(
"implicit wildcard scope",
`{"name":"wildcard-token"}`,
`Cannot grant scope "*"`,
)
listReq, err := http.NewRequest(http.MethodGet, srv.server.URL+"/api/security/tokens", nil)
if err != nil {
t.Fatalf("create list tokens request: %v", err)
}
listReq.Header.Set("X-CSRF-Token", csrfToken)
listResp, err := sessionClient.Do(listReq)
if err != nil {
t.Fatalf("list tokens request failed: %v", err)
}
defer listResp.Body.Close()
if listResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(listResp.Body)
t.Fatalf("expected 200 listing tokens, got %d: %s", listResp.StatusCode, string(body))
}
var listPayload struct {
Tokens []struct {
Name string `json:"name"`
} `json:"tokens"`
}
if err := json.NewDecoder(listResp.Body).Decode(&listPayload); err != nil {
t.Fatalf("decode list tokens response: %v", err)
}
if len(listPayload.Tokens) != 1 || listPayload.Tokens[0].Name != "limited-token" {
t.Fatalf("expected only limited token to remain after denied requests, got %+v", listPayload.Tokens)
}
}
func TestPublicURLDetectionUsesForwardedHeaders(t *testing.T) {
const apiToken = "public-url-detection-token-12345"
// Configure 127.0.0.1 as trusted proxy so X-Forwarded-* headers are read
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "127.0.0.1/32")
api.ResetTrustedProxyConfigForTests()
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
record, err := config.NewAPITokenRecord(apiToken, "Public URL detection test", nil)
if err != nil {
t.Fatalf("create API token record: %v", err)
}
cfg.APITokens = []config.APITokenRecord{*record}
cfg.SortAPITokens()
})
req, err := http.NewRequest(http.MethodGet, srv.server.URL+"/api/health", nil)
if err != nil {
t.Fatalf("failed to build request: %v", err)
}
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "pulse.example.com")
req.Header.Set("X-Forwarded-Port", "8443")
req.Header.Set("X-API-Token", apiToken)
res, err := srv.server.Client().Do(req)
if err != nil {
t.Fatalf("health request failed: %v", err)
}
res.Body.Close()
expected := "https://pulse.example.com:8443"
if got := srv.config.PublicURL; got != expected {
t.Fatalf("expected config public URL %q, got %q", expected, got)
}
if mgr := srv.monitor.GetNotificationManager(); mgr != nil {
if actual := mgr.GetPublicURL(); actual != expected {
t.Fatalf("expected notification manager public URL %q, got %q", expected, actual)
}
}
}
func TestPublicURLDetectionRespectsEnvOverride(t *testing.T) {
const overrideURL = "https://from-env.example.com"
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
cfg.PublicURL = overrideURL
cfg.EnvOverrides["publicURL"] = true
})
req, err := http.NewRequest(http.MethodGet, srv.server.URL+"/api/health", nil)
if err != nil {
t.Fatalf("failed to build request: %v", err)
}
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "ignored.example.org")
res, err := srv.server.Client().Do(req)
if err != nil {
t.Fatalf("health request failed: %v", err)
}
res.Body.Close()
if got := srv.config.PublicURL; got != overrideURL {
t.Fatalf("expected config public URL to remain %q, got %q", overrideURL, got)
}
if mgr := srv.monitor.GetNotificationManager(); mgr != nil {
if actual := mgr.GetPublicURL(); actual != overrideURL {
t.Fatalf("expected notification manager public URL %q, got %q", overrideURL, actual)
}
}
}
func readVersionFile(t *testing.T) string {
t.Helper()
versionPath := filepath.Join("..", "..", "VERSION")
data, err := os.ReadFile(versionPath)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func readRuntimeVersion(t *testing.T) string {
t.Helper()
info, err := updates.GetCurrentVersion()
if err != nil {
t.Fatalf("failed to determine current version: %v", err)
}
return strings.TrimSpace(info.Version)
}
func normalizeVersion(v string) string {
v = strings.TrimSpace(v)
v = strings.TrimPrefix(v, "v")
v = strings.TrimSuffix(v, "-dirty")
// Strip pre-release metadata (after '-')
if idx := strings.IndexByte(v, '-'); idx >= 0 {
v = v[:idx]
}
// Strip build metadata (after '+')
if idx := strings.IndexByte(v, '+'); idx >= 0 {
v = v[:idx]
}
return v
}