mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-10 12:00:17 +00:00
553 lines
21 KiB
Go
553 lines
21 KiB
Go
// Package migration contains integration tests verifying v5→v6 migration safety
|
|
// for session/CSRF token continuity and SQLite database schema auto-migration.
|
|
package migration
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/api"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/audit"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// TestV5DataDir_SessionTokenContinuity verifies that sessions.json written by a
|
|
// v5 binary in the hashed format are correctly loaded by the v6 SessionStore,
|
|
// preserving all session data across the binary upgrade.
|
|
func TestV5DataDir_SessionTokenContinuity(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
|
|
// Simulate v5-written sessions.json in hashed (current) format.
|
|
// v5 writes sessions as a JSON array of objects with SHA256-hashed keys.
|
|
// The raw tokens "token-admin-v5", "token-viewer-v5", and "token-expired-v5"
|
|
// map to their respective SHA256 hashes.
|
|
adminToken := "token-admin-v5"
|
|
viewerToken := "token-viewer-v5"
|
|
expiredToken := "token-expired-v5"
|
|
adminHash := "100e59de5770d3d39661074377e2a1917a888664833ade104d8d9c53a0fbca7c" // sha256("token-admin-v5")
|
|
viewerHash := "9a3f360e528a5da0be0d0125fffe8c0184dda6a12a2fa44dc6c5b1c0f561cc1d" // sha256("token-viewer-v5")
|
|
futureExpiry := time.Now().Add(24 * time.Hour)
|
|
|
|
v5Sessions := []map[string]interface{}{
|
|
{
|
|
"key": adminHash,
|
|
"username": "admin",
|
|
"expires_at": futureExpiry.Format(time.RFC3339Nano),
|
|
"created_at": time.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), // 24h in nanoseconds
|
|
},
|
|
{
|
|
"key": viewerHash,
|
|
"username": "viewer",
|
|
"expires_at": futureExpiry.Format(time.RFC3339Nano),
|
|
"created_at": time.Now().Add(-2 * time.Hour).Format(time.RFC3339Nano),
|
|
"user_agent": "curl/7.88.1",
|
|
"ip": "10.0.0.5",
|
|
"original_duration": float64(3600000000000), // 1h in nanoseconds
|
|
},
|
|
{
|
|
// Expired session — should be filtered out during load.
|
|
// Uses sha256("token-expired-v5") so we can verify by raw token lookup.
|
|
"key": "fec6dfe256d34be31619a833ea19b2866838af691d19b9086c63570b80324ab0",
|
|
"username": "old-user",
|
|
"expires_at": time.Now().Add(-1 * time.Hour).Format(time.RFC3339Nano),
|
|
"created_at": time.Now().Add(-25 * time.Hour).Format(time.RFC3339Nano),
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(v5Sessions)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "sessions.json"), data, 0o600))
|
|
|
|
// v6 SessionStore loads sessions.json — sessions should survive
|
|
store := api.NewSessionStore(dataDir)
|
|
require.NotNil(t, store)
|
|
|
|
// Verify the v5 "admin" session was loaded and is accessible via the raw token.
|
|
// SessionStore hashes the raw token to look up the key in the map.
|
|
assert.True(t, store.ValidateSession(adminToken), "v5 admin session must be valid after v6 load")
|
|
adminSD := store.GetSession(adminToken)
|
|
require.NotNil(t, adminSD, "v5 admin session data must be retrievable by raw token")
|
|
assert.Equal(t, "admin", adminSD.Username)
|
|
assert.Equal(t, "Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0", adminSD.UserAgent)
|
|
assert.Equal(t, "192.168.1.100", adminSD.IP)
|
|
|
|
// Verify the v5 "viewer" session was loaded
|
|
assert.True(t, store.ValidateSession(viewerToken), "v5 viewer session must be valid after v6 load")
|
|
viewerSD := store.GetSession(viewerToken)
|
|
require.NotNil(t, viewerSD, "v5 viewer session data must be retrievable")
|
|
assert.Equal(t, "viewer", viewerSD.Username)
|
|
assert.Equal(t, "curl/7.88.1", viewerSD.UserAgent)
|
|
|
|
// Verify the expired session was filtered out during load — use the exact
|
|
// raw token whose hash is in the file, confirming load-time expiry filtering.
|
|
assert.False(t, store.ValidateSession(expiredToken), "expired session must be filtered out during load")
|
|
assert.Nil(t, store.GetSession(expiredToken), "expired session data must not be present")
|
|
|
|
// Verify sliding expiration works (v6 feature) on a v5-loaded session.
|
|
// The fixture sets OriginalDuration = 24h and ExpiresAt = now+24h (set at file
|
|
// write time), so ValidateAndExtendSession recalculates ExpiresAt = time.Now() + 24h
|
|
// which must be strictly after the original value (time has advanced since write).
|
|
beforeExtend := store.GetSession(adminToken).ExpiresAt
|
|
assert.True(t, store.ValidateAndExtendSession(adminToken), "sliding expiration must work on v5 session")
|
|
afterExtend := store.GetSession(adminToken).ExpiresAt
|
|
assert.True(t, afterExtend.After(beforeExtend),
|
|
"ExpiresAt must increase after ValidateAndExtendSession (was %v, now %v)", beforeExtend, afterExtend)
|
|
}
|
|
|
|
// TestV5DataDir_SessionLegacyMapFormat verifies that sessions.json in the legacy
|
|
// v5 map format (raw tokens as keys) is correctly loaded and migrated to the
|
|
// hashed format by v6.
|
|
func TestV5DataDir_SessionLegacyMapFormat(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
|
|
rawToken := "legacy-v5-session-token-plaintext"
|
|
futureExpiry := time.Now().Add(12 * time.Hour)
|
|
|
|
// Legacy v5 format: map[rawToken] -> SessionData
|
|
legacySessions := map[string]map[string]interface{}{
|
|
rawToken: {
|
|
"username": "legacyuser",
|
|
"expires_at": futureExpiry.Format(time.RFC3339Nano),
|
|
"created_at": time.Now().Add(-30 * time.Minute).Format(time.RFC3339Nano),
|
|
"user_agent": "Mozilla/5.0 legacy browser",
|
|
"ip": "192.168.0.50",
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(legacySessions)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "sessions.json"), data, 0o600))
|
|
|
|
// v6 SessionStore should load legacy format and migrate to hashed keys
|
|
store := api.NewSessionStore(dataDir)
|
|
require.NotNil(t, store)
|
|
|
|
// The legacy token should be valid — the store hashes it on load
|
|
assert.True(t, store.ValidateSession(rawToken), "legacy raw-token session must validate in v6")
|
|
|
|
sd := store.GetSession(rawToken)
|
|
require.NotNil(t, sd, "legacy session data must be retrievable")
|
|
assert.Equal(t, "legacyuser", sd.Username)
|
|
|
|
// Re-read the file and verify it's now immediately in the hashed (array) format,
|
|
// not legacy map format.
|
|
savedData, err := os.ReadFile(filepath.Join(dataDir, "sessions.json"))
|
|
require.NoError(t, err)
|
|
var hashedFormat []json.RawMessage
|
|
require.NoError(t, json.Unmarshal(savedData, &hashedFormat), "after save, sessions.json must be in hashed array format")
|
|
assert.Len(t, hashedFormat, 1, "saved file must contain the migrated legacy session only")
|
|
}
|
|
|
|
// TestV5DataDir_CSRFTokenFileContinuity verifies that csrf_tokens.json written
|
|
// by v5 in the hashed format is loadable by the v6 binary.
|
|
//
|
|
// NOTE: The CSRFTokenStore uses sync.Once for initialization, making it
|
|
// impossible to create isolated instances in tests. We verify format
|
|
// compatibility by confirming the v5 file deserializes into the v6
|
|
// CSRFTokenData type — the same struct used by CSRFTokenStore.load().
|
|
func TestV5DataDir_CSRFTokenFileContinuity(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
|
|
futureExpiry := time.Now().Add(4 * time.Hour)
|
|
|
|
// Write v5-format csrf_tokens.json (hashed format)
|
|
v5CSRFTokens := []map[string]interface{}{
|
|
{
|
|
"token_hash": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
|
"session_key": "session-key-hash-1",
|
|
"expires_at": futureExpiry.Format(time.RFC3339Nano),
|
|
},
|
|
{
|
|
"token_hash": "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5",
|
|
"session_key": "session-key-hash-2",
|
|
"expires_at": futureExpiry.Format(time.RFC3339Nano),
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(v5CSRFTokens)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "csrf_tokens.json"), data, 0o600))
|
|
|
|
// Verify the file is valid JSON that deserializes into the v6 CSRFTokenData format
|
|
fileData, err := os.ReadFile(filepath.Join(dataDir, "csrf_tokens.json"))
|
|
require.NoError(t, err)
|
|
|
|
var tokens []api.CSRFTokenData
|
|
require.NoError(t, json.Unmarshal(fileData, &tokens), "v5 csrf_tokens.json must unmarshal into v6 CSRFTokenData")
|
|
require.Len(t, tokens, 2)
|
|
|
|
assert.Equal(t, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", tokens[0].TokenHash)
|
|
assert.Equal(t, "session-key-hash-1", tokens[0].SessionKey)
|
|
assert.False(t, tokens[0].ExpiresAt.IsZero())
|
|
assert.True(t, tokens[0].ExpiresAt.After(time.Now()), "token should not be expired")
|
|
}
|
|
|
|
// TestV5DataDir_CSRFLegacyMapFormat verifies that csrf_tokens.json in the legacy
|
|
// v5 map format (raw tokens) deserializes correctly for the v6 migration path.
|
|
// See TestV5DataDir_CSRFTokenFileContinuity for the sync.Once limitation note.
|
|
func TestV5DataDir_CSRFLegacyMapFormat(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
|
|
futureExpiry := time.Now().Add(4 * time.Hour)
|
|
|
|
// Legacy format: map[sessionID] -> {token, session_id, expires_at}
|
|
legacyCSRF := map[string]map[string]interface{}{
|
|
"raw-session-id-1": {
|
|
"token": "raw-csrf-token-plaintext",
|
|
"session_id": "raw-session-id-1",
|
|
"expires_at": futureExpiry.Format(time.RFC3339Nano),
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(legacyCSRF)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "csrf_tokens.json"), data, 0o600))
|
|
|
|
// Verify the legacy format can be read (the current format unmarshal will fail,
|
|
// but the fallback legacy unmarshal should succeed)
|
|
fileData, err := os.ReadFile(filepath.Join(dataDir, "csrf_tokens.json"))
|
|
require.NoError(t, err)
|
|
|
|
// v6 load() tries hashed format first, then falls back to legacy map format.
|
|
// Verify the legacy format deserializes into the expected legacy struct shape.
|
|
type legacyCSRFEntry struct {
|
|
Token string `json:"token"`
|
|
SessionID string `json:"session_id"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
var legacyMap map[string]*legacyCSRFEntry
|
|
require.NoError(t, json.Unmarshal(fileData, &legacyMap), "legacy csrf_tokens.json must unmarshal as map[string]*legacyCSRFEntry")
|
|
require.Contains(t, legacyMap, "raw-session-id-1")
|
|
|
|
entry := legacyMap["raw-session-id-1"]
|
|
require.NotNil(t, entry)
|
|
assert.Equal(t, "raw-csrf-token-plaintext", entry.Token)
|
|
assert.Equal(t, "raw-session-id-1", entry.SessionID)
|
|
assert.False(t, entry.ExpiresAt.IsZero(), "expires_at must parse correctly")
|
|
}
|
|
|
|
// TestV5DataDir_MissingSessionFiles verifies that v6 starts cleanly when no
|
|
// sessions.json or csrf_tokens.json exist (fresh install or v5 install that
|
|
// never had active sessions persisted).
|
|
func TestV5DataDir_MissingSessionFiles(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
|
|
// No sessions.json or csrf_tokens.json — v6 should start cleanly
|
|
store := api.NewSessionStore(dataDir)
|
|
require.NotNil(t, store)
|
|
|
|
// Store should be empty but functional
|
|
assert.False(t, store.ValidateSession("nonexistent-token"))
|
|
|
|
// Creating sessions should work on a fresh store
|
|
store.CreateSession("fresh-token", time.Hour, "TestAgent", "127.0.0.1", "admin")
|
|
assert.True(t, store.ValidateSession("fresh-token"))
|
|
}
|
|
|
|
// TestV5DataDir_MetricsDBSchemaAutoMigration verifies that the v6 metrics store
|
|
// can open a v5-era metrics.db (with a minimal schema) and auto-migrate it by
|
|
// adding missing indexes and tables without losing existing data.
|
|
func TestV5DataDir_MetricsDBSchemaAutoMigration(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
dbPath := filepath.Join(dataDir, "metrics.db")
|
|
|
|
// Create a minimal v5-era metrics database with just the basic table
|
|
// (no unique index, no metrics_meta table — these are v6 additions).
|
|
dsn := dbPath + "?" + url.Values{
|
|
"_pragma": []string{
|
|
"busy_timeout(5000)",
|
|
"journal_mode(WAL)",
|
|
},
|
|
}.Encode()
|
|
rawDB, err := sql.Open("sqlite", dsn)
|
|
require.NoError(t, err)
|
|
|
|
// Create v5 schema (basic metrics table only, no unique index)
|
|
_, 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 v5-era test data
|
|
now := time.Now()
|
|
for i := 0; i < 5; i++ {
|
|
ts := now.Add(-time.Duration(i) * time.Minute).Unix()
|
|
_, err = rawDB.Exec(`INSERT INTO metrics (resource_type, resource_id, metric_type, value, timestamp, tier)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
"node", "pve-node-1", "cpu", 45.0+float64(i)*5.0, ts, "raw")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Verify v5 data was written
|
|
var count int
|
|
require.NoError(t, rawDB.QueryRow("SELECT COUNT(*) FROM metrics").Scan(&count))
|
|
assert.Equal(t, 5, count, "v5 metrics data should be present")
|
|
rawDB.Close()
|
|
|
|
// Now open with v6 metrics store — should auto-migrate schema
|
|
cfg := 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(cfg)
|
|
require.NoError(t, err, "v6 metrics store must open v5 database without error")
|
|
defer store.Close()
|
|
|
|
// Verify v5 data survived the schema migration
|
|
start := now.Add(-10 * time.Minute)
|
|
end := now.Add(time.Minute)
|
|
points, err := store.Query("node", "pve-node-1", "cpu", start, end, 0)
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, len(points), 5, "all v5 metric points must survive schema migration")
|
|
|
|
// Verify v6 can write new data to the migrated database synchronously.
|
|
// Use WriteBatchSync to bypass the async buffer and confirm the insert directly.
|
|
v6WriteTime := now.Add(10 * time.Minute)
|
|
store.WriteBatchSync([]metrics.WriteMetric{{
|
|
ResourceType: "node",
|
|
ResourceID: "pve-node-1",
|
|
MetricType: "cpu",
|
|
Value: 99.0,
|
|
Timestamp: v6WriteTime,
|
|
Tier: metrics.TierRaw,
|
|
}})
|
|
|
|
// Query again to verify new data was written
|
|
points2, err := store.Query("node", "pve-node-1", "cpu", start, v6WriteTime.Add(time.Minute), 0)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 6, len(points2), "v5 data (5) + v6 write (1) must all be present")
|
|
}
|
|
|
|
// TestV5DataDir_MetricsDBDuplicateDedup verifies that the v6 metrics store
|
|
// handles a v5 database containing duplicate metrics (which older versions
|
|
// could produce) by deduplicating and creating a unique index.
|
|
func TestV5DataDir_MetricsDBDuplicateDedup(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
dbPath := filepath.Join(dataDir, "metrics.db")
|
|
|
|
// Create v5-era database with intentional duplicates
|
|
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'
|
|
);
|
|
`)
|
|
require.NoError(t, err)
|
|
|
|
// Insert duplicate entries (same resource/metric/timestamp/tier)
|
|
ts := time.Now().Unix()
|
|
for i := 0; i < 3; i++ {
|
|
_, err = rawDB.Exec(`INSERT INTO metrics (resource_type, resource_id, metric_type, value, timestamp, tier)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
"vm", "vm-100", "memory", 72.5, ts, "raw")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
var count int
|
|
require.NoError(t, rawDB.QueryRow("SELECT COUNT(*) FROM metrics").Scan(&count))
|
|
assert.Equal(t, 3, count, "should have 3 duplicate rows before migration")
|
|
rawDB.Close()
|
|
|
|
// v6 store should detect duplicates, deduplicate, and create unique index
|
|
cfg := 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(cfg)
|
|
require.NoError(t, err, "v6 must open v5 database with duplicates and auto-deduplicate")
|
|
defer store.Close()
|
|
|
|
// After migration, only 1 copy should remain
|
|
start := time.Unix(ts-60, 0)
|
|
end := time.Unix(ts+60, 0)
|
|
points, err := store.Query("vm", "vm-100", "memory", start, end, 0)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, len(points), "duplicates must be deduplicated to 1 row")
|
|
}
|
|
|
|
// TestV5DataDir_AuditDBSchemaAutoMigration verifies that the v6 audit logger
|
|
// can create a new audit.db in a v5 data directory and that the schema includes
|
|
// all required tables and indexes for v6 operation.
|
|
func TestV5DataDir_AuditDBSchemaAutoMigration(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
|
|
// Create audit logger (creates audit.db with v6 schema)
|
|
logger, err := audit.NewSQLiteLogger(audit.SQLiteLoggerConfig{
|
|
DataDir: dataDir,
|
|
RetentionDays: 90,
|
|
})
|
|
require.NoError(t, err, "v6 audit logger must initialize on v5 data directory")
|
|
defer logger.Close()
|
|
|
|
// Log a test event to verify the schema works
|
|
event := audit.Event{
|
|
ID: "test-migration-event-1",
|
|
Timestamp: time.Now(),
|
|
EventType: "config_change",
|
|
User: "admin",
|
|
IP: "192.168.1.1",
|
|
Path: "/api/settings",
|
|
Success: true,
|
|
Details: "test migration verification",
|
|
}
|
|
require.NoError(t, logger.Log(event))
|
|
|
|
// Query the event back
|
|
events, err := logger.Query(audit.QueryFilter{ID: "test-migration-event-1"})
|
|
require.NoError(t, err)
|
|
require.Len(t, events, 1)
|
|
assert.Equal(t, "config_change", events[0].EventType)
|
|
assert.Equal(t, "admin", events[0].User)
|
|
assert.True(t, events[0].Success)
|
|
|
|
// Verify the schema_version table exists and has version 1
|
|
dbPath := filepath.Join(dataDir, "audit", "audit.db")
|
|
dsn := dbPath + "?" + url.Values{
|
|
"_pragma": []string{"busy_timeout(5000)"},
|
|
}.Encode()
|
|
db, err := sql.Open("sqlite", dsn)
|
|
require.NoError(t, err)
|
|
defer db.Close()
|
|
|
|
var version int
|
|
require.NoError(t, db.QueryRow("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").Scan(&version))
|
|
assert.Equal(t, 1, version, "schema_version should be 1")
|
|
}
|
|
|
|
// TestV5DataDir_AuditDBPreExistingData verifies that the v6 audit logger
|
|
// preserves events already in an audit.db created by v5.
|
|
func TestV5DataDir_AuditDBPreExistingData(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
auditDir := filepath.Join(dataDir, "audit")
|
|
require.NoError(t, os.MkdirAll(auditDir, 0o700))
|
|
dbPath := filepath.Join(auditDir, "audit.db")
|
|
|
|
// Create a v5-era audit database with some events
|
|
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 audit_events (
|
|
id TEXT PRIMARY KEY,
|
|
timestamp INTEGER NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
user TEXT,
|
|
ip TEXT,
|
|
path TEXT,
|
|
success INTEGER NOT NULL,
|
|
details TEXT,
|
|
signature TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS audit_config (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
`)
|
|
require.NoError(t, err)
|
|
|
|
// Insert v5 audit events
|
|
ts := time.Now().Unix()
|
|
_, err = rawDB.Exec(`INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
"v5-event-001", ts, "login", "admin", "192.168.1.1", "/api/auth/login", 1, "successful login", "v5-sig-placeholder")
|
|
require.NoError(t, err)
|
|
_, err = rawDB.Exec(`INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
"v5-event-002", ts-3600, "config_change", "admin", "10.0.0.1", "/api/settings", 1, "changed polling interval", "v5-sig-placeholder-2")
|
|
require.NoError(t, err)
|
|
rawDB.Close()
|
|
|
|
// Open with v6 audit logger — should auto-migrate (add schema_version table, indexes)
|
|
logger, err := audit.NewSQLiteLogger(audit.SQLiteLoggerConfig{
|
|
DataDir: dataDir,
|
|
RetentionDays: 90,
|
|
})
|
|
require.NoError(t, err, "v6 audit logger must open v5 audit.db without error")
|
|
defer logger.Close()
|
|
|
|
// Verify v5 events survived the migration
|
|
events, err := logger.Query(audit.QueryFilter{ID: "v5-event-001"})
|
|
require.NoError(t, err)
|
|
require.Len(t, events, 1, "v5 audit event must survive v6 schema migration")
|
|
assert.Equal(t, "login", events[0].EventType)
|
|
assert.Equal(t, "admin", events[0].User)
|
|
assert.True(t, events[0].Success)
|
|
|
|
events2, err := logger.Query(audit.QueryFilter{ID: "v5-event-002"})
|
|
require.NoError(t, err)
|
|
require.Len(t, events2, 1)
|
|
assert.Equal(t, "config_change", events2[0].EventType)
|
|
|
|
// Verify v6 can write new events alongside v5 data
|
|
newEvent := audit.Event{
|
|
ID: "v6-event-001",
|
|
Timestamp: time.Now(),
|
|
EventType: "logout",
|
|
User: "admin",
|
|
IP: "192.168.1.1",
|
|
Success: true,
|
|
Details: "v6 logout after migration",
|
|
}
|
|
require.NoError(t, logger.Log(newEvent))
|
|
|
|
// Verify both v5 and v6 events coexist
|
|
allEvents, err := logger.Query(audit.QueryFilter{Limit: 100})
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, len(allEvents), 3, "v5 + v6 events must coexist")
|
|
}
|