mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-22 11:10:33 +00:00
Remove local upgrade-metrics API registration, settings payload wiring, startup store migration, and backend conversion recorder hooks from the normal product runtime. Delete the retired conversion/funnel and metering packages from compiled licensing code, and extend diagnostics boundary audits and governance contracts so maintainer commercial analytics cannot return through Settings or diagnostics.
2242 lines
64 KiB
Go
2242 lines
64 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"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/recovery"
|
|
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
|
"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"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
type integrationServer struct {
|
|
server *httptest.Server
|
|
monitor *monitoring.Monitor
|
|
hub *internalws.Hub
|
|
config *config.Config
|
|
}
|
|
|
|
func wsHeadersForHTTP(t *testing.T, serverURL string) http.Header {
|
|
t.Helper()
|
|
|
|
origin, err := securityutil.HTTPOriginForWebSocketBaseURL(serverURL)
|
|
if err != nil {
|
|
t.Fatalf("failed to derive websocket origin: %v", err)
|
|
}
|
|
|
|
headers := http.Header{}
|
|
headers.Set("Origin", origin)
|
|
return headers
|
|
}
|
|
|
|
func newIntegrationServer(t *testing.T) *integrationServer {
|
|
return newIntegrationServerWithConfig(t, nil)
|
|
}
|
|
|
|
func newIntegrationServerWithConfig(t *testing.T, customize func(*config.Config)) *integrationServer {
|
|
t.Helper()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
ConfigPath: tmpDir,
|
|
DataPath: tmpDir,
|
|
DemoMode: false,
|
|
AllowedOrigins: "*",
|
|
EnvOverrides: make(map[string]bool),
|
|
}
|
|
|
|
return newIntegrationServerWithRuntimeMode(t, cfg, true, customize)
|
|
}
|
|
|
|
func newIntegrationServerWithoutMock(t *testing.T, customize func(*config.Config)) *integrationServer {
|
|
t.Helper()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
ConfigPath: tmpDir,
|
|
DataPath: tmpDir,
|
|
DemoMode: false,
|
|
AllowedOrigins: "*",
|
|
EnvOverrides: make(map[string]bool),
|
|
}
|
|
|
|
return newIntegrationServerWithRuntimeMode(t, cfg, false, customize)
|
|
}
|
|
|
|
func newIntegrationServerWithRuntimeMode(
|
|
t *testing.T,
|
|
cfg *config.Config,
|
|
mockMode bool,
|
|
customize func(*config.Config),
|
|
) *integrationServer {
|
|
t.Helper()
|
|
|
|
setMockModeForTest(t, mockMode)
|
|
|
|
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(mockMode)
|
|
|
|
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)
|
|
|
|
srv := newIPv4HTTPServer(t, router.Handler())
|
|
t.Cleanup(func() {
|
|
srv.Close()
|
|
if monitor != nil {
|
|
monitor.StopDiscoveryService()
|
|
monitor.Stop()
|
|
}
|
|
if hub != nil {
|
|
hub.Stop()
|
|
}
|
|
})
|
|
|
|
return &integrationServer{
|
|
server: srv,
|
|
monitor: monitor,
|
|
hub: hub,
|
|
config: cfg,
|
|
}
|
|
}
|
|
|
|
func TestRecoveryRollupsEndpointToleratesMalformedPersistedMetadata(t *testing.T) {
|
|
srv := newIntegrationServerWithoutMock(t, nil)
|
|
|
|
dbPath := seedRecoveryPointForIntegration(t, srv.config, recovery.RecoveryPoint{
|
|
ID: "router-rollup-bad-json",
|
|
Provider: recovery.ProviderTrueNAS,
|
|
Kind: recovery.KindSnapshot,
|
|
Mode: recovery.ModeSnapshot,
|
|
Outcome: recovery.OutcomeSuccess,
|
|
StartedAt: timePtrIntegration(time.Date(2026, 2, 22, 12, 0, 0, 0, time.UTC)),
|
|
CompletedAt: timePtrIntegration(time.Date(2026, 2, 22, 12, 0, 0, 0, time.UTC)),
|
|
SubjectRef: &recovery.ExternalRef{
|
|
Type: "truenas-dataset",
|
|
Name: "tank/apps",
|
|
ID: "tank/apps",
|
|
},
|
|
})
|
|
corruptRecoveryIntegrationRowJSON(t, dbPath, "router-rollup-bad-json", true, false, false)
|
|
|
|
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 []struct {
|
|
RollupID string `json:"rollupId"`
|
|
ItemRef map[string]any `json:"itemRef"`
|
|
SubjectRef map[string]any `json:"subjectRef"`
|
|
LastOutcome string `json:"lastOutcome"`
|
|
Display struct {
|
|
SubjectLabel string `json:"subjectLabel"`
|
|
ItemType string `json:"itemType"`
|
|
} `json:"display"`
|
|
} `json:"data"`
|
|
}
|
|
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) != 1 {
|
|
t.Fatalf("expected exactly 1 rollup, got %d body=%s", len(payload.Data), string(body))
|
|
}
|
|
rollup := payload.Data[0]
|
|
if rollup.RollupID == "" {
|
|
t.Fatalf("expected non-empty rollup id, body=%s", string(body))
|
|
}
|
|
if rollup.ItemRef != nil || rollup.SubjectRef != nil {
|
|
t.Fatalf("expected malformed item refs to be omitted, got itemRef=%#v subjectRef=%#v", rollup.ItemRef, rollup.SubjectRef)
|
|
}
|
|
if rollup.Display.SubjectLabel != "tank/apps" {
|
|
t.Fatalf("display.subjectLabel = %q, want %q", rollup.Display.SubjectLabel, "tank/apps")
|
|
}
|
|
if rollup.Display.ItemType != "dataset" {
|
|
t.Fatalf("display.itemType = %q, want %q", rollup.Display.ItemType, "dataset")
|
|
}
|
|
if rollup.LastOutcome != "success" {
|
|
t.Fatalf("lastOutcome = %q, want %q", rollup.LastOutcome, "success")
|
|
}
|
|
}
|
|
|
|
func TestRecoveryPointsEndpointToleratesMalformedPersistedMetadata(t *testing.T) {
|
|
srv := newIntegrationServerWithoutMock(t, nil)
|
|
|
|
dbPath := seedRecoveryPointForIntegration(t, srv.config, recovery.RecoveryPoint{
|
|
ID: "router-point-bad-json",
|
|
Provider: recovery.ProviderKubernetes,
|
|
Kind: recovery.KindSnapshot,
|
|
Mode: recovery.ModeSnapshot,
|
|
Outcome: recovery.OutcomeSuccess,
|
|
StartedAt: timePtrIntegration(time.Date(2026, 2, 23, 12, 0, 0, 0, time.UTC)),
|
|
CompletedAt: timePtrIntegration(time.Date(2026, 2, 23, 12, 0, 0, 0, time.UTC)),
|
|
SubjectRef: &recovery.ExternalRef{
|
|
Type: "k8s-pvc",
|
|
Namespace: "default",
|
|
Name: "data",
|
|
},
|
|
RepositoryRef: &recovery.ExternalRef{
|
|
Type: "velero-backup-storage-location",
|
|
Name: "repo-a",
|
|
},
|
|
Details: map[string]any{"foo": "bar"},
|
|
})
|
|
corruptRecoveryIntegrationRowJSON(t, dbPath, "router-point-bad-json", true, true, true)
|
|
|
|
res, err := http.Get(srv.server.URL + "/api/recovery/points?limit=500")
|
|
if err != nil {
|
|
t.Fatalf("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 {
|
|
ID string `json:"id"`
|
|
ItemRef map[string]any `json:"itemRef"`
|
|
SubjectRef map[string]any `json:"subjectRef"`
|
|
RepositoryRef map[string]any `json:"repositoryRef"`
|
|
Details map[string]any `json:"details"`
|
|
Display struct {
|
|
SubjectLabel string `json:"subjectLabel"`
|
|
ItemType string `json:"itemType"`
|
|
} `json:"display"`
|
|
} `json:"data"`
|
|
}
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatalf("read points response: %v", err)
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
t.Fatalf("unmarshal points response: %v", err)
|
|
}
|
|
if len(payload.Data) != 1 {
|
|
t.Fatalf("expected exactly 1 point, got %d body=%s", len(payload.Data), string(body))
|
|
}
|
|
point := payload.Data[0]
|
|
if point.ID != "router-point-bad-json" {
|
|
t.Fatalf("id = %q, want %q", point.ID, "router-point-bad-json")
|
|
}
|
|
if point.ItemRef != nil || point.SubjectRef != nil || point.RepositoryRef != nil || point.Details != nil {
|
|
t.Fatalf("expected malformed metadata fields to be omitted, got itemRef=%#v subjectRef=%#v repositoryRef=%#v details=%#v", point.ItemRef, point.SubjectRef, point.RepositoryRef, point.Details)
|
|
}
|
|
if point.Display.SubjectLabel != "default/data" {
|
|
t.Fatalf("display.subjectLabel = %q, want %q", point.Display.SubjectLabel, "default/data")
|
|
}
|
|
if point.Display.ItemType != "pvc" {
|
|
t.Fatalf("display.itemType = %q, want %q", point.Display.ItemType, "pvc")
|
|
}
|
|
}
|
|
|
|
func TestRecoveryPointsEndpointMigratesLegacySchemaMissingItemType(t *testing.T) {
|
|
srv := newIntegrationServerWithoutMock(t, nil)
|
|
|
|
dbPath := seedLegacyRecoveryPointWithoutItemTypeForIntegration(t, srv.config, recovery.RecoveryPoint{
|
|
ID: "legacy-point-no-item-type",
|
|
Provider: recovery.ProviderKubernetes,
|
|
Kind: recovery.KindSnapshot,
|
|
Mode: recovery.ModeSnapshot,
|
|
Outcome: recovery.OutcomeSuccess,
|
|
StartedAt: timePtrIntegration(time.Date(2026, 2, 24, 12, 0, 0, 0, time.UTC)),
|
|
CompletedAt: timePtrIntegration(time.Date(2026, 2, 24, 12, 0, 0, 0, time.UTC)),
|
|
SubjectRef: &recovery.ExternalRef{
|
|
Type: "k8s-pvc",
|
|
Namespace: "default",
|
|
Name: "data",
|
|
},
|
|
RepositoryRef: &recovery.ExternalRef{
|
|
Type: "velero-backup-storage-location",
|
|
Name: "repo-a",
|
|
},
|
|
Details: map[string]any{
|
|
"foo": "bar",
|
|
},
|
|
})
|
|
|
|
res, err := http.Get(srv.server.URL + "/api/recovery/points?limit=500")
|
|
if err != nil {
|
|
t.Fatalf("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))
|
|
}
|
|
|
|
assertRecoveryIntegrationColumnExists(t, dbPath, "item_type")
|
|
|
|
var payload struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
Display struct {
|
|
SubjectLabel string `json:"subjectLabel"`
|
|
ItemType string `json:"itemType"`
|
|
} `json:"display"`
|
|
} `json:"data"`
|
|
}
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatalf("read points response: %v", err)
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
t.Fatalf("unmarshal points response: %v", err)
|
|
}
|
|
if len(payload.Data) != 1 {
|
|
t.Fatalf("expected exactly 1 point, got %d body=%s", len(payload.Data), string(body))
|
|
}
|
|
if payload.Data[0].ID != "legacy-point-no-item-type" {
|
|
t.Fatalf("point id = %q, want %q", payload.Data[0].ID, "legacy-point-no-item-type")
|
|
}
|
|
if payload.Data[0].Display.SubjectLabel != "default/data" {
|
|
t.Fatalf("display.subjectLabel = %q, want %q", payload.Data[0].Display.SubjectLabel, "default/data")
|
|
}
|
|
if payload.Data[0].Display.ItemType != "pvc" {
|
|
t.Fatalf("display.itemType = %q, want %q", payload.Data[0].Display.ItemType, "pvc")
|
|
}
|
|
}
|
|
|
|
func TestRecoveryRollupsEndpointMigratesLegacySchemaMissingItemType(t *testing.T) {
|
|
srv := newIntegrationServerWithoutMock(t, nil)
|
|
|
|
dbPath := seedLegacyRecoveryPointWithoutItemTypeForIntegration(t, srv.config, recovery.RecoveryPoint{
|
|
ID: "legacy-rollup-no-item-type",
|
|
Provider: recovery.ProviderTrueNAS,
|
|
Kind: recovery.KindSnapshot,
|
|
Mode: recovery.ModeSnapshot,
|
|
Outcome: recovery.OutcomeSuccess,
|
|
StartedAt: timePtrIntegration(time.Date(2026, 2, 25, 12, 0, 0, 0, time.UTC)),
|
|
CompletedAt: timePtrIntegration(time.Date(2026, 2, 25, 12, 0, 0, 0, time.UTC)),
|
|
SubjectRef: &recovery.ExternalRef{
|
|
Type: "truenas-dataset",
|
|
Name: "tank/apps",
|
|
ID: "tank/apps",
|
|
},
|
|
})
|
|
|
|
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))
|
|
}
|
|
|
|
assertRecoveryIntegrationColumnExists(t, dbPath, "item_type")
|
|
|
|
var payload struct {
|
|
Data []struct {
|
|
RollupID string `json:"rollupId"`
|
|
Display struct {
|
|
SubjectLabel string `json:"subjectLabel"`
|
|
ItemType string `json:"itemType"`
|
|
} `json:"display"`
|
|
} `json:"data"`
|
|
}
|
|
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) != 1 {
|
|
t.Fatalf("expected exactly 1 rollup, got %d body=%s", len(payload.Data), string(body))
|
|
}
|
|
if payload.Data[0].RollupID == "" {
|
|
t.Fatalf("expected non-empty rollup id body=%s", string(body))
|
|
}
|
|
if payload.Data[0].Display.SubjectLabel != "tank/apps" {
|
|
t.Fatalf("display.subjectLabel = %q, want %q", payload.Data[0].Display.SubjectLabel, "tank/apps")
|
|
}
|
|
if payload.Data[0].Display.ItemType != "dataset" {
|
|
t.Fatalf("display.itemType = %q, want %q", payload.Data[0].Display.ItemType, "dataset")
|
|
}
|
|
}
|
|
|
|
func seedRecoveryPointForIntegration(t *testing.T, cfg *config.Config, point recovery.RecoveryPoint) string {
|
|
t.Helper()
|
|
|
|
mtp := config.NewMultiTenantPersistence(cfg.DataPath)
|
|
manager := recoverymanager.New(mtp)
|
|
store, err := manager.StoreForOrg("default")
|
|
if err != nil {
|
|
t.Fatalf("StoreForOrg(default): %v", err)
|
|
}
|
|
if err := store.UpsertPoints(context.Background(), []recovery.RecoveryPoint{point}); err != nil {
|
|
t.Fatalf("UpsertPoints(): %v", err)
|
|
}
|
|
|
|
return filepath.Join(cfg.DataPath, "recovery", "recovery.db")
|
|
}
|
|
|
|
func seedLegacyRecoveryPointWithoutItemTypeForIntegration(t *testing.T, cfg *config.Config, point recovery.RecoveryPoint) string {
|
|
t.Helper()
|
|
|
|
dbPath := filepath.Join(cfg.DataPath, "recovery", "recovery.db")
|
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil {
|
|
t.Fatalf("MkdirAll(recovery dir): %v", err)
|
|
}
|
|
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("sql.Open(%q): %v", dbPath, err)
|
|
}
|
|
defer db.Close()
|
|
|
|
schema := `
|
|
CREATE TABLE recovery_points (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
mode TEXT NOT NULL,
|
|
outcome TEXT NOT NULL,
|
|
started_at_ms INTEGER,
|
|
completed_at_ms INTEGER,
|
|
size_bytes INTEGER,
|
|
verified INTEGER,
|
|
encrypted INTEGER,
|
|
immutable INTEGER,
|
|
subject_key TEXT,
|
|
repository_key TEXT,
|
|
subject_resource_id TEXT,
|
|
repository_resource_id TEXT,
|
|
subject_ref_json TEXT,
|
|
repository_ref_json TEXT,
|
|
details_json TEXT,
|
|
subject_label TEXT,
|
|
subject_type TEXT,
|
|
is_workload INTEGER,
|
|
cluster_label TEXT,
|
|
node_host_label TEXT,
|
|
namespace_label TEXT,
|
|
entity_id_label TEXT,
|
|
repository_label TEXT,
|
|
details_summary TEXT,
|
|
created_at_ms INTEGER NOT NULL,
|
|
updated_at_ms INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE INDEX idx_recovery_points_completed
|
|
ON recovery_points(completed_at_ms);
|
|
|
|
CREATE INDEX idx_recovery_points_provider_completed
|
|
ON recovery_points(provider, completed_at_ms);
|
|
|
|
CREATE INDEX idx_recovery_points_subject_completed
|
|
ON recovery_points(subject_resource_id, completed_at_ms);
|
|
|
|
CREATE INDEX idx_recovery_points_subject_key_completed
|
|
ON recovery_points(subject_key, completed_at_ms);
|
|
|
|
CREATE INDEX idx_recovery_points_cluster_completed
|
|
ON recovery_points(cluster_label, completed_at_ms);
|
|
|
|
CREATE INDEX idx_recovery_points_node_completed
|
|
ON recovery_points(node_host_label, completed_at_ms);
|
|
|
|
CREATE INDEX idx_recovery_points_namespace_completed
|
|
ON recovery_points(namespace_label, completed_at_ms);
|
|
`
|
|
if _, err := db.Exec(schema); err != nil {
|
|
t.Fatalf("create legacy recovery schema: %v", err)
|
|
}
|
|
|
|
var (
|
|
subjectRefJSON sql.NullString
|
|
repositoryRefJSON sql.NullString
|
|
detailsJSON sql.NullString
|
|
sizeBytes sql.NullInt64
|
|
verified sql.NullInt64
|
|
encrypted sql.NullInt64
|
|
immutable sql.NullInt64
|
|
subjectRID sql.NullString
|
|
repositoryRID sql.NullString
|
|
startedAtMs sql.NullInt64
|
|
completedAtMs sql.NullInt64
|
|
)
|
|
|
|
if point.SubjectRef != nil {
|
|
data, err := json.Marshal(point.SubjectRef)
|
|
if err != nil {
|
|
t.Fatalf("marshal subject ref: %v", err)
|
|
}
|
|
subjectRefJSON = sql.NullString{String: string(data), Valid: true}
|
|
}
|
|
if point.RepositoryRef != nil {
|
|
data, err := json.Marshal(point.RepositoryRef)
|
|
if err != nil {
|
|
t.Fatalf("marshal repository ref: %v", err)
|
|
}
|
|
repositoryRefJSON = sql.NullString{String: string(data), Valid: true}
|
|
}
|
|
if len(point.Details) > 0 {
|
|
data, err := json.Marshal(point.Details)
|
|
if err != nil {
|
|
t.Fatalf("marshal details: %v", err)
|
|
}
|
|
detailsJSON = sql.NullString{String: string(data), Valid: true}
|
|
}
|
|
if point.SizeBytes != nil {
|
|
sizeBytes = sql.NullInt64{Int64: *point.SizeBytes, Valid: true}
|
|
}
|
|
if point.Verified != nil {
|
|
if *point.Verified {
|
|
verified = sql.NullInt64{Int64: 1, Valid: true}
|
|
} else {
|
|
verified = sql.NullInt64{Int64: 0, Valid: true}
|
|
}
|
|
}
|
|
if point.Encrypted != nil {
|
|
if *point.Encrypted {
|
|
encrypted = sql.NullInt64{Int64: 1, Valid: true}
|
|
} else {
|
|
encrypted = sql.NullInt64{Int64: 0, Valid: true}
|
|
}
|
|
}
|
|
if point.Immutable != nil {
|
|
if *point.Immutable {
|
|
immutable = sql.NullInt64{Int64: 1, Valid: true}
|
|
} else {
|
|
immutable = sql.NullInt64{Int64: 0, Valid: true}
|
|
}
|
|
}
|
|
if point.SubjectResourceID != "" {
|
|
subjectRID = sql.NullString{String: point.SubjectResourceID, Valid: true}
|
|
}
|
|
if point.RepositoryResourceID != "" {
|
|
repositoryRID = sql.NullString{String: point.RepositoryResourceID, Valid: true}
|
|
}
|
|
if point.StartedAt != nil {
|
|
startedAtMs = sql.NullInt64{Int64: point.StartedAt.UTC().UnixMilli(), Valid: true}
|
|
}
|
|
if point.CompletedAt != nil {
|
|
completedAtMs = sql.NullInt64{Int64: point.CompletedAt.UTC().UnixMilli(), Valid: true}
|
|
}
|
|
|
|
idx := recovery.DeriveIndex(point)
|
|
isWorkload := 0
|
|
if idx.IsWorkload {
|
|
isWorkload = 1
|
|
}
|
|
|
|
createdAtMs := time.Date(2026, 2, 26, 12, 0, 0, 0, time.UTC).UnixMilli()
|
|
updatedAtMs := createdAtMs
|
|
|
|
if _, err := db.ExecContext(context.Background(), `
|
|
INSERT INTO recovery_points (
|
|
id, provider, kind, mode, outcome,
|
|
started_at_ms, completed_at_ms, size_bytes,
|
|
verified, encrypted, immutable,
|
|
subject_key, repository_key,
|
|
subject_resource_id, repository_resource_id,
|
|
subject_ref_json, repository_ref_json, details_json,
|
|
subject_label, subject_type, is_workload,
|
|
cluster_label, node_host_label, namespace_label, entity_id_label,
|
|
repository_label, details_summary,
|
|
created_at_ms, updated_at_ms
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`,
|
|
point.ID,
|
|
string(point.Provider),
|
|
string(point.Kind),
|
|
string(point.Mode),
|
|
string(point.Outcome),
|
|
startedAtMs,
|
|
completedAtMs,
|
|
sizeBytes,
|
|
verified,
|
|
encrypted,
|
|
immutable,
|
|
recovery.SubjectKeyForPoint(point),
|
|
nil,
|
|
subjectRID,
|
|
repositoryRID,
|
|
subjectRefJSON,
|
|
repositoryRefJSON,
|
|
detailsJSON,
|
|
idx.SubjectLabel,
|
|
idx.SubjectType,
|
|
isWorkload,
|
|
idx.ClusterLabel,
|
|
idx.NodeHostLabel,
|
|
idx.NamespaceLabel,
|
|
idx.EntityIDLabel,
|
|
idx.RepositoryLabel,
|
|
idx.DetailsSummary,
|
|
createdAtMs,
|
|
updatedAtMs,
|
|
); err != nil {
|
|
t.Fatalf("insert legacy recovery point: %v", err)
|
|
}
|
|
|
|
return dbPath
|
|
}
|
|
|
|
func assertRecoveryIntegrationColumnExists(t *testing.T, dbPath string, column string) {
|
|
t.Helper()
|
|
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("sql.Open(%q): %v", dbPath, err)
|
|
}
|
|
defer db.Close()
|
|
|
|
rows, err := db.QueryContext(context.Background(), `PRAGMA table_info(recovery_points)`)
|
|
if err != nil {
|
|
t.Fatalf("PRAGMA table_info(recovery_points): %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var (
|
|
cid int
|
|
name string
|
|
colType string
|
|
notNull int
|
|
defaultVal sql.NullString
|
|
primaryKey int
|
|
)
|
|
if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultVal, &primaryKey); err != nil {
|
|
t.Fatalf("scan table_info row: %v", err)
|
|
}
|
|
if name == column {
|
|
return
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("table_info rows err: %v", err)
|
|
}
|
|
t.Fatalf("expected recovery_points column %q to exist after migration", column)
|
|
}
|
|
|
|
func corruptRecoveryIntegrationRowJSON(t *testing.T, dbPath string, rowID string, corruptSubject bool, corruptRepository bool, corruptDetails bool) {
|
|
t.Helper()
|
|
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("sql.Open(%q): %v", dbPath, err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
assignments := make([]string, 0, 3)
|
|
if corruptSubject {
|
|
assignments = append(assignments, "subject_ref_json = '{'")
|
|
}
|
|
if corruptRepository {
|
|
assignments = append(assignments, "repository_ref_json = '{'")
|
|
}
|
|
if corruptDetails {
|
|
assignments = append(assignments, "details_json = '{'")
|
|
}
|
|
if len(assignments) == 0 {
|
|
t.Fatal("corruptRecoveryIntegrationRowJSON called without any fields to corrupt")
|
|
}
|
|
|
|
query := "UPDATE recovery_points SET " + joinCSVIntegration(assignments) + " WHERE id = ?"
|
|
if _, err := db.ExecContext(context.Background(), query, rowID); err != nil {
|
|
t.Fatalf("corrupt recovery row json: %v", err)
|
|
}
|
|
}
|
|
|
|
func joinCSVIntegration(parts []string) string {
|
|
switch len(parts) {
|
|
case 0:
|
|
return ""
|
|
case 1:
|
|
return parts[0]
|
|
default:
|
|
out := parts[0]
|
|
for _, part := range parts[1:] {
|
|
out += ", " + part
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
func timePtrIntegration(t time.Time) *time.Time {
|
|
return &t
|
|
}
|
|
|
|
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["platforms"]
|
|
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 platforms, 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 {
|
|
Platform string `json:"platform"`
|
|
} `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.Platform {
|
|
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 TestMockModeToggleFromRealDataRebindsPlatformSupplementalProviders(t *testing.T) {
|
|
srv := newIntegrationServerWithoutMock(t, nil)
|
|
|
|
if mock.IsMockEnabled() {
|
|
t.Fatalf("mock mode should be disabled at start of test")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
checkResourceSource := func(path string, wantSource string) {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
res, err := http.Get(srv.server.URL + path)
|
|
if err != nil {
|
|
t.Fatalf("GET %s failed: %v", path, err)
|
|
}
|
|
|
|
var payload api.ResourcesResponse
|
|
decodeErr := json.NewDecoder(res.Body).Decode(&payload)
|
|
res.Body.Close()
|
|
if decodeErr != nil {
|
|
t.Fatalf("decode %s response: %v", path, decodeErr)
|
|
}
|
|
if res.StatusCode != http.StatusOK {
|
|
t.Fatalf("unexpected %s status: got %d want %d", path, res.StatusCode, http.StatusOK)
|
|
}
|
|
|
|
for _, resource := range payload.Data {
|
|
for _, source := range resource.Sources {
|
|
if string(source) == wantSource {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("expected %s resources after mock toggle, got %#v", wantSource, payload.Data)
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
checkResourceSource("/api/resources?source=truenas", "truenas")
|
|
checkResourceSource("/api/resources?source=vmware-vsphere", "vmware")
|
|
}
|
|
|
|
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, wsHeadersForHTTP(t, srv.server.URL))
|
|
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)
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
msgType, payload = readMsg()
|
|
if msgType == "rawData" {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("expected rawData broadcast before deadline, 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, wsHeadersForHTTP(t, srv.server.URL))
|
|
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, wsHeadersForHTTP(t, srv.server.URL))
|
|
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
|
|
}
|