Pulse/internal/unifiedresources/store_test.go

1404 lines
43 KiB
Go

package unifiedresources
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestSanitizeOrgID_AllowsSafeChars(t *testing.T) {
in := "Acme_Org-123"
if got := sanitizeOrgID(in); got != in {
t.Fatalf("sanitizeOrgID(%q) = %q, want %q", in, got, in)
}
}
func TestSanitizeOrgID_StripsUnsafeCharsAndBoundsLength(t *testing.T) {
in := "../../../../tenant?mode=memory&_pragma=trusted_schema(OFF)#frag"
got := sanitizeOrgID(in)
if got == "" {
t.Fatal("expected non-empty sanitized org ID")
}
if len(got) > maxOrgIDLength {
t.Fatalf("sanitizeOrgID length = %d, want <= %d", len(got), maxOrgIDLength)
}
if strings.ContainsAny(got, "/\\?&=#. \t\r\n") {
t.Fatalf("sanitizeOrgID produced unsafe characters: %q", got)
}
}
func TestSanitizeOrgID_AllUnsafeInputReturnsEmpty(t *testing.T) {
if got := sanitizeOrgID("../??//.. "); got != "" {
t.Fatalf("sanitizeOrgID returned %q, want empty string", got)
}
}
func TestNewSQLiteResourceStore_DefaultOrgUsesSharedResourcesPath(t *testing.T) {
dataDir := t.TempDir()
store, err := NewSQLiteResourceStore(dataDir, "default")
if err != nil {
t.Fatalf("NewSQLiteResourceStore returned error: %v", err)
}
defer store.Close()
wantPath := filepath.Join(dataDir, "resources", "unified_resources.db")
if store.dbPath != wantPath {
t.Fatalf("db path = %q, want %q", store.dbPath, wantPath)
}
}
func TestNewSQLiteResourceStore_NonDefaultOrgUsesTenantScopedPath(t *testing.T) {
dataDir := t.TempDir()
store, err := NewSQLiteResourceStore(dataDir, "org-a")
if err != nil {
t.Fatalf("NewSQLiteResourceStore returned error: %v", err)
}
defer store.Close()
wantPath := filepath.Join(dataDir, "orgs", "org-a", "resources", "unified_resources.db")
if store.dbPath != wantPath {
t.Fatalf("db path = %q, want %q", store.dbPath, wantPath)
}
}
func TestNewSQLiteResourceStore_OrgDotAndUnderscoreDoNotCollide(t *testing.T) {
dataDir := t.TempDir()
dotStore, err := NewSQLiteResourceStore(dataDir, "org.a")
if err != nil {
t.Fatalf("NewSQLiteResourceStore(org.a) returned error: %v", err)
}
defer dotStore.Close()
underscoreStore, err := NewSQLiteResourceStore(dataDir, "org_a")
if err != nil {
t.Fatalf("NewSQLiteResourceStore(org_a) returned error: %v", err)
}
defer underscoreStore.Close()
if dotStore.dbPath == underscoreStore.dbPath {
t.Fatalf("db path collision: org.a and org_a both mapped to %q", dotStore.dbPath)
}
}
func TestNewSQLiteResourceStore_RejectsInvalidOrgID(t *testing.T) {
dataDir := t.TempDir()
if _, err := NewSQLiteResourceStore(dataDir, "../bad-org"); err == nil {
t.Fatal("expected invalid org ID error, got nil")
}
}
func TestNewSQLiteResourceStore_MigratesLegacyStore(t *testing.T) {
dataDir := t.TempDir()
orgID := "org.a"
legacyPath := filepath.Join(dataDir, "resources", legacyResourceStoreFileName(orgID))
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil {
t.Fatalf("MkdirAll(%q) failed: %v", filepath.Dir(legacyPath), err)
}
seedLegacyLinksTable(t, legacyPath)
store, err := NewSQLiteResourceStore(dataDir, orgID)
if err != nil {
t.Fatalf("NewSQLiteResourceStore returned error: %v", err)
}
defer store.Close()
links, err := store.GetLinks()
if err != nil {
t.Fatalf("GetLinks returned error: %v", err)
}
if len(links) != 1 {
t.Fatalf("GetLinks length = %d, want 1", len(links))
}
if links[0].ResourceA != "legacy-a" || links[0].ResourceB != "legacy-b" {
t.Fatalf("unexpected migrated link: %+v", links[0])
}
if _, err := os.Stat(legacyPath); err != nil {
t.Fatalf("legacy db should remain for compatibility, stat(%q) failed: %v", legacyPath, err)
}
if store.dbPath == legacyPath {
t.Fatalf("expected migrated store path to differ from legacy path: %q", store.dbPath)
}
}
func TestNewSQLiteResourceStore_MigratesLegacyResourceChangesTable(t *testing.T) {
dataDir := t.TempDir()
legacyPath := filepath.Join(dataDir, "resources", resourceDBFileName)
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil {
t.Fatalf("MkdirAll(%q) failed: %v", filepath.Dir(legacyPath), err)
}
db, err := sql.Open("sqlite", legacyPath)
if err != nil {
t.Fatalf("sql.Open(%q) failed: %v", legacyPath, err)
}
if _, err := db.Exec(`
CREATE TABLE resource_changes (
id TEXT PRIMARY KEY,
canonical_id TEXT NOT NULL,
timestamp DATETIME NOT NULL,
kind TEXT NOT NULL,
from_state TEXT,
to_state TEXT,
source TEXT,
confidence TEXT NOT NULL,
reason TEXT
)
`); err != nil {
_ = db.Close()
t.Fatalf("create legacy resource_changes table failed: %v", err)
}
if _, err := db.Exec(`
INSERT INTO resource_changes (
id, canonical_id, timestamp, kind, from_state, to_state, source, confidence, reason
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "chg-legacy", "vm:legacy", time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC), string(ChangeStateTransition), "offline", "online", "proxmox", string(ConfidenceHigh), "legacy row"); err != nil {
_ = db.Close()
t.Fatalf("insert legacy resource change failed: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close legacy db failed: %v", err)
}
store, err := NewSQLiteResourceStore(dataDir, defaultOrgID)
if err != nil {
t.Fatalf("NewSQLiteResourceStore returned error: %v", err)
}
defer store.Close()
results, err := store.GetRecentChanges("vm:legacy", time.Time{}, 10)
if err != nil {
t.Fatalf("GetRecentChanges on migrated legacy table returned error: %v", err)
}
if len(results) != 1 {
t.Fatalf("GetRecentChanges on migrated legacy table returned %d rows, want 1", len(results))
}
if results[0].ID != "chg-legacy" {
t.Fatalf("unexpected legacy row after migration: %+v", results[0])
}
if results[0].SourceType != SourcePulseDiff {
t.Fatalf("legacy source type = %q, want %q", results[0].SourceType, SourcePulseDiff)
}
if results[0].SourceAdapter != ChangeSourceAdapter("proxmox") {
t.Fatalf("legacy source adapter = %q, want proxmox", results[0].SourceAdapter)
}
if !results[0].ObservedAt.Equal(time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)) {
t.Fatalf("legacy observed_at = %v, want 2026-03-18T12:00:00Z", results[0].ObservedAt)
}
if results[0].OccurredAt != nil {
t.Fatalf("legacy occurred_at = %v, want nil", results[0].OccurredAt)
}
if err := store.RecordChange(ResourceChange{
ID: "chg-new",
ResourceID: "vm:legacy",
ObservedAt: time.Date(2026, 3, 18, 13, 0, 0, 0, time.UTC),
Kind: ChangeRestart,
SourceType: SourcePlatformEvent,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceHigh,
Reason: "post-migration write",
}); err != nil {
t.Fatalf("RecordChange after migration failed: %v", err)
}
results, err = store.GetRecentChanges("vm:legacy", time.Time{}, 10)
if err != nil {
t.Fatalf("GetRecentChanges after migration write returned error: %v", err)
}
if len(results) != 2 {
t.Fatalf("GetRecentChanges after migration write returned %d rows, want 2", len(results))
}
columns, err := resourceChangeColumns(store.db)
if err != nil {
t.Fatalf("resourceChangeColumns: %v", err)
}
for _, want := range []string{"observed_at", "occurred_at", "source_type", "source_adapter", "actor", "related_resources", "metadata_json"} {
if _, ok := columns[want]; !ok {
t.Fatalf("expected migrated resource_changes column %q, got %#v", want, columns)
}
}
indexes, err := resourceChangesIndexes(store.db)
if err != nil {
t.Fatalf("resourceChangesIndexes: %v", err)
}
for _, want := range []string{
"idx_resource_changes_canonical_time",
"idx_resource_changes_kind_time",
"idx_resource_changes_source_type_time",
"idx_resource_changes_source_adapter_time",
} {
if _, ok := indexes[want]; !ok {
t.Fatalf("expected migrated resource_changes index %q, got %#v", want, indexes)
}
}
}
func TestNewSQLiteResourceStore_InitializesCanonicalResourceChangesSchema(t *testing.T) {
dataDir := t.TempDir()
store, err := NewSQLiteResourceStore(dataDir, defaultOrgID)
if err != nil {
t.Fatalf("NewSQLiteResourceStore returned error: %v", err)
}
defer store.Close()
columns, err := resourceChangeColumns(store.db)
if err != nil {
t.Fatalf("resourceChangeColumns: %v", err)
}
if _, ok := columns["timestamp"]; ok {
t.Fatalf("fresh resource_changes schema unexpectedly contains legacy timestamp column: %#v", columns)
}
for _, want := range []string{"observed_at", "occurred_at", "source_type", "source_adapter", "actor", "related_resources", "metadata_json"} {
if _, ok := columns[want]; !ok {
t.Fatalf("expected canonical resource_changes column %q, got %#v", want, columns)
}
}
change := ResourceChange{
ID: "chg-fresh",
ResourceID: "vm:fresh",
ObservedAt: time.Date(2026, 3, 18, 14, 0, 0, 0, time.UTC),
Kind: ChangeRestart,
SourceType: SourcePlatformEvent,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceHigh,
Reason: "fresh schema write",
}
if err := store.RecordChange(change); err != nil {
t.Fatalf("RecordChange on fresh schema failed: %v", err)
}
results, err := store.GetRecentChanges("vm:fresh", time.Time{}, 10)
if err != nil {
t.Fatalf("GetRecentChanges on fresh schema returned error: %v", err)
}
if len(results) != 1 {
t.Fatalf("GetRecentChanges on fresh schema returned %d rows, want 1", len(results))
}
if results[0].ID != change.ID {
t.Fatalf("unexpected fresh row after write: %+v", results[0])
}
if !results[0].ObservedAt.Equal(change.ObservedAt) {
t.Fatalf("fresh observed_at = %v, want %v", results[0].ObservedAt, change.ObservedAt)
}
}
func TestNewSQLiteResourceStore_InitializesCanonicalAuditSchemas(t *testing.T) {
dataDir := t.TempDir()
store, err := NewSQLiteResourceStore(dataDir, defaultOrgID)
if err != nil {
t.Fatalf("NewSQLiteResourceStore returned error: %v", err)
}
defer store.Close()
auditTables := []struct {
name string
columns []string
indexes []string
}{
{
name: "action_audits",
columns: []string{
"id", "action_id", "canonical_id", "request_id", "created_at", "updated_at",
"state", "request_json", "plan_json", "approvals_json", "result_json",
},
indexes: []string{
"idx_action_audits_canonical_created",
"idx_action_audits_action_id",
},
},
{
name: "action_lifecycle_events",
columns: []string{"id", "action_id", "timestamp", "state", "actor", "message"},
indexes: []string{"idx_action_lifecycle_events_action"},
},
{
name: "export_audits",
columns: []string{"id", "timestamp", "actor", "envelope_hash", "decision", "destination", "redactions_json"},
indexes: []string{"idx_export_audits_timestamp"},
},
}
for _, tt := range auditTables {
columns, err := tableColumns(store.db, tt.name)
if err != nil {
t.Fatalf("tableColumns(%q): %v", tt.name, err)
}
for _, want := range tt.columns {
if _, ok := columns[want]; !ok {
t.Fatalf("expected %s column %q, got %#v", tt.name, want, columns)
}
}
indexes, err := tableIndexes(store.db, tt.name)
if err != nil {
t.Fatalf("tableIndexes(%q): %v", tt.name, err)
}
for _, want := range tt.indexes {
if _, ok := indexes[want]; !ok {
t.Fatalf("expected %s index %q, got %#v", tt.name, want, indexes)
}
}
}
}
func resourceChangeColumns(db *sql.DB) (map[string]struct{}, error) {
return tableColumns(db, "resource_changes")
}
func tableColumns(db *sql.DB, tableName string) (map[string]struct{}, error) {
rows, err := db.Query(`PRAGMA table_info(` + tableName + `)`)
if err != nil {
return nil, err
}
defer rows.Close()
columns := make(map[string]struct{})
for rows.Next() {
var (
cid int
name string
typ string
notNull int
dflt sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
return nil, err
}
columns[name] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, err
}
return columns, nil
}
func resourceChangesIndexes(db *sql.DB) (map[string]struct{}, error) {
return tableIndexes(db, "resource_changes")
}
func tableIndexes(db *sql.DB, tableName string) (map[string]struct{}, error) {
rows, err := db.Query(`PRAGMA index_list(` + tableName + `)`)
if err != nil {
return nil, err
}
defer rows.Close()
indexes := make(map[string]struct{})
for rows.Next() {
var (
seq int
name string
uniq int
origin string
part int
)
if err := rows.Scan(&seq, &name, &uniq, &origin, &part); err != nil {
return nil, err
}
indexes[name] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, err
}
return indexes, nil
}
func newTestStore(t *testing.T) *SQLiteResourceStore {
t.Helper()
dir := t.TempDir()
store, err := NewSQLiteResourceStore(dir, "testorg")
if err != nil {
t.Fatalf("NewSQLiteResourceStore: %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
func TestRecordChange_RoundTrip(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC().Truncate(time.Second)
occurredAt := now.Add(-30 * time.Second)
change := ResourceChange{
ID: "chg-1",
ResourceID: "vm:100",
ObservedAt: now,
OccurredAt: &occurredAt,
Kind: ChangeStateTransition,
From: "offline",
To: "online",
SourceType: SourcePlatformEvent,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceHigh,
Actor: "agent:ops-helper",
RelatedResources: []string{"node:1", "storage:2"},
Reason: "vm started",
Metadata: map[string]any{
"source": "snapshot",
"retry": 1,
},
}
if err := store.RecordChange(change); err != nil {
t.Fatalf("RecordChange: %v", err)
}
results, err := store.GetRecentChanges("vm:100", now.Add(-time.Minute), 10)
if err != nil {
t.Fatalf("GetRecentChanges: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 change, got %d", len(results))
}
got := results[0]
if got.ID != change.ID {
t.Errorf("ID: got %q, want %q", got.ID, change.ID)
}
if got.Kind != change.Kind {
t.Errorf("Kind: got %q, want %q", got.Kind, change.Kind)
}
if got.From != change.From || got.To != change.To {
t.Errorf("From/To: got %q/%q, want %q/%q", got.From, got.To, change.From, change.To)
}
if got.Confidence != change.Confidence {
t.Errorf("Confidence: got %q, want %q", got.Confidence, change.Confidence)
}
if got.SourceType != change.SourceType {
t.Errorf("SourceType: got %q, want %q", got.SourceType, change.SourceType)
}
if got.SourceAdapter != change.SourceAdapter {
t.Errorf("SourceAdapter: got %q, want %q", got.SourceAdapter, change.SourceAdapter)
}
if got.Actor != change.Actor {
t.Errorf("Actor: got %q, want %q", got.Actor, change.Actor)
}
if got.Reason != change.Reason {
t.Errorf("Reason: got %q, want %q", got.Reason, change.Reason)
}
if len(got.RelatedResources) != len(change.RelatedResources) {
t.Fatalf("RelatedResources length: got %d, want %d", len(got.RelatedResources), len(change.RelatedResources))
}
for i := range change.RelatedResources {
if got.RelatedResources[i] != change.RelatedResources[i] {
t.Fatalf("RelatedResources[%d]: got %q, want %q", i, got.RelatedResources[i], change.RelatedResources[i])
}
}
if got.OccurredAt == nil || !got.OccurredAt.Equal(occurredAt) {
t.Fatalf("OccurredAt: got %v, want %v", got.OccurredAt, occurredAt)
}
if got.Metadata["source"] != change.Metadata["source"] {
t.Fatalf("Metadata source: got %v, want %v", got.Metadata["source"], change.Metadata["source"])
}
if fmt.Sprint(got.Metadata["retry"]) != "1" {
t.Fatalf("Metadata retry: got %v, want 1", got.Metadata["retry"])
}
}
func TestRecordChange_IgnoresDuplicateIDs(t *testing.T) {
store := newTestStore(t)
now := time.Date(2026, 3, 30, 18, 20, 0, 0, time.UTC)
initial := ResourceChange{
ID: "chg-dup-1",
ResourceID: "vm:dup",
ObservedAt: now,
Kind: ChangeActivity,
SourceType: SourcePlatformEvent,
SourceAdapter: AdapterVMware,
Confidence: ConfidenceHigh,
Reason: "Create snapshot (success)",
}
if err := store.RecordChange(initial); err != nil {
t.Fatalf("RecordChange initial: %v", err)
}
duplicate := initial
duplicate.Reason = "should be ignored"
duplicate.Metadata = map[string]any{"ignored": true}
if err := store.RecordChange(duplicate); err != nil {
t.Fatalf("RecordChange duplicate: %v", err)
}
results, err := store.GetRecentChanges("vm:dup", now.Add(-time.Minute), 10)
if err != nil {
t.Fatalf("GetRecentChanges: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 change after duplicate insert, got %d", len(results))
}
if results[0].Reason != initial.Reason {
t.Fatalf("Reason = %q, want original %q", results[0].Reason, initial.Reason)
}
if len(results[0].Metadata) != 0 {
t.Fatalf("Metadata = %#v, want original empty metadata", results[0].Metadata)
}
}
func TestMemoryStore_RecordChangeIgnoresDuplicateIDs(t *testing.T) {
store := NewMemoryStore()
now := time.Date(2026, 3, 30, 18, 25, 0, 0, time.UTC)
change := ResourceChange{
ID: "chg-mem-dup-1",
ResourceID: "vm:memdup",
ObservedAt: now,
Kind: ChangeActivity,
SourceType: SourcePlatformEvent,
SourceAdapter: AdapterVMware,
Confidence: ConfidenceHigh,
Reason: "Host entered maintenance evaluation",
}
if err := store.RecordChange(change); err != nil {
t.Fatalf("RecordChange initial: %v", err)
}
change.Reason = "should be ignored"
if err := store.RecordChange(change); err != nil {
t.Fatalf("RecordChange duplicate: %v", err)
}
results, err := store.GetRecentChanges("vm:memdup", now.Add(-time.Minute), 10)
if err != nil {
t.Fatalf("GetRecentChanges: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 change after duplicate insert, got %d", len(results))
}
if results[0].Reason != "Host entered maintenance evaluation" {
t.Fatalf("Reason = %q, want original reason", results[0].Reason)
}
}
func TestRecordChange_PreservesTimelineMetadata(t *testing.T) {
store := newTestStore(t)
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
occurredAt := now.Add(-5 * time.Minute)
change := ResourceChange{
ID: "chg-rich-1",
ResourceID: "vm:200",
ObservedAt: now,
OccurredAt: &occurredAt,
Kind: ChangeRelationship,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterDocker,
Confidence: ConfidenceMedium,
Actor: "pulse:differ",
RelatedResources: []string{"node:20", "service:api"},
Reason: "relationship updated",
Metadata: map[string]any{
"edgeType": "runs_on",
"active": true,
},
}
if err := store.RecordChange(change); err != nil {
t.Fatalf("RecordChange: %v", err)
}
results, err := store.GetRecentChanges("vm:200", now.Add(-time.Hour), 10)
if err != nil {
t.Fatalf("GetRecentChanges: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 change, got %d", len(results))
}
got := results[0]
if got.Kind != change.Kind || got.SourceType != change.SourceType || got.SourceAdapter != change.SourceAdapter {
t.Fatalf("unexpected change headers: %+v", got)
}
if got.OccurredAt == nil || !got.OccurredAt.Equal(occurredAt) {
t.Fatalf("OccurredAt: got %v, want %v", got.OccurredAt, occurredAt)
}
if len(got.RelatedResources) != 2 || got.RelatedResources[0] != "node:20" || got.RelatedResources[1] != "service:api" {
t.Fatalf("RelatedResources round-trip failed: %+v", got.RelatedResources)
}
if got.Metadata["edgeType"] != "runs_on" {
t.Fatalf("Metadata edgeType: got %v, want %v", got.Metadata["edgeType"], "runs_on")
}
if got.Metadata["active"] != true {
t.Fatalf("Metadata active: got %v, want true", got.Metadata["active"])
}
}
func TestCountRecentChanges_RespectsFilters(t *testing.T) {
store := newTestStore(t)
base := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
changes := []ResourceChange{
{
ID: "chg-count-1",
ResourceID: "vm:1",
ObservedAt: base.Add(-30 * time.Minute),
Kind: ChangeStateTransition,
SourceType: SourcePlatformEvent,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceHigh,
},
{
ID: "chg-count-2",
ResourceID: "vm:1",
ObservedAt: base.Add(-20 * time.Minute),
Kind: ChangeAnomaly,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterDocker,
Confidence: ConfidenceMedium,
},
{
ID: "chg-count-3",
ResourceID: "vm:1",
ObservedAt: base.Add(-10 * time.Minute),
Kind: ChangeRelationship,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceLow,
},
{
ID: "chg-count-4",
ResourceID: "vm:2",
ObservedAt: base.Add(-5 * time.Minute),
Kind: ChangeCapability,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterDocker,
Confidence: ConfidenceLow,
},
}
for _, change := range changes {
if err := store.RecordChange(change); err != nil {
t.Fatalf("RecordChange(%s): %v", change.ID, err)
}
}
count, err := store.CountRecentChanges("vm:1", base.Add(-25*time.Minute))
if err != nil {
t.Fatalf("CountRecentChanges vm:1: %v", err)
}
if count != 2 {
t.Fatalf("CountRecentChanges vm:1 = %d, want 2", count)
}
allCount, err := store.CountRecentChanges("", base.Add(-15*time.Minute))
if err != nil {
t.Fatalf("CountRecentChanges all: %v", err)
}
if allCount != 2 {
t.Fatalf("CountRecentChanges all = %d, want 2", allCount)
}
filteredCount, err := store.CountRecentChangesFiltered("vm:1", base.Add(-35*time.Minute), ResourceChangeFilters{
Kinds: []ChangeKind{ChangeAnomaly, ChangeRelationship},
})
if err != nil {
t.Fatalf("CountRecentChangesFiltered kinds: %v", err)
}
if filteredCount != 2 {
t.Fatalf("CountRecentChangesFiltered kinds = %d, want 2", filteredCount)
}
sourceFilteredCount, err := store.CountRecentChangesFiltered("", base.Add(-25*time.Minute), ResourceChangeFilters{
SourceTypes: []ChangeSourceType{SourcePulseDiff},
})
if err != nil {
t.Fatalf("CountRecentChangesFiltered source types: %v", err)
}
if sourceFilteredCount != 3 {
t.Fatalf("CountRecentChangesFiltered source types = %d, want 3", sourceFilteredCount)
}
adapterFilteredCount, err := store.CountRecentChangesFiltered("vm:1", base.Add(-35*time.Minute), ResourceChangeFilters{
SourceAdapters: []ChangeSourceAdapter{AdapterProxmox},
})
if err != nil {
t.Fatalf("CountRecentChangesFiltered source adapters: %v", err)
}
if adapterFilteredCount != 2 {
t.Fatalf("CountRecentChangesFiltered source adapters = %d, want 2", adapterFilteredCount)
}
}
func TestCountRecentChangesByKind_RespectsFilters(t *testing.T) {
store := newTestStore(t)
base := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
changes := []ResourceChange{
{
ID: "chg-kind-1",
ResourceID: "vm:1",
ObservedAt: base.Add(-30 * time.Minute),
Kind: ChangeStateTransition,
SourceType: SourcePlatformEvent,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceHigh,
},
{
ID: "chg-kind-2",
ResourceID: "vm:1",
ObservedAt: base.Add(-20 * time.Minute),
Kind: ChangeAnomaly,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterDocker,
Confidence: ConfidenceMedium,
},
{
ID: "chg-kind-3",
ResourceID: "vm:1",
ObservedAt: base.Add(-10 * time.Minute),
Kind: ChangeAnomaly,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceLow,
},
{
ID: "chg-kind-4",
ResourceID: "vm:2",
ObservedAt: base.Add(-5 * time.Minute),
Kind: ChangeCapability,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterDocker,
Confidence: ConfidenceLow,
},
}
for _, change := range changes {
if err := store.RecordChange(change); err != nil {
t.Fatalf("RecordChange(%s): %v", change.ID, err)
}
}
counts, err := store.CountRecentChangesByKind("vm:1", base.Add(-35*time.Minute))
if err != nil {
t.Fatalf("CountRecentChangesByKind vm:1: %v", err)
}
wantCounts := map[ChangeKind]int{
ChangeStateTransition: 1,
ChangeAnomaly: 2,
}
if !reflect.DeepEqual(counts, wantCounts) {
t.Fatalf("CountRecentChangesByKind vm:1 = %#v, want %#v", counts, wantCounts)
}
filteredCounts, err := store.CountRecentChangesByKindFiltered("vm:1", base.Add(-35*time.Minute), ResourceChangeFilters{
SourceTypes: []ChangeSourceType{SourcePulseDiff},
})
if err != nil {
t.Fatalf("CountRecentChangesByKindFiltered source types: %v", err)
}
if !reflect.DeepEqual(filteredCounts, map[ChangeKind]int{ChangeAnomaly: 2}) {
t.Fatalf("CountRecentChangesByKindFiltered source types = %#v, want %#v", filteredCounts, map[ChangeKind]int{ChangeAnomaly: 2})
}
adapterCounts, err := store.CountRecentChangesByKindFiltered("vm:1", base.Add(-35*time.Minute), ResourceChangeFilters{
SourceAdapters: []ChangeSourceAdapter{AdapterProxmox},
})
if err != nil {
t.Fatalf("CountRecentChangesByKindFiltered source adapters: %v", err)
}
if !reflect.DeepEqual(adapterCounts, map[ChangeKind]int{ChangeStateTransition: 1, ChangeAnomaly: 1}) {
t.Fatalf("CountRecentChangesByKindFiltered source adapters = %#v, want %#v", adapterCounts, map[ChangeKind]int{ChangeStateTransition: 1, ChangeAnomaly: 1})
}
}
func TestStorePersistsCanonicalIncidentTimelineKinds(t *testing.T) {
store := newTestStore(t)
base := time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC)
changes := []ResourceChange{
{
ID: "incident-kind-1",
ResourceID: "vm:incident",
ObservedAt: base.Add(-3 * time.Minute),
Kind: ChangeAlertFired,
SourceType: SourceHeuristic,
Confidence: ConfidenceHigh,
},
{
ID: "incident-kind-2",
ResourceID: "vm:incident",
ObservedAt: base.Add(-2 * time.Minute),
Kind: ChangeCommandExecuted,
SourceType: SourceAgentAction,
Confidence: ConfidenceHigh,
},
{
ID: "incident-kind-3",
ResourceID: "vm:incident",
ObservedAt: base.Add(-time.Minute),
Kind: ChangeRunbookExecuted,
SourceType: SourceAgentAction,
Confidence: ConfidenceHigh,
},
}
for _, change := range changes {
if err := store.RecordChange(change); err != nil {
t.Fatalf("RecordChange(%s): %v", change.ID, err)
}
}
results, err := store.GetRecentChangesFiltered("vm:incident", base.Add(-10*time.Minute), 10, ResourceChangeFilters{
Kinds: []ChangeKind{ChangeAlertFired, ChangeCommandExecuted, ChangeRunbookExecuted},
})
if err != nil {
t.Fatalf("GetRecentChangesFiltered: %v", err)
}
if len(results) != 3 {
t.Fatalf("expected 3 incident timeline changes, got %d", len(results))
}
counts, err := store.CountRecentChangesByKind("vm:incident", base.Add(-10*time.Minute))
if err != nil {
t.Fatalf("CountRecentChangesByKind: %v", err)
}
for _, kind := range []ChangeKind{ChangeAlertFired, ChangeCommandExecuted, ChangeRunbookExecuted} {
if counts[kind] != 1 {
t.Fatalf("CountRecentChangesByKind[%q] = %d, want 1", kind, counts[kind])
}
}
if got := results[0].Kind; got != ChangeRunbookExecuted {
t.Fatalf("latest kind = %q, want %q", got, ChangeRunbookExecuted)
}
}
func TestCountRecentChangesBySourceType_RespectsFilters(t *testing.T) {
store := newTestStore(t)
base := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
changes := []ResourceChange{
{
ID: "chg-source-1",
ResourceID: "vm:1",
ObservedAt: base.Add(-30 * time.Minute),
Kind: ChangeStateTransition,
SourceType: SourcePlatformEvent,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceHigh,
},
{
ID: "chg-source-2",
ResourceID: "vm:1",
ObservedAt: base.Add(-20 * time.Minute),
Kind: ChangeAnomaly,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterDocker,
Confidence: ConfidenceMedium,
},
{
ID: "chg-source-3",
ResourceID: "vm:1",
ObservedAt: base.Add(-10 * time.Minute),
Kind: ChangeAnomaly,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceLow,
},
{
ID: "chg-source-4",
ResourceID: "vm:2",
ObservedAt: base.Add(-5 * time.Minute),
Kind: ChangeCapability,
SourceType: SourceAgentAction,
SourceAdapter: AdapterDocker,
Confidence: ConfidenceLow,
},
}
for _, change := range changes {
if err := store.RecordChange(change); err != nil {
t.Fatalf("RecordChange(%s): %v", change.ID, err)
}
}
counts, err := store.CountRecentChangesBySourceType("vm:1", base.Add(-35*time.Minute))
if err != nil {
t.Fatalf("CountRecentChangesBySourceType vm:1: %v", err)
}
wantCounts := map[ChangeSourceType]int{
SourcePlatformEvent: 1,
SourcePulseDiff: 2,
}
if !reflect.DeepEqual(counts, wantCounts) {
t.Fatalf("CountRecentChangesBySourceType vm:1 = %#v, want %#v", counts, wantCounts)
}
filteredCounts, err := store.CountRecentChangesBySourceTypeFiltered("vm:1", base.Add(-35*time.Minute), ResourceChangeFilters{
SourceAdapters: []ChangeSourceAdapter{AdapterProxmox},
})
if err != nil {
t.Fatalf("CountRecentChangesBySourceTypeFiltered source adapters: %v", err)
}
if !reflect.DeepEqual(filteredCounts, map[ChangeSourceType]int{SourcePlatformEvent: 1, SourcePulseDiff: 1}) {
t.Fatalf("CountRecentChangesBySourceTypeFiltered source adapters = %#v, want %#v", filteredCounts, map[ChangeSourceType]int{SourcePlatformEvent: 1, SourcePulseDiff: 1})
}
adapterCounts, err := store.CountRecentChangesBySourceAdapter("vm:1", base.Add(-35*time.Minute))
if err != nil {
t.Fatalf("CountRecentChangesBySourceAdapter vm:1: %v", err)
}
wantAdapterCounts := map[ChangeSourceAdapter]int{
AdapterDocker: 1,
AdapterProxmox: 2,
}
if !reflect.DeepEqual(adapterCounts, wantAdapterCounts) {
t.Fatalf("CountRecentChangesBySourceAdapter vm:1 = %#v, want %#v", adapterCounts, wantAdapterCounts)
}
filteredAdapterCounts, err := store.CountRecentChangesBySourceAdapterFiltered("vm:1", base.Add(-35*time.Minute), ResourceChangeFilters{
SourceTypes: []ChangeSourceType{SourcePulseDiff},
})
if err != nil {
t.Fatalf("CountRecentChangesBySourceAdapterFiltered source types: %v", err)
}
if !reflect.DeepEqual(filteredAdapterCounts, map[ChangeSourceAdapter]int{AdapterDocker: 1, AdapterProxmox: 1}) {
t.Fatalf("CountRecentChangesBySourceAdapterFiltered source types = %#v, want %#v", filteredAdapterCounts, map[ChangeSourceAdapter]int{AdapterDocker: 1, AdapterProxmox: 1})
}
}
func TestGetRecentChanges_RespectsFilters(t *testing.T) {
store := newTestStore(t)
base := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
changes := []ResourceChange{
{
ID: "chg-1",
ResourceID: "vm:1",
ObservedAt: base.Add(-30 * time.Minute),
Kind: ChangeStateTransition,
SourceType: SourcePlatformEvent,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceHigh,
},
{
ID: "chg-2",
ResourceID: "vm:1",
ObservedAt: base.Add(-20 * time.Minute),
Kind: ChangeAnomaly,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterDocker,
Confidence: ConfidenceMedium,
},
{
ID: "chg-3",
ResourceID: "vm:1",
ObservedAt: base.Add(-10 * time.Minute),
Kind: ChangeRelationship,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceLow,
},
}
for _, change := range changes {
if err := store.RecordChange(change); err != nil {
t.Fatalf("RecordChange(%s): %v", change.ID, err)
}
}
results, err := store.GetRecentChangesFiltered("vm:1", base.Add(-35*time.Minute), 10, ResourceChangeFilters{
Kinds: []ChangeKind{ChangeRelationship},
})
if err != nil {
t.Fatalf("GetRecentChangesFiltered kinds: %v", err)
}
if len(results) != 1 || results[0].ID != "chg-3" {
t.Fatalf("GetRecentChangesFiltered kinds = %#v, want chg-3", results)
}
sourceResults, err := store.GetRecentChangesFiltered("vm:1", base.Add(-25*time.Minute), 10, ResourceChangeFilters{
SourceTypes: []ChangeSourceType{SourcePulseDiff},
})
if err != nil {
t.Fatalf("GetRecentChangesFiltered source types: %v", err)
}
if len(sourceResults) != 2 || sourceResults[0].ID != "chg-3" || sourceResults[1].ID != "chg-2" {
t.Fatalf("GetRecentChangesFiltered source types = %#v, want chg-3 then chg-2", sourceResults)
}
adapterResults, err := store.GetRecentChangesFiltered("vm:1", base.Add(-35*time.Minute), 10, ResourceChangeFilters{
SourceAdapters: []ChangeSourceAdapter{AdapterProxmox},
})
if err != nil {
t.Fatalf("GetRecentChangesFiltered source adapters: %v", err)
}
if len(adapterResults) != 2 || adapterResults[0].ID != "chg-3" || adapterResults[1].ID != "chg-1" {
t.Fatalf("GetRecentChangesFiltered source adapters = %#v, want chg-3 then chg-1", adapterResults)
}
}
func TestActionAuditRecord_RoundTrip(t *testing.T) {
store := newTestStore(t)
now := time.Date(2026, 3, 18, 13, 0, 0, 0, time.UTC)
expires := now.Add(15 * time.Minute)
approvedAt := now.Add(2 * time.Minute)
result := &ExecutionResult{Success: true, Output: "completed"}
record := ActionAuditRecord{
ID: "action-1",
CreatedAt: now,
UpdatedAt: now.Add(5 * time.Minute),
State: ActionStateCompleted,
Request: ActionRequest{
RequestID: "req-1",
ResourceID: "vm:300",
CapabilityName: "restart",
Params: map[string]any{
"force": true,
},
Reason: "restart for maintenance",
RequestedBy: "agent:oncall-helper",
},
Plan: ActionPlan{
ActionID: "action-1",
RequestID: "req-1",
Allowed: true,
RequiresApproval: true,
ApprovalPolicy: ApprovalAdmin,
PredictedBlastRadius: []string{"node:1", "storage:1"},
RollbackAvailable: true,
Message: "allowed",
PlannedAt: now,
ExpiresAt: expires,
ResourceVersion: "rv-1",
PolicyVersion: "pv-1",
PlanHash: "plan-hash-1",
},
Approvals: []ActionApprovalRecord{
{
Actor: "admin@example.com",
Method: MethodUI,
Timestamp: approvedAt,
Outcome: OutcomeApproved,
Reason: "approved for maintenance window",
},
},
Result: result,
}
if err := store.RecordActionAudit(record); err != nil {
t.Fatalf("RecordActionAudit: %v", err)
}
results, err := store.GetActionAudits("vm:300", now.Add(-time.Hour), 10)
if err != nil {
t.Fatalf("GetActionAudits: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 action audit, got %d", len(results))
}
got := results[0]
if got.ID != record.ID || got.State != record.State {
t.Fatalf("unexpected audit headers: %+v", got)
}
if got.Request.RequestID != record.Request.RequestID || got.Request.RequestedBy != record.Request.RequestedBy {
t.Fatalf("request round-trip failed: %+v", got.Request)
}
if got.Plan.ResourceVersion != record.Plan.ResourceVersion || got.Plan.PolicyVersion != record.Plan.PolicyVersion || got.Plan.PlanHash != record.Plan.PlanHash {
t.Fatalf("plan round-trip failed: %+v", got.Plan)
}
if len(got.Approvals) != 1 || got.Approvals[0].Actor != record.Approvals[0].Actor || got.Approvals[0].Outcome != record.Approvals[0].Outcome {
t.Fatalf("approvals round-trip failed: %+v", got.Approvals)
}
if got.Result == nil || !got.Result.Success || got.Result.Output != result.Output {
t.Fatalf("result round-trip failed: %+v", got.Result)
}
}
func TestMemoryStore_RecordActionAudit_UpsertsByID(t *testing.T) {
store := NewMemoryStore()
now := time.Date(2026, 3, 18, 13, 30, 0, 0, time.UTC)
first := ActionAuditRecord{
ID: "action-2",
CreatedAt: now,
UpdatedAt: now,
State: ActionStatePlanned,
Request: ActionRequest{
RequestID: "req-2",
ResourceID: "vm:301",
CapabilityName: "restart",
RequestedBy: "agent:test",
},
}
second := first
second.UpdatedAt = now.Add(2 * time.Minute)
second.State = ActionStateCompleted
second.Result = &ExecutionResult{Success: true, Output: "done"}
if err := store.RecordActionAudit(first); err != nil {
t.Fatalf("RecordActionAudit(first): %v", err)
}
if err := store.RecordActionAudit(second); err != nil {
t.Fatalf("RecordActionAudit(second): %v", err)
}
results, err := store.GetActionAudits("vm:301", now.Add(-time.Hour), 10)
if err != nil {
t.Fatalf("GetActionAudits: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 action audit after upsert, got %d", len(results))
}
if results[0].State != ActionStateCompleted {
t.Fatalf("expected latest action state to win, got %q", results[0].State)
}
if results[0].Result == nil || results[0].Result.Output != "done" {
t.Fatalf("expected latest action result to win, got %+v", results[0].Result)
}
}
func TestSQLiteStore_GetActionAudits_AllWhenResourceIDBlank(t *testing.T) {
store := newTestStore(t)
now := time.Date(2026, 3, 18, 13, 45, 0, 0, time.UTC)
records := []ActionAuditRecord{
{
ID: "action-3",
CreatedAt: now.Add(-2 * time.Minute),
UpdatedAt: now.Add(-2 * time.Minute),
State: ActionStatePlanned,
Request: ActionRequest{
RequestID: "req-3",
ResourceID: "vm:400",
CapabilityName: "restart",
RequestedBy: "agent:test",
},
},
{
ID: "action-4",
CreatedAt: now.Add(-time.Minute),
UpdatedAt: now.Add(-time.Minute),
State: ActionStateCompleted,
Request: ActionRequest{
RequestID: "req-4",
ResourceID: "vm:401",
CapabilityName: "restart",
RequestedBy: "agent:test",
},
},
}
for _, record := range records {
if err := store.RecordActionAudit(record); err != nil {
t.Fatalf("RecordActionAudit(%s): %v", record.ID, err)
}
}
results, err := store.GetActionAudits("", now.Add(-time.Hour), 10)
if err != nil {
t.Fatalf("GetActionAudits: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 action audits without resource filter, got %d", len(results))
}
if results[0].ID != "action-4" || results[1].ID != "action-3" {
t.Fatalf("unexpected action audit order: %+v", results)
}
}
func TestSQLiteStore_GetRecentChanges_AllWhenResourceIDBlank(t *testing.T) {
store := newTestStore(t)
now := time.Date(2026, 3, 18, 13, 50, 0, 0, time.UTC)
changes := []ResourceChange{
{
ID: "change-1",
ResourceID: "vm:500",
ObservedAt: now.Add(-2 * time.Minute),
Kind: ChangeRestart,
SourceType: SourcePlatformEvent,
SourceAdapter: AdapterDocker,
Confidence: ConfidenceHigh,
Reason: "restart detected",
},
{
ID: "change-2",
ResourceID: "vm:501",
ObservedAt: now.Add(-time.Minute),
Kind: ChangeConfigUpdate,
SourceType: SourcePulseDiff,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceMedium,
Reason: "configuration drift",
},
}
for _, change := range changes {
if err := store.RecordChange(change); err != nil {
t.Fatalf("RecordChange(%s): %v", change.ID, err)
}
}
results, err := store.GetRecentChanges("", now.Add(-time.Hour), 10)
if err != nil {
t.Fatalf("GetRecentChanges: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 recent changes without resource filter, got %d", len(results))
}
if results[0].ID != "change-2" || results[1].ID != "change-1" {
t.Fatalf("unexpected recent change order: %+v", results)
}
if results[0].ResourceID != "vm:501" || results[1].ResourceID != "vm:500" {
t.Fatalf("expected canonical resource IDs to round-trip, got %+v", results)
}
}
func TestActionLifecycleEvent_RoundTrip(t *testing.T) {
store := newTestStore(t)
now := time.Date(2026, 3, 18, 14, 0, 0, 0, time.UTC)
events := []ActionLifecycleEvent{
{
ActionID: "action-2",
Timestamp: now,
State: ActionStatePlanned,
Actor: "system",
Message: "planned",
},
{
ActionID: "action-2",
Timestamp: now.Add(1 * time.Minute),
State: ActionStateApproved,
Actor: "admin@example.com",
Message: "approved",
},
}
for _, event := range events {
if err := store.RecordActionLifecycleEvent(event); err != nil {
t.Fatalf("RecordActionLifecycleEvent: %v", err)
}
}
results, err := store.GetActionLifecycleEvents("action-2", now.Add(-time.Hour), 10)
if err != nil {
t.Fatalf("GetActionLifecycleEvents: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 lifecycle events, got %d", len(results))
}
if results[0].State != ActionStateApproved || results[1].State != ActionStatePlanned {
t.Fatalf("unexpected lifecycle ordering: %+v", results)
}
}
func TestExportAuditRecord_RoundTrip(t *testing.T) {
store := newTestStore(t)
now := time.Date(2026, 3, 18, 15, 0, 0, 0, time.UTC)
record := ExportAuditRecord{
ID: "export-1",
Timestamp: now,
Actor: "agent:context-router",
EnvelopeHash: "sha256:deadbeef",
Decision: ExportRedacted,
Destination: "local-llama",
Redactions: []string{"metadata.hostname", "identity.ipAddresses"},
}
if err := store.RecordExportAudit(record); err != nil {
t.Fatalf("RecordExportAudit: %v", err)
}
results, err := store.GetExportAudits(now.Add(-time.Hour), 10)
if err != nil {
t.Fatalf("GetExportAudits: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 export audit, got %d", len(results))
}
got := results[0]
if got.ID != record.ID || got.Decision != record.Decision || got.Destination != record.Destination {
t.Fatalf("unexpected export audit round-trip: %+v", got)
}
if len(got.Redactions) != len(record.Redactions) || got.Redactions[0] != record.Redactions[0] || got.Redactions[1] != record.Redactions[1] {
t.Fatalf("redactions round-trip failed: %+v", got.Redactions)
}
}
func TestGetRecentChanges_RespectsTimeFilter(t *testing.T) {
store := newTestStore(t)
base := time.Now().UTC().Truncate(time.Second)
old := ResourceChange{ID: "chg-old", ResourceID: "vm:1", ObservedAt: base.Add(-2 * time.Hour), Kind: ChangeStateTransition, SourceType: "proxmox", Confidence: ConfidenceHigh}
recent := ResourceChange{ID: "chg-new", ResourceID: "vm:1", ObservedAt: base, Kind: ChangeStateTransition, SourceType: "proxmox", Confidence: ConfidenceHigh}
for _, c := range []ResourceChange{old, recent} {
if err := store.RecordChange(c); err != nil {
t.Fatalf("RecordChange: %v", err)
}
}
results, err := store.GetRecentChanges("vm:1", base.Add(-time.Hour), 10)
if err != nil {
t.Fatalf("GetRecentChanges: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result (recent only), got %d", len(results))
}
if results[0].ID != "chg-new" {
t.Errorf("expected chg-new, got %q", results[0].ID)
}
}
func TestGetRecentChanges_RespectsLimit(t *testing.T) {
store := newTestStore(t)
base := time.Now().UTC().Truncate(time.Second)
for i := 0; i < 5; i++ {
c := ResourceChange{
ID: strings.Repeat("x", 3) + string(rune('0'+i)),
ResourceID: "vm:2",
ObservedAt: base.Add(time.Duration(i) * time.Second),
Kind: ChangeStateTransition,
SourceType: "proxmox",
Confidence: ConfidenceHigh,
}
if err := store.RecordChange(c); err != nil {
t.Fatalf("RecordChange: %v", err)
}
}
results, err := store.GetRecentChanges("vm:2", base.Add(-time.Minute), 3)
if err != nil {
t.Fatalf("GetRecentChanges: %v", err)
}
if len(results) != 3 {
t.Fatalf("expected 3 results (limit), got %d", len(results))
}
}
func seedLegacyLinksTable(t *testing.T, legacyPath string) {
t.Helper()
db, err := sql.Open("sqlite", legacyPath)
if err != nil {
t.Fatalf("sql.Open(%q) failed: %v", legacyPath, err)
}
defer db.Close()
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS resource_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_a TEXT NOT NULL,
resource_b TEXT NOT NULL,
primary_id TEXT NOT NULL,
reason TEXT,
created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(resource_a, resource_b)
);
`); err != nil {
t.Fatalf("failed to create legacy schema: %v", err)
}
if _, err := db.Exec(`
INSERT INTO resource_links (resource_a, resource_b, primary_id, reason, created_by, created_at)
VALUES ('legacy-a', 'legacy-b', 'legacy-a', 'legacy migration', 'tester', CURRENT_TIMESTAMP);
`); err != nil {
t.Fatalf("failed to seed legacy link row: %v", err)
}
}