Pulse/tests/migration/v5_full_upgrade_test.go
2026-03-18 16:06:30 +00:00

853 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package migration contains integration tests verifying v5→v6 migration safety.
// This file implements the full upgrade scenario test required for L11 score 8:
// v5 fixture with realistic data → v6 binary startup → API health check →
// verify all resources accessible via API → verify license state preserved →
// verify no data loss.
package migration
import (
"context"
"database/sql"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"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"
internalws "github.com/rcourtman/pulse-go-rewrite/internal/websocket"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
_ "modernc.org/sqlite"
)
// buildRealisticV5DataDir creates a comprehensive v5 data directory with:
// - 3 PVE nodes, 1 PBS instance (encrypted nodes.enc)
// - AI config with patrol enabled (encrypted ai.enc)
// - 5 alert overrides for different resources (alerts.json)
// - System settings with custom polling intervals (system.json)
// - Metrics history database with multi-resource data (metrics.db)
// - Active session file (sessions.json)
//
// This represents a realistic v5 production installation that would be
// upgraded to v6.
func buildRealisticV5DataDir(t *testing.T) string {
t.Helper()
// Start with the standard v5 data dir (3 PVE + 1 PBS + AI + alerts + system)
dataDir, _, _, _, _ := buildV5DataDir(t)
// --- Add 5 alert overrides (expanding the 1 already in buildV5DataDir) ---
cp := config.NewConfigPersistence(dataDir)
alertCfg, err := cp.LoadAlertConfig()
require.NoError(t, err)
// Add 4 more overrides to the existing "vm-100" override
alertCfg.Overrides["vm-101"] = alerts.ThresholdConfig{
CPU: &alerts.HysteresisThreshold{Trigger: 70, Clear: 65},
Memory: &alerts.HysteresisThreshold{Trigger: 80, Clear: 75},
}
alertCfg.Overrides["vm-200"] = alerts.ThresholdConfig{
CPU: &alerts.HysteresisThreshold{Trigger: 60, Clear: 55},
Disk: &alerts.HysteresisThreshold{Trigger: 85, Clear: 80},
}
alertCfg.Overrides["ct-300"] = alerts.ThresholdConfig{
CPU: &alerts.HysteresisThreshold{Trigger: 90, Clear: 85},
Memory: &alerts.HysteresisThreshold{Trigger: 92, Clear: 87},
}
alertCfg.Overrides["node/pve-node-1"] = alerts.ThresholdConfig{
CPU: &alerts.HysteresisThreshold{Trigger: 75, Clear: 70},
Temperature: &alerts.HysteresisThreshold{Trigger: 70, Clear: 65},
}
alertsJSON, err := json.Marshal(alertCfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "alerts.json"), alertsJSON, 0o644))
// --- Add metrics.db with multi-resource history ---
dbPath := filepath.Join(dataDir, "metrics.db")
dsn := dbPath + "?" + url.Values{
"_pragma": []string{
"busy_timeout(5000)",
"journal_mode(WAL)",
},
}.Encode()
rawDB, err := sql.Open("sqlite", dsn)
require.NoError(t, err)
_, err = rawDB.Exec(`
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
metric_type TEXT NOT NULL,
value REAL NOT NULL,
min_value REAL,
max_value REAL,
timestamp INTEGER NOT NULL,
tier TEXT NOT NULL DEFAULT 'raw'
);
CREATE INDEX IF NOT EXISTS idx_metrics_lookup
ON metrics(resource_type, resource_id, metric_type, tier, timestamp);
`)
require.NoError(t, err)
// Insert realistic metrics: 3 nodes × cpu,memory + 2 VMs × cpu,memory = 10 series
now := time.Now()
resources := []struct {
resType, resID string
}{
{"node", "pve-node-1"},
{"node", "pve-node-2"},
{"node", "pve-node-3"},
{"vm", "vm-100"},
{"vm", "vm-101"},
}
metricTypes := []string{"cpu", "memory"}
totalInserted := 0
for _, res := range resources {
for _, mt := range metricTypes {
// 10 data points per series (spread over 10 minutes)
for i := 0; i < 10; i++ {
ts := now.Add(-time.Duration(i) * time.Minute).Unix()
val := 30.0 + float64(i)*3.0
if mt == "memory" {
val = 50.0 + float64(i)*2.0
}
_, err = rawDB.Exec(`INSERT INTO metrics (resource_type, resource_id, metric_type, value, timestamp, tier)
VALUES (?, ?, ?, ?, ?, ?)`, res.resType, res.resID, mt, val, ts, "raw")
require.NoError(t, err)
totalInserted++
}
}
}
rawDB.Close()
// --- Add session file (realism fixture — session continuity is tested
// separately in v5_session_db_test.go with proper raw-token↔hash mapping) ---
futureExpiry := now.Add(24 * time.Hour)
v5Sessions := []map[string]interface{}{
{
"key": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
"username": "admin",
"expires_at": futureExpiry.Format(time.RFC3339Nano),
"created_at": now.Add(-1 * time.Hour).Format(time.RFC3339Nano),
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0",
"ip": "192.168.1.100",
"original_duration": float64(86400000000000),
},
}
sessData, err := json.Marshal(v5Sessions)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "sessions.json"), sessData, 0o600))
return dataDir
}
// newMigrationTestServer creates an httptest server that mimics a v6 Pulse
// startup against the given data directory. It follows the same pattern as
// the integration server in internal/api/ but uses mock mode to avoid needing
// real Proxmox nodes.
//
// NOTE: api.NewRouter initializes process-wide session/CSRF stores via sync.Once,
// so only one test per binary can use this helper with a distinct data directory.
// This is acceptable because TestV5FullUpgradeScenario is the sole caller.
func newMigrationTestServer(t *testing.T, dataDir string) *httptest.Server {
t.Helper()
t.Setenv("PULSE_MOCK_MODE", "true")
mock.SetEnabled(true)
cfg := &config.Config{
ConfigPath: dataDir,
DataPath: dataDir,
DemoMode: false,
AllowedOrigins: "*",
EnvOverrides: make(map[string]bool),
}
var monitor *monitoring.Monitor
hub := internalws.NewHub(func(orgID string) interface{} {
if monitor == nil {
return models.StateSnapshot{}
}
return monitor.GetState().ToFrontend()
})
go hub.Run()
var err error
monitor, err = monitoring.New(cfg)
require.NoError(t, err, "monitor should initialize against v5 data dir")
monitor.SetMockMode(true)
hub.SetStateGetter(func(orgID string) interface{} {
return monitor.GetState().ToFrontend()
})
router := api.NewRouter(cfg, monitor, nil, hub, func() error {
monitor.SyncAlertState()
return nil
}, "6.0.0-test", nil)
ln, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Skipf("cannot listen on tcp4 loopback: %v", err)
}
srv := &httptest.Server{
Listener: ln,
Config: &http.Server{Handler: router.Handler()},
}
srv.Start()
t.Cleanup(func() {
srv.Close()
monitor.StopDiscoveryService()
monitor.Stop()
hub.Stop()
mock.SetEnabled(false)
})
return srv
}
// TestV5FullUpgradeScenario is the comprehensive L11 score 8 test.
// It builds a realistic v5 data directory, starts a full v6 API server against it,
// and verifies that all data is accessible via the API, preserves migrated
// license state, and incurs no data loss.
func TestV5FullUpgradeScenario(t *testing.T) {
dataDir := buildRealisticV5DataDir(t)
srv := newMigrationTestServer(t, dataDir)
// --- 1. Health check: v6 server must report healthy ---
t.Run("HealthCheck", func(t *testing.T) {
res, err := http.Get(srv.URL + "/api/health")
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
var payload map[string]interface{}
require.NoError(t, json.NewDecoder(res.Body).Decode(&payload))
assert.Equal(t, "healthy", payload["status"])
deps, ok := payload["dependencies"].(map[string]interface{})
require.True(t, ok, "health response must include dependencies")
assert.Equal(t, true, deps["monitor"])
assert.Equal(t, true, deps["websocket"])
})
// --- 2. Version: v6 server reports expected version ---
t.Run("VersionEndpoint", func(t *testing.T) {
res, err := http.Get(srv.URL + "/api/version")
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
var payload map[string]interface{}
require.NoError(t, json.NewDecoder(res.Body).Decode(&payload))
version, ok := payload["version"].(string)
require.True(t, ok)
assert.NotEmpty(t, version)
})
// --- 3. State: v6 mock mode responds with the canonical unified state shape ---
t.Run("StateEndpoint", func(t *testing.T) {
res, err := http.Get(srv.URL + "/api/state")
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
var state map[string]interface{}
require.NoError(t, json.Unmarshal(body, &state))
// v6 contract: legacy per-type arrays are intentionally stripped from
// /api/state in favor of the unified state frontend payload.
for _, key := range []string{"nodes", "vms", "containers", "dockerHosts", "hosts", "storage"} {
if _, ok := state[key]; ok {
t.Fatalf("expected %q to be omitted from /api/state payload", key)
}
}
// lastUpdate should be present (non-zero)
lastUpdate, ok := state["lastUpdate"].(float64)
require.True(t, ok, "state must include lastUpdate timestamp")
assert.Greater(t, lastUpdate, float64(0))
})
// --- 4. Alert config: v5 overrides preserved ---
t.Run("AlertConfigPreserved", func(t *testing.T) {
// Verify the alert config can be loaded via the config persistence layer
// (the /api/alerts endpoint requires auth, but we verify the underlying
// data survived by loading directly through ConfigPersistence)
cp := config.NewConfigPersistence(dataDir)
alertCfg, err := cp.LoadAlertConfig()
require.NoError(t, err)
assert.True(t, alertCfg.Enabled)
// Verify all 5 overrides survived
require.Len(t, alertCfg.Overrides, 5, "all 5 v5 alert overrides must survive")
require.Contains(t, alertCfg.Overrides, "vm-100")
require.Contains(t, alertCfg.Overrides, "vm-101")
require.Contains(t, alertCfg.Overrides, "vm-200")
require.Contains(t, alertCfg.Overrides, "ct-300")
require.Contains(t, alertCfg.Overrides, "node/pve-node-1")
// Verify specific threshold values
assert.Equal(t, float64(95), alertCfg.Overrides["vm-100"].CPU.Trigger)
assert.Equal(t, float64(70), alertCfg.Overrides["vm-101"].CPU.Trigger)
assert.Equal(t, float64(85), alertCfg.Overrides["vm-200"].Disk.Trigger)
assert.Equal(t, float64(92), alertCfg.Overrides["ct-300"].Memory.Trigger)
assert.Equal(t, float64(70), alertCfg.Overrides["node/pve-node-1"].Temperature.Trigger)
})
// --- 5. AI config: v5 settings preserved ---
t.Run("AIConfigPreserved", func(t *testing.T) {
cp := config.NewConfigPersistence(dataDir)
aiCfg, err := cp.LoadAIConfig()
require.NoError(t, err)
assert.True(t, aiCfg.Enabled)
assert.Equal(t, "anthropic:claude-3-5-sonnet-20241022", aiCfg.Model)
assert.Equal(t, "sk-ant-v5-test-key-placeholder", aiCfg.AnthropicAPIKey)
assert.True(t, aiCfg.PatrolEnabled)
assert.True(t, aiCfg.PatrolAnalyzeNodes)
assert.True(t, aiCfg.PatrolAnalyzeGuests)
assert.Equal(t, config.ControlLevelReadOnly, aiCfg.GetControlLevel())
assert.Equal(t, "3-node Proxmox cluster running production workloads", aiCfg.CustomContext)
})
// --- 6. Nodes config: all 3 PVE + 1 PBS survive ---
t.Run("NodesConfigPreserved", func(t *testing.T) {
cp := config.NewConfigPersistence(dataDir)
nodesCfg, err := cp.LoadNodesConfig()
require.NoError(t, err)
require.Len(t, nodesCfg.PVEInstances, 3)
require.Len(t, nodesCfg.PBSInstances, 1)
// Verify node details
assert.Equal(t, "pve-node-1", nodesCfg.PVEInstances[0].Name)
assert.Equal(t, "https://192.168.1.10:8006", nodesCfg.PVEInstances[0].Host)
assert.Equal(t, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", nodesCfg.PVEInstances[0].TokenValue)
assert.Equal(t, "pve-node-2", nodesCfg.PVEInstances[1].Name)
assert.Equal(t, "supersecret", nodesCfg.PVEInstances[1].Password)
assert.True(t, nodesCfg.PVEInstances[1].IsCluster)
assert.Equal(t, "dc1", nodesCfg.PVEInstances[1].ClusterName)
assert.Equal(t, "pve-node-3", nodesCfg.PVEInstances[2].Name)
assert.Equal(t, "pbs-backup-1", nodesCfg.PBSInstances[0].Name)
})
// --- 7. System settings: custom intervals preserved ---
t.Run("SystemSettingsPreserved", func(t *testing.T) {
cp := config.NewConfigPersistence(dataDir)
sysCfg, err := cp.LoadSystemSettings()
require.NoError(t, err)
require.NotNil(t, sysCfg)
assert.Equal(t, 10, sysCfg.PVEPollingInterval)
assert.Equal(t, 60, sysCfg.PBSPollingInterval)
assert.Equal(t, 60, sysCfg.PMGPollingInterval)
})
// --- 8. Metrics DB: v5 history data survives v6 schema migration ---
t.Run("MetricsHistorySurvives", func(t *testing.T) {
dbPath := filepath.Join(dataDir, "metrics.db")
storeCfg := metrics.StoreConfig{
DBPath: dbPath,
WriteBufferSize: 10,
FlushInterval: 1 * time.Second,
RetentionRaw: 2 * time.Hour,
RetentionMinute: 24 * time.Hour,
RetentionHourly: 7 * 24 * time.Hour,
RetentionDaily: 90 * 24 * time.Hour,
}
store, err := metrics.NewStore(storeCfg)
require.NoError(t, err)
defer store.Close()
start := time.Now().Add(-30 * time.Minute)
end := time.Now().Add(time.Minute)
// Verify all 5 resources × 2 metrics = 10 series survived
resources := []struct {
resType, resID string
}{
{"node", "pve-node-1"},
{"node", "pve-node-2"},
{"node", "pve-node-3"},
{"vm", "vm-100"},
{"vm", "vm-101"},
}
for _, res := range resources {
for _, mt := range []string{"cpu", "memory"} {
points, err := store.Query(res.resType, res.resID, mt, start, end, 0)
require.NoError(t, err, "query %s/%s/%s", res.resType, res.resID, mt)
assert.Equal(t, 10, len(points),
"all 10 data points for %s/%s/%s must survive migration",
res.resType, res.resID, mt)
}
}
// Verify v6 can write new data to the migrated database
store.WriteBatchSync([]metrics.WriteMetric{{
ResourceType: "node",
ResourceID: "pve-node-1",
MetricType: "cpu",
Value: 99.0,
Timestamp: time.Now().Add(10 * time.Minute),
Tier: metrics.TierRaw,
}})
points, err := store.Query("node", "pve-node-1", "cpu", start, time.Now().Add(15*time.Minute), 0)
require.NoError(t, err)
assert.Equal(t, 11, len(points), "10 v5 + 1 v6 data point")
})
// --- 9. Server starts without license file ---
t.Run("StartsWithoutLicenseFile", func(t *testing.T) {
// A v5 installation without a Pro license (no license.enc) must start
// without crashing. The server defaults to free tier in this case.
// Session continuity is tested separately in v5_session_db_test.go.
res, err := http.Get(srv.URL + "/api/health")
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode,
"server must start and report healthy without license.enc")
})
t.Run("PersistedV5LicenseAutoExchanges", func(t *testing.T) {
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
legacyLicense, err := pkglicensing.GenerateLicenseForTesting(
"legacy-lifetime@example.com",
pkglicensing.TierLifetime,
365*24*time.Hour,
)
require.NoError(t, err)
persistence, err := pkglicensing.NewPersistence(dataDir)
require.NoError(t, err)
require.NoError(t, persistence.Save(legacyLicense))
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
LicenseID: "lic_v5_migrated",
Tier: string(pkglicensing.TierLifetime),
PlanKey: "v5_lifetime_grandfathered",
State: "active",
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierLifetime]...),
MaxMonitoredSystems: pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierLifetime],
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
Email: "legacy-lifetime@example.com",
})
require.NoError(t, err)
pkglicensing.SetPublicKey(grantPublicKey)
t.Cleanup(func() { pkglicensing.SetPublicKey(nil) })
exchangeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/licenses/exchange", r.URL.Path)
var req pkglicensing.ExchangeLegacyLicenseRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, legacyLicense, req.LegacyLicenseKey)
w.WriteHeader(http.StatusCreated)
require.NoError(t, json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{
License: pkglicensing.ActivateResponseLicense{
LicenseID: "lic_v5_migrated",
State: "active",
Tier: string(pkglicensing.TierLifetime),
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierLifetime]...),
MaxMonitoredSystems: pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierLifetime],
},
Installation: pkglicensing.ActivateResponseInstallation{
InstallationID: "inst_v5_migrated",
InstallationToken: "pit_live_v5_migrated",
Status: "active",
},
Grant: pkglicensing.GrantEnvelope{
JWT: grantJWT,
JTI: "grant_v5_migrated",
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
},
}))
}))
defer exchangeServer.Close()
t.Setenv("PULSE_LICENSE_SERVER_URL", exchangeServer.URL)
mtp := config.NewMultiTenantPersistence(dataDir)
handlers := api.NewLicenseHandlers(mtp, false)
ctx := context.WithValue(context.Background(), api.OrgIDContextKey, "default")
svc := handlers.Service(ctx)
require.NotNil(t, svc)
require.True(t, svc.IsActivated(), "persisted v5 license must auto-exchange on v6 startup")
current := svc.Current()
require.NotNil(t, current)
assert.Equal(t, "lic_v5_migrated", current.Claims.LicenseID)
assert.Equal(t, pkglicensing.TierLifetime, current.Claims.Tier)
assert.Equal(t, "v5_lifetime_grandfathered", current.Claims.PlanVersion)
activationState, err := persistence.LoadActivationState()
require.NoError(t, err)
require.NotNil(t, activationState, "activation state must be persisted after exchange")
assert.Equal(t, "lic_v5_migrated", activationState.LicenseID)
legacyLeft, err := persistence.Load()
require.NoError(t, err)
assert.Equal(t, legacyLicense, legacyLeft, "legacy v5 license persistence must remain available for downgrade")
statusReq := httptest.NewRequest(http.MethodGet, "/api/license/status", nil).WithContext(ctx)
statusRec := httptest.NewRecorder()
handlers.HandleLicenseStatus(statusRec, statusReq)
require.Equal(t, http.StatusOK, statusRec.Code)
var status pkglicensing.LicenseStatus
require.NoError(t, json.Unmarshal(statusRec.Body.Bytes(), &status))
assert.True(t, status.Valid)
assert.True(t, status.IsLifetime)
assert.Equal(t, "v5_lifetime_grandfathered", status.PlanVersion)
entReq := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx)
entRec := httptest.NewRecorder()
handlers.HandleEntitlements(entRec, entReq)
require.Equal(t, http.StatusOK, entRec.Code)
var entitlements api.EntitlementPayload
require.NoError(t, json.Unmarshal(entRec.Body.Bytes(), &entitlements))
assert.Equal(t, "v5_lifetime_grandfathered", entitlements.PlanVersion)
assert.True(t, entitlements.IsLifetime)
assert.Equal(t, "active", entitlements.SubscriptionState)
handlers.StopAllBackgroundLoops()
})
t.Run("PersistedV5RecurringLicenseAutoExchanges", func(t *testing.T) {
tests := []struct {
name string
email string
licenseID string
planKey string
installID string
installToken string
grantJTI string
}{
{
name: "monthly grandfathered",
email: "legacy-monthly@example.com",
licenseID: "lic_v5_monthly_migrated",
planKey: "v5_pro_monthly_grandfathered",
installID: "inst_v5_monthly_migrated",
installToken: "pit_live_v5_monthly_migrated",
grantJTI: "grant_v5_monthly_migrated",
},
{
name: "annual grandfathered",
email: "legacy-annual@example.com",
licenseID: "lic_v5_annual_migrated",
planKey: "v5_pro_annual_grandfathered",
installID: "inst_v5_annual_migrated",
installToken: "pit_live_v5_annual_migrated",
grantJTI: "grant_v5_annual_migrated",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
legacyLicense, err := pkglicensing.GenerateLicenseForTesting(
tc.email,
pkglicensing.TierPro,
365*24*time.Hour,
)
require.NoError(t, err)
persistence, err := pkglicensing.NewPersistence(dataDir)
require.NoError(t, err)
require.NoError(t, persistence.Save(legacyLicense))
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
LicenseID: tc.licenseID,
Tier: string(pkglicensing.TierPro),
PlanKey: tc.planKey,
State: "active",
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...),
MaxMonitoredSystems: pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierPro],
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
Email: tc.email,
})
require.NoError(t, err)
pkglicensing.SetPublicKey(grantPublicKey)
t.Cleanup(func() { pkglicensing.SetPublicKey(nil) })
exchangeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/licenses/exchange", r.URL.Path)
var req pkglicensing.ExchangeLegacyLicenseRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, legacyLicense, req.LegacyLicenseKey)
w.WriteHeader(http.StatusCreated)
require.NoError(t, json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{
License: pkglicensing.ActivateResponseLicense{
LicenseID: tc.licenseID,
State: "active",
Tier: string(pkglicensing.TierPro),
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...),
MaxMonitoredSystems: pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierPro],
},
Installation: pkglicensing.ActivateResponseInstallation{
InstallationID: tc.installID,
InstallationToken: tc.installToken,
Status: "active",
},
Grant: pkglicensing.GrantEnvelope{
JWT: grantJWT,
JTI: tc.grantJTI,
ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339),
},
}))
}))
defer exchangeServer.Close()
t.Setenv("PULSE_LICENSE_SERVER_URL", exchangeServer.URL)
mtp := config.NewMultiTenantPersistence(dataDir)
handlers := api.NewLicenseHandlers(mtp, false)
ctx := context.WithValue(context.Background(), api.OrgIDContextKey, "default")
svc := handlers.Service(ctx)
require.NotNil(t, svc)
require.True(t, svc.IsActivated(), "persisted v5 license must auto-exchange on v6 startup")
current := svc.Current()
require.NotNil(t, current)
assert.Equal(t, tc.licenseID, current.Claims.LicenseID)
assert.Equal(t, pkglicensing.TierPro, current.Claims.Tier)
assert.Equal(t, tc.planKey, current.Claims.PlanVersion)
statusReq := httptest.NewRequest(http.MethodGet, "/api/license/status", nil).WithContext(ctx)
statusRec := httptest.NewRecorder()
handlers.HandleLicenseStatus(statusRec, statusReq)
require.Equal(t, http.StatusOK, statusRec.Code)
var status pkglicensing.LicenseStatus
require.NoError(t, json.Unmarshal(statusRec.Body.Bytes(), &status))
assert.True(t, status.Valid)
assert.False(t, status.IsLifetime)
assert.Equal(t, tc.planKey, status.PlanVersion)
entReq := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx)
entRec := httptest.NewRecorder()
handlers.HandleEntitlements(entRec, entReq)
require.Equal(t, http.StatusOK, entRec.Code)
var entitlements api.EntitlementPayload
require.NoError(t, json.Unmarshal(entRec.Body.Bytes(), &entitlements))
assert.Equal(t, tc.planKey, entitlements.PlanVersion)
assert.False(t, entitlements.IsLifetime)
assert.Equal(t, "active", entitlements.SubscriptionState)
handlers.StopAllBackgroundLoops()
})
}
})
}
// TestV5DowngradeSafety verifies what happens when a v5-compatible config
// loader reads data that was potentially modified by v6. This documents the
// downgrade path: if a user reverts to the v5 binary after running v6 briefly.
//
// Key guarantees:
// 1. Encrypted config files (nodes.enc, ai.enc) remain readable by v5
// because the encryption format (AES-GCM) and key file (.encryption.key)
// are unchanged between v5 and v6.
// 2. JSON config files (alerts.json, system.json) that gain new v6 fields
// are still loadable by v5 because Go's json.Unmarshal ignores unknown fields.
// 3. SQLite databases (metrics.db) with new indexes/tables are still readable
// because SQLite is forward-compatible (unknown indexes don't break reads).
// 4. Session files may be in the hashed array format which v5 also supports.
func TestV5DowngradeSafety(t *testing.T) {
dataDir := buildRealisticV5DataDir(t)
// Simulate v6 startup by loading and re-saving configs through v6 code.
// This may add new fields or reformat files.
cp := config.NewConfigPersistence(dataDir)
// --- Load via v6, modify, re-save (simulates v6 modifying config) ---
// Modify and re-save nodes.enc
nodesCfg, err := cp.LoadNodesConfig()
require.NoError(t, err)
nodesCfg.PVEInstances = append(nodesCfg.PVEInstances, config.PVEInstance{
Name: "pve-node-4-v6",
Host: "https://10.0.0.200:8006",
User: "root@pam",
TokenName: "pulse-v6",
TokenValue: "v6-only-token",
VerifySSL: true,
MonitorVMs: true,
})
err = cp.SaveNodesConfig(nodesCfg.PVEInstances, nodesCfg.PBSInstances, nodesCfg.PMGInstances)
require.NoError(t, err)
// Modify and re-save ai.enc (simulates v6 adding a new field value)
aiCfg, err := cp.LoadAIConfig()
require.NoError(t, err)
aiCfg.ChatModel = "anthropic:claude-3-5-haiku-20241022" // v6-only field
err = cp.SaveAIConfig(*aiCfg)
require.NoError(t, err)
// --- Downgrade scenario: v5-compatible loader reads v6-modified data ---
t.Run("EncryptedNodesReadableAfterV6Write", func(t *testing.T) {
// A fresh ConfigPersistence (simulating v5 binary) should be able to
// read the re-encrypted nodes.enc. The encryption format is identical.
cp2 := config.NewConfigPersistence(dataDir)
nodesCfg2, err := cp2.LoadNodesConfig()
require.NoError(t, err)
// All 4 nodes (3 original + 1 v6-added) should be present
require.Len(t, nodesCfg2.PVEInstances, 4)
assert.Equal(t, "pve-node-1", nodesCfg2.PVEInstances[0].Name)
assert.Equal(t, "pve-node-4-v6", nodesCfg2.PVEInstances[3].Name)
// PBS should also survive
require.Len(t, nodesCfg2.PBSInstances, 1)
assert.Equal(t, "pbs-backup-1", nodesCfg2.PBSInstances[0].Name)
})
t.Run("EncryptedAIConfigReadableAfterV6Write", func(t *testing.T) {
// ai.enc was modified and re-saved above (v6 added ChatModel).
// A v5 loader (fresh ConfigPersistence) must be able to decrypt and
// read the re-encrypted file. v5 will ignore the unknown ChatModel
// field but all original fields must survive.
cp2 := config.NewConfigPersistence(dataDir)
aiCfg2, err := cp2.LoadAIConfig()
require.NoError(t, err)
// Original v5 fields must survive the v6 re-encryption
assert.True(t, aiCfg2.Enabled)
assert.Equal(t, "anthropic:claude-3-5-sonnet-20241022", aiCfg2.Model)
assert.Equal(t, "sk-ant-v5-test-key-placeholder", aiCfg2.AnthropicAPIKey)
assert.True(t, aiCfg2.PatrolEnabled)
// v6-added field must also be present (proves the save roundtripped)
assert.Equal(t, "anthropic:claude-3-5-haiku-20241022", aiCfg2.ChatModel)
})
t.Run("JSONConfigUnknownFieldsIgnored", func(t *testing.T) {
// Simulate v6 adding new fields to alerts.json that v5 doesn't know about.
// Read the current alerts.json, add a hypothetical v6-only field, write it back.
alertsPath := filepath.Join(dataDir, "alerts.json")
raw, err := os.ReadFile(alertsPath)
require.NoError(t, err)
var alertMap map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &alertMap))
// Add a hypothetical v6-only field
alertMap["v6_advanced_correlations"] = true
alertMap["v6_ml_threshold_tuning"] = map[string]interface{}{
"enabled": true,
"confidence": 0.95,
}
modified, err := json.Marshal(alertMap)
require.NoError(t, err)
require.NoError(t, os.WriteFile(alertsPath, modified, 0o644))
// v5-compatible loader (same Go unmarshal) should ignore unknown fields
cp3 := config.NewConfigPersistence(dataDir)
alertCfg, err := cp3.LoadAlertConfig()
require.NoError(t, err)
// Known fields must survive
assert.True(t, alertCfg.Enabled)
require.Contains(t, alertCfg.Overrides, "vm-100")
assert.Equal(t, float64(95), alertCfg.Overrides["vm-100"].CPU.Trigger)
// All 5 original overrides must be present
assert.Len(t, alertCfg.Overrides, 5)
})
t.Run("MetricsDBReadableWithNewIndexes", func(t *testing.T) {
dbPath := filepath.Join(dataDir, "metrics.db")
// Open with v6 metrics store (adds unique index, metrics_meta table)
storeCfg := metrics.StoreConfig{
DBPath: dbPath,
WriteBufferSize: 10,
FlushInterval: 1 * time.Second,
RetentionRaw: 2 * time.Hour,
RetentionMinute: 24 * time.Hour,
RetentionHourly: 7 * 24 * time.Hour,
RetentionDaily: 90 * 24 * time.Hour,
}
store, err := metrics.NewStore(storeCfg)
require.NoError(t, err)
store.Close()
// Now open with raw SQLite (simulating a v5 binary that uses basic queries)
dsn := dbPath + "?" + url.Values{
"_pragma": []string{"busy_timeout(5000)"},
}.Encode()
rawDB, err := sql.Open("sqlite", dsn)
require.NoError(t, err)
defer rawDB.Close()
// v5-style query should still work despite v6 schema additions
var count int
err = rawDB.QueryRow(`SELECT COUNT(*) FROM metrics WHERE resource_type = ? AND resource_id = ?`,
"node", "pve-node-1").Scan(&count)
require.NoError(t, err)
assert.GreaterOrEqual(t, count, 10, "v5-style query must find v5 data in v6-migrated DB")
})
t.Run("SystemSettingsNewFieldsIgnored", func(t *testing.T) {
// Add hypothetical v6-only fields to system.json
sysPath := filepath.Join(dataDir, "system.json")
raw, err := os.ReadFile(sysPath)
require.NoError(t, err)
var sysMap map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &sysMap))
sysMap["v6_telemetry_enabled"] = false
sysMap["v6_resource_grouping"] = "auto"
modified, err := json.Marshal(sysMap)
require.NoError(t, err)
require.NoError(t, os.WriteFile(sysPath, modified, 0o644))
// v5-compatible loader should ignore unknown fields
cp4 := config.NewConfigPersistence(dataDir)
sysCfg, err := cp4.LoadSystemSettings()
require.NoError(t, err)
require.NotNil(t, sysCfg)
// Known v5 fields must survive
assert.Equal(t, 10, sysCfg.PVEPollingInterval)
assert.Equal(t, 60, sysCfg.PBSPollingInterval)
})
}