package api import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/storagehealth" "github.com/rcourtman/pulse-go-rewrite/internal/truenas" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) type resourceStateProvider struct { snapshot models.StateSnapshot } func (s resourceStateProvider) ReadSnapshot() models.StateSnapshot { return s.snapshot } type resourceUnifiedSeedProvider struct { snapshot models.StateSnapshot resources []unified.Resource } func (p resourceUnifiedSeedProvider) ReadSnapshot() models.StateSnapshot { return p.snapshot } func (p resourceUnifiedSeedProvider) UnifiedResourceSnapshot() ([]unified.Resource, time.Time) { out := make([]unified.Resource, len(p.resources)) copy(out, p.resources) return out, p.snapshot.LastUpdate } type mutableResourceUnifiedSeedProvider struct { snapshot models.StateSnapshot resources []unified.Resource freshness time.Time } func (p *mutableResourceUnifiedSeedProvider) ReadSnapshot() models.StateSnapshot { return p.snapshot } func (p *mutableResourceUnifiedSeedProvider) UnifiedResourceSnapshot() ([]unified.Resource, time.Time) { out := make([]unified.Resource, len(p.resources)) copy(out, p.resources) return out, p.freshness } type mockSupplementalRecordsProvider struct { records []unified.IngestRecord ownedSources []unified.DataSource } func (m mockSupplementalRecordsProvider) GetCurrentRecords() []unified.IngestRecord { out := make([]unified.IngestRecord, len(m.records)) copy(out, m.records) return out } func (m mockSupplementalRecordsProvider) SnapshotOwnedSources() []unified.DataSource { out := make([]unified.DataSource, len(m.ownedSources)) copy(out, m.ownedSources) return out } func (m mockSupplementalRecordsProvider) SupplementalRecords(*monitoring.Monitor, string) []unified.IngestRecord { return m.GetCurrentRecords() } func TestResourceListRejectsLegacyHostTypeFilter(t *testing.T) { cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{}}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=host", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) } if body := rec.Body.String(); !strings.Contains(body, `unsupported type filter token(s): host`) { t.Fatalf("unexpected response body: %s", body) } } func TestResourceListMergesLinkedHost(t *testing.T) { now := time.Now().UTC() node := models.Node{ ID: "instance-pve1", Name: "pve1", Instance: "instance", Host: "https://pve1:8006", Status: "online", CPU: 0.15, Memory: models.Memory{Total: 1024, Used: 512, Free: 512, Usage: 0.5}, Disk: models.Disk{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, LinkedAgentID: "host-1", } host := models.Host{ ID: "host-1", Hostname: "pve1", Status: "online", Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, LinkedNodeID: node.ID, } snapshot := models.StateSnapshot{ Nodes: []models.Node{node}, Hosts: []models.Host{host}, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 resource, got %d", len(resp.Data)) } resource := resp.Data[0] if !containsSource(resource.Sources, unified.SourceProxmox) || !containsSource(resource.Sources, unified.SourceAgent) { t.Fatalf("expected merged sources, got %+v", resource.Sources) } if resource.DiscoveryTarget == nil { t.Fatalf("expected discovery target on merged host") } if resource.DiscoveryTarget.ResourceType != "agent" { t.Fatalf("discovery target resourceType = %q, want agent", resource.DiscoveryTarget.ResourceType) } if resource.DiscoveryTarget.AgentID != "host-1" || resource.DiscoveryTarget.ResourceID != "host-1" { t.Fatalf("discovery target = %+v, want host-1/host-1", resource.DiscoveryTarget) } if resource.Canonical == nil { t.Fatalf("expected canonical identity on merged host") } if got := resource.Canonical.DisplayName; got != "pve1" { t.Fatalf("canonical displayName = %q, want pve1", got) } if got := resource.Canonical.PlatformID; got != "pve1" { t.Fatalf("canonical platformId = %q, want pve1", got) } if got := resource.Canonical.PrimaryID; got != "node:instance-pve1" { t.Fatalf("canonical primaryId = %q, want node:instance-pve1", got) } } func TestResourceListUsesUnifiedSeedProvider(t *testing.T) { now := time.Now().UTC() cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ { ID: "agent-seeded", Type: unified.ResourceTypeAgent, Name: "seeded-agent", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourceAgent}, Capabilities: []unified.ResourceCapability{ { Name: "restart", Type: unified.CapabilityTypeCommon, Description: "Restart the resource", MinimumApprovalLevel: unified.ApprovalAdmin, }, }, Relationships: []unified.ResourceRelationship{ { SourceID: "agent-seeded", TargetID: "node-1", Type: unified.RelRunsOn, Confidence: 1, Active: true, Discoverer: "proxmox_adapter", ObservedAt: now, LastSeenAt: now, }, }, RecentChanges: []unified.ResourceChange{ { ID: "chg-1", ResourceID: "agent-seeded", ObservedAt: now, Kind: unified.ChangeStateTransition, From: "offline", To: "online", SourceType: unified.SourcePlatformEvent, SourceAdapter: unified.AdapterProxmox, Confidence: unified.ConfidenceHigh, }, }, Identity: unified.ResourceIdentity{ Hostnames: []string{"seeded-agent"}, }, }, }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 resource, got %d", len(resp.Data)) } if got := resp.Data[0].Name; got != "seeded-agent" { t.Fatalf("resource name = %q, want seeded-agent", got) } if resp.Data[0].Policy == nil { t.Fatal("expected policy metadata on seeded resource") } if got := resp.Data[0].Policy.Sensitivity; got != unified.ResourceSensitivityInternal { t.Fatalf("policy sensitivity = %q, want %q", got, unified.ResourceSensitivityInternal) } if strings.TrimSpace(resp.Data[0].AISafeSummary) == "" { t.Fatal("expected aiSafeSummary on seeded resource") } if got := resp.Data[0].FacetCounts; got.RecentChanges != 1 { t.Fatalf("facetCounts = %+v, want recentChanges=1", got) } } func TestResourceListAttachesDiscoveryReadiness(t *testing.T) { now := time.Date(2026, 6, 4, 11, 0, 0, 0, time.UTC) observedAt := now.Add(-5 * time.Minute) cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ { ID: "agent:node-a", Type: unified.ResourceTypeAgent, Name: "node-a", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourceAgent}, Agent: &unified.AgentData{ AgentID: "node-a", Hostname: "node-a", }, }, }, }) h.SetDiscoveryReadinessProvider(staticResourceDiscoveryReadinessProvider{ byResource: map[string]unified.ResourceDiscoveryReadiness{ "agent:node-a": { State: unified.ResourceDiscoveryReadinessStale, Source: "service-discovery", ObservedAt: &observedAt, AgeSeconds: int64((31 * 24 * time.Hour).Seconds()), ServiceName: "Node services", FactCount: 3, }, }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 resource, got %d", len(resp.Data)) } readiness := resp.Data[0].DiscoveryReadiness if readiness == nil { t.Fatal("expected discoveryReadiness in resource payload") } if readiness.State != unified.ResourceDiscoveryReadinessStale { t.Fatalf("readiness state = %q, want stale", readiness.State) } if readiness.ResourceType != "agent" || readiness.TargetID != "node-a" || readiness.ResourceID != "node-a" { t.Fatalf("readiness target = %+v, want agent/node-a/node-a", readiness) } } func TestResourceListUsesDeterministicNameTieBreakers(t *testing.T) { now := time.Date(2026, 4, 11, 0, 0, 0, 0, time.UTC) cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ {ID: "storage-c", Type: unified.ResourceTypeStorage, Name: "backup-vault-a", Status: unified.StatusOnline, LastSeen: now}, {ID: "storage-a", Type: unified.ResourceTypeStorage, Name: "backup-vault-a", Status: unified.StatusOnline, LastSeen: now}, {ID: "storage-b", Type: unified.ResourceTypeStorage, Name: "backup-vault-a", Status: unified.StatusOnline, LastSeen: now}, }, }) for attempt := 0; attempt < 2; attempt++ { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage&page=1&limit=100", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("attempt %d: status = %d, body=%s", attempt+1, rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("attempt %d: decode response: %v", attempt+1, err) } gotIDs := make([]string, 0, len(resp.Data)) for _, resource := range resp.Data { gotIDs = append(gotIDs, resource.ID) } wantIDs := []string{"storage-a", "storage-b", "storage-c"} if len(gotIDs) != len(wantIDs) { t.Fatalf("attempt %d: expected %d resources, got %d (%v)", attempt+1, len(wantIDs), len(gotIDs), gotIDs) } for index := range wantIDs { if gotIDs[index] != wantIDs[index] { t.Fatalf("attempt %d: position %d = %q, want %q (got=%v)", attempt+1, index, gotIDs[index], wantIDs[index], gotIDs) } } } } func TestResourceListInvalidatesUnifiedSeedCacheOnFreshnessChange(t *testing.T) { now := time.Now().UTC() provider := &mutableResourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ { ID: "agent-seeded-1", Type: unified.ResourceTypeAgent, Name: "seeded-agent-old", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourceAgent}, Identity: unified.ResourceIdentity{ Hostnames: []string{"seeded-agent-old"}, }, }, }, freshness: now, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(provider) firstRec := httptest.NewRecorder() firstReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil) h.HandleListResources(firstRec, firstReq) if firstRec.Code != http.StatusOK { t.Fatalf("first status = %d, body=%s", firstRec.Code, firstRec.Body.String()) } var firstResp ResourcesResponse if err := json.NewDecoder(firstRec.Body).Decode(&firstResp); err != nil { t.Fatalf("decode first response: %v", err) } if len(firstResp.Data) != 1 || firstResp.Data[0].Name != "seeded-agent-old" { t.Fatalf("unexpected first response: %#v", firstResp.Data) } provider.resources = []unified.Resource{ { ID: "agent-seeded-2", Type: unified.ResourceTypeAgent, Name: "seeded-agent-new", Status: unified.StatusOnline, LastSeen: now.Add(time.Minute), UpdatedAt: now.Add(time.Minute), Sources: []unified.DataSource{unified.SourceAgent}, Identity: unified.ResourceIdentity{ Hostnames: []string{"seeded-agent-new"}, }, }, } provider.freshness = now.Add(time.Minute) secondRec := httptest.NewRecorder() secondReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil) h.HandleListResources(secondRec, secondReq) if secondRec.Code != http.StatusOK { t.Fatalf("second status = %d, body=%s", secondRec.Code, secondRec.Body.String()) } var secondResp ResourcesResponse if err := json.NewDecoder(secondRec.Body).Decode(&secondResp); err != nil { t.Fatalf("decode second response: %v", err) } if len(secondResp.Data) != 1 || secondResp.Data[0].Name != "seeded-agent-new" { t.Fatalf("expected cache invalidation after freshness change, got %#v", secondResp.Data) } } func TestResourceListMergesOneSidedLinkedHostWhenHostnameCorroborates(t *testing.T) { now := time.Now().UTC() node := models.Node{ ID: "instance-pve1", Name: "pve1", Instance: "instance", Host: "https://pve1:8006", Status: "online", CPU: 0.15, Memory: models.Memory{Total: 1024, Used: 512, Free: 512, Usage: 0.5}, Disk: models.Disk{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, LinkedAgentID: "host-1", } host := models.Host{ ID: "host-1", Hostname: "pve1", Status: "online", Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, // Intentionally not setting LinkedNodeID to ensure one-sided links are ignored. } snapshot := models.StateSnapshot{ Nodes: []models.Node{node}, Hosts: []models.Host{host}, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 merged resource when one-sided link is corroborated, got %d", len(resp.Data)) } resource := resp.Data[0] if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceProxmox) { t.Fatalf("expected merged agent+proxmox sources, got %+v", resource.Sources) } if resource.DiscoveryTarget == nil { t.Fatalf("expected discovery target for merged host") } if resource.DiscoveryTarget.ResourceType != "agent" { t.Fatalf("discovery target type = %q, want agent", resource.DiscoveryTarget.ResourceType) } if resource.DiscoveryTarget.AgentID != "host-1" || resource.DiscoveryTarget.ResourceID != "host-1" { t.Fatalf("discovery target = %+v, want host-1/host-1", resource.DiscoveryTarget) } } func TestResourceListDoesNotMergeOneSidedLinkedHostWithoutHostnameCorroboration(t *testing.T) { now := time.Now().UTC() node := models.Node{ ID: "instance-pve1", Name: "pve1", Instance: "instance", Host: "https://pve1:8006", Status: "online", CPU: 0.15, Memory: models.Memory{Total: 1024, Used: 512, Free: 512, Usage: 0.5}, Disk: models.Disk{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, LinkedAgentID: "host-1", } host := models.Host{ ID: "host-1", Hostname: "minipc", Status: "online", Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, } snapshot := models.StateSnapshot{ Nodes: []models.Node{node}, Hosts: []models.Host{host}, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 2 { t.Fatalf("expected 2 resources without corroborating hostname, got %d", len(resp.Data)) } } func TestResourceListCollapsesClusterAndStandaloneNodeViewsByEndpoint(t *testing.T) { state := models.NewState() now := time.Now().UTC() state.Hosts = []models.Host{ { ID: "host-1", Hostname: "minipc.local", Status: "online", ReportIP: "10.0.0.5", Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, NetworkInterfaces: []models.HostNetworkInterface{ {Name: "eth0", Addresses: []string{"10.0.0.5/24"}}, }, }, } state.UpdateNodesForInstance("homelab-entry", []models.Node{ { ID: "homelab-minipc", Name: "minipc", Instance: "homelab-entry", ClusterName: "homelab", IsClusterMember: true, Host: "https://10.0.0.5:8006", Status: "online", LastSeen: now, }, }) state.UpdateNodesForInstance("minipc-standalone", []models.Node{ { ID: "standalone-minipc", Name: "minipc", Instance: "minipc-standalone", Host: "https://10.0.0.5:8006", Status: "online", LastSeen: now, }, }) snapshot := state.GetSnapshot() if len(snapshot.Nodes) != 1 { t.Fatalf("state snapshot nodes = %#v, want exactly 1 node", snapshot.Nodes) } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent&q=minipc", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 minipc resource, got %d", len(resp.Data)) } resource := resp.Data[0] if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceProxmox) { t.Fatalf("expected merged agent+proxmox sources, got %+v", resource.Sources) } if resource.Proxmox == nil || resource.Proxmox.ClusterName != "homelab" { t.Fatalf("expected proxmox cluster homelab, got %+v", resource.Proxmox) } } func TestResourceListDerivesProxmoxWorkloadParentFromUnifiedSeed(t *testing.T) { now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC) cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ { ID: "agent-delly", Type: unified.ResourceTypeAgent, Name: "delly", Status: unified.StatusOnline, LastSeen: now, Sources: []unified.DataSource{unified.SourceProxmox, unified.SourceAgent}, Identity: unified.ResourceIdentity{ MachineID: "machine-delly", Hostnames: []string{"delly"}, }, Proxmox: &unified.ProxmoxData{ SourceID: "homelab-delly", NodeName: "delly", ClusterName: "homelab", Instance: "delly", }, Agent: &unified.AgentData{ AgentID: "agent-source-delly", Hostname: "delly", }, }, { ID: "system-container-cloudflared", Type: unified.ResourceTypeSystemContainer, Name: "cloudflared", Status: unified.StatusOnline, LastSeen: now, Sources: []unified.DataSource{unified.SourceProxmox}, Identity: unified.ResourceIdentity{Hostnames: []string{"cloudflared"}}, Proxmox: &unified.ProxmoxData{ SourceID: "delly:delly:104", NodeName: "delly", ClusterName: "homelab", Instance: "delly", VMID: 104, }, }, }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=system-container&q=cloudflared&page=1&limit=20", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 cloudflared resource, got %d: %#v", len(resp.Data), resp.Data) } resource := resp.Data[0] if resource.ParentID == nil || *resource.ParentID != "agent-delly" { t.Fatalf("expected cloudflared parent agent-delly, got %+v", resource.ParentID) } if resource.ParentName != "delly" { t.Fatalf("expected cloudflared parentName delly, got %q", resource.ParentName) } if resource.Proxmox == nil || resource.Proxmox.NodeName != "delly" || resource.Proxmox.ClusterName != "homelab" { t.Fatalf("expected cloudflared Proxmox node metadata, got %+v", resource.Proxmox) } } func TestStateEndpointDerivesProxmoxWorkloadParentFromSupplementalRecords(t *testing.T) { now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC) hashedPassword, err := authpkg.HashPassword("password") if err != nil { t.Fatalf("hash password: %v", err) } dataPath := t.TempDir() InitSessionStore(dataPath) InitCSRFStore(dataPath) cfg := &config.Config{ DataPath: dataPath, AuthUser: "admin", AuthPass: hashedPassword, } monitor, err := monitoring.New(cfg) if err != nil { t.Fatalf("new monitor: %v", err) } t.Cleanup(func() { monitor.Stop() }) monitor.SetResourceStore(unified.NewMonitorAdapter(nil)) monitor.SetSupplementalRecordsProvider(unified.SourceProxmox, mockSupplementalRecordsProvider{ records: []unified.IngestRecord{ { SourceID: "homelab-delly", Resource: unified.Resource{ Type: unified.ResourceTypeAgent, Name: "delly", Status: unified.StatusOnline, LastSeen: now, Proxmox: &unified.ProxmoxData{ SourceID: "homelab-delly", NodeName: "delly", ClusterName: "homelab", Instance: "delly", }, }, Identity: unified.ResourceIdentity{ MachineID: "machine-delly", Hostnames: []string{"delly"}, }, }, { SourceID: "delly:delly:104", Resource: unified.Resource{ Type: unified.ResourceTypeSystemContainer, Name: "cloudflared", Status: unified.StatusOnline, LastSeen: now, Proxmox: &unified.ProxmoxData{ SourceID: "delly:delly:104", NodeName: "delly", ClusterName: "homelab", Instance: "delly", VMID: 104, }, }, Identity: unified.ResourceIdentity{Hostnames: []string{"cloudflared"}}, }, }, }) router := &Router{config: cfg, monitor: monitor} rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/state", nil) req.SetBasicAuth("admin", "password") router.handleState(rec, req) if rec.Code != http.StatusOK { t.Fatalf("/api/state status = %d, body=%s", rec.Code, rec.Body.String()) } var state models.StateFrontend if err := json.NewDecoder(rec.Body).Decode(&state); err != nil { t.Fatalf("decode /api/state: %v", err) } dellyCount := 0 dellyID := "" cloudflaredParentID := "" for _, resource := range state.Resources { switch { case resource.Type == string(unified.ResourceTypeAgent) && resource.Name == "delly": dellyCount++ dellyID = resource.ID case resource.Type == string(unified.ResourceTypeSystemContainer) && resource.Name == "cloudflared": cloudflaredParentID = resource.ParentID } } if dellyCount != 1 { t.Fatalf("expected exactly one delly resource in /api/state, got %d: %#v", dellyCount, state.Resources) } if dellyID == "" { t.Fatalf("expected delly resource id in /api/state: %#v", state.Resources) } if cloudflaredParentID != dellyID { t.Fatalf("expected cloudflared parent %q, got %q: %#v", dellyID, cloudflaredParentID, state.Resources) } } func TestResourceListCollapsesAsymmetricLinkedClusterNodeViews(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ Nodes: []models.Node{ { ID: "homelab-minipc", Name: "minipc", Instance: "homelab-entry", ClusterName: "homelab", IsClusterMember: true, Host: "https://10.0.0.5:8006", LinkedAgentID: "host-1", Status: "online", LastSeen: now, }, { ID: "homelab-minipc-shadow", Name: "minipc", Instance: "homelab-shadow", ClusterName: "homelab", IsClusterMember: true, Host: "https://10.0.0.5:8006", Status: "online", LastSeen: now.Add(-time.Minute), }, }, Hosts: []models.Host{ { ID: "host-1", Hostname: "minipc.local", Status: "online", ReportIP: "10.0.0.5", MachineID: "machine-1", LinkedNodeID: "homelab-minipc", Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, NetworkInterfaces: []models.HostNetworkInterface{ {Name: "eth0", Addresses: []string{"10.0.0.5/24"}}, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent&q=minipc", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 minipc resource, got %d", len(resp.Data)) } resource := resp.Data[0] if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceProxmox) { t.Fatalf("expected merged agent+proxmox sources, got %+v", resource.Sources) } if resource.Proxmox == nil || resource.Proxmox.ClusterName != "homelab" { t.Fatalf("expected proxmox cluster homelab, got %+v", resource.Proxmox) } if resource.DiscoveryTarget == nil || resource.DiscoveryTarget.AgentID != "host-1" { t.Fatalf("expected merged discovery target for host-1, got %+v", resource.DiscoveryTarget) } } func TestResourceListCollapsesHostLinkedClusterNodeViews(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ Nodes: []models.Node{ { ID: "homelab-delly", Name: "delly", Instance: "homelab-entry", ClusterName: "homelab", IsClusterMember: true, Host: "https://10.0.0.9:8006", Status: "online", LastSeen: now, }, { ID: "homelab-delly-shadow", Name: "delly", Instance: "homelab-shadow", ClusterName: "homelab", IsClusterMember: true, Host: "https://10.0.0.9:8006", Status: "online", LastSeen: now.Add(-time.Minute), }, }, Hosts: []models.Host{ { ID: "host-1", Hostname: "delly.local", Status: "online", ReportIP: "10.0.0.9", MachineID: "machine-delly", LinkedNodeID: "homelab-delly", Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, NetworkInterfaces: []models.HostNetworkInterface{ {Name: "eth0", Addresses: []string{"10.0.0.9/24"}}, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent&q=delly", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 delly resource, got %d", len(resp.Data)) } resource := resp.Data[0] if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceProxmox) { t.Fatalf("expected merged agent+proxmox sources, got %+v", resource.Sources) } if resource.Proxmox == nil || resource.Proxmox.ClusterName != "homelab" { t.Fatalf("expected proxmox cluster homelab, got %+v", resource.Proxmox) } if resource.DiscoveryTarget == nil || resource.DiscoveryTarget.AgentID != "host-1" { t.Fatalf("expected merged discovery target for host-1, got %+v", resource.DiscoveryTarget) } } func TestResourceListCollapsesHostLinkedNodeViewsAcrossEndpointForms(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ Nodes: []models.Node{ { ID: "minipc-ip-view", Name: "minipc", Instance: "standalone-ip", Host: "https://10.0.0.5:8006", Status: "online", LastSeen: now, }, { ID: "minipc-hostname-view", Name: "minipc", Instance: "standalone-hostname", Host: "https://minipc.local:8006", Status: "online", LastSeen: now.Add(-time.Minute), }, }, Hosts: []models.Host{ { ID: "host-1", Hostname: "minipc.local", Status: "online", ReportIP: "10.0.0.5", MachineID: "machine-minipc", LinkedNodeID: "minipc-ip-view", Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, NetworkInterfaces: []models.HostNetworkInterface{ {Name: "eth0", Addresses: []string{"10.0.0.5/24"}}, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent&q=minipc", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 minipc resource, got %d", len(resp.Data)) } resource := resp.Data[0] if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceProxmox) { t.Fatalf("expected merged agent+proxmox sources, got %+v", resource.Sources) } if resource.DiscoveryTarget == nil || resource.DiscoveryTarget.AgentID != "host-1" { t.Fatalf("expected merged discovery target for host-1, got %+v", resource.DiscoveryTarget) } } func TestResourceListIncludesHostSMARTPhysicalDisks(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ Hosts: []models.Host{ { ID: "host-tower", Hostname: "tower", Status: "online", LastSeen: now, Disks: []models.Disk{ {Device: "/dev/sdb", Total: 12 * 1024, Mountpoint: "/mnt/disk1"}, }, Unraid: &models.HostUnraidStorage{ ArrayStarted: true, Disks: []models.HostUnraidDisk{ {Name: "parity", Device: "/dev/sdb", Role: "parity", Status: "online", Serial: "SERIAL-TOWER-1"}, }, }, Sensors: models.HostSensorSummary{ SMART: []models.HostDiskSMART{ { Device: "/dev/sdb", Model: "Seagate IronWolf", Serial: "SERIAL-TOWER-1", Type: "sata", Temperature: 37, Health: "PASSED", }, }, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=physical_disk", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 tower physical disk resource, got %d", len(resp.Data)) } resource := resp.Data[0] if !containsSource(resource.Sources, unified.SourceAgent) { t.Fatalf("expected agent-backed physical disk source, got %+v", resource.Sources) } if resource.PhysicalDisk == nil || resource.PhysicalDisk.Serial != "SERIAL-TOWER-1" { t.Fatalf("expected SMART-backed physical disk metadata, got %+v", resource.PhysicalDisk) } if resource.PhysicalDisk.StorageRole != "parity" { t.Fatalf("storageRole = %q, want parity", resource.PhysicalDisk.StorageRole) } if resource.MetricsTarget == nil || resource.MetricsTarget.ResourceType != "disk" || resource.MetricsTarget.ResourceID != "SERIAL-TOWER-1" { t.Fatalf("expected disk metrics target SERIAL-TOWER-1, got %+v", resource.MetricsTarget) } } func TestResourceListUsesCanonicalMetricIDForProxmoxPhysicalDisks(t *testing.T) { snapshot := models.StateSnapshot{ PhysicalDisks: []models.PhysicalDisk{ { ID: "pve1-node1-/dev-sda", Instance: "pve1", Node: "node1", DevPath: "/dev/sda", Model: "Exos", Serial: "SERIAL-PVE-1", Temperature: 34, LastChecked: time.Now().UTC(), }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=physical_disk", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 proxmox physical disk resource, got %d", len(resp.Data)) } resource := resp.Data[0] if resource.MetricsTarget == nil || resource.MetricsTarget.ResourceType != "disk" || resource.MetricsTarget.ResourceID != "SERIAL-PVE-1" { t.Fatalf("expected canonical disk metrics target SERIAL-PVE-1, got %+v", resource.MetricsTarget) } } func TestResourceGetResource(t *testing.T) { now := time.Now().UTC() host := models.Host{ ID: "host-1", Hostname: "pve1", Status: "online", Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, } snapshot := models.StateSnapshot{Hosts: []models.Host{host}} cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) listRec := httptest.NewRecorder() listReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host", nil) h.HandleListResources(listRec, listReq) var listResp ResourcesResponse if err := json.NewDecoder(listRec.Body).Decode(&listResp); err != nil { t.Fatalf("decode list response: %v", err) } if len(listResp.Data) != 1 { t.Fatalf("expected 1 resource, got %d", len(listResp.Data)) } resourceID := listResp.Data[0].ID getRec := httptest.NewRecorder() getReq := httptest.NewRequest(http.MethodGet, "/api/resources/"+resourceID, nil) h.HandleGetResource(getRec, getReq) if getRec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", getRec.Code, getRec.Body.String()) } var resource unified.Resource if err := json.NewDecoder(getRec.Body).Decode(&resource); err != nil { t.Fatalf("decode resource: %v", err) } if resource.ID != resourceID { t.Fatalf("resource id = %q, want %q", resource.ID, resourceID) } if resource.DiscoveryTarget == nil { t.Fatalf("expected discovery target on get resource") } if resource.DiscoveryTarget.ResourceType != "agent" { t.Fatalf("discovery target resourceType = %q, want agent", resource.DiscoveryTarget.ResourceType) } if resource.DiscoveryTarget.AgentID != "host-1" || resource.DiscoveryTarget.ResourceID != "host-1" { t.Fatalf("discovery target = %+v, want host-1/host-1", resource.DiscoveryTarget) } } func TestResourceGetFacetsAndTimeline(t *testing.T) { now := time.Date(2026, 3, 18, 17, 0, 0, 0, time.UTC) resource := unified.Resource{ ID: "vm:42", Type: unified.ResourceTypeVM, Name: "web-42", Status: unified.StatusOnline, LastSeen: now, Capabilities: []unified.ResourceCapability{ { Name: "restart", Type: unified.CapabilityTypeCommon, Description: "Restart the VM", MinimumApprovalLevel: unified.ApprovalAdmin, }, }, Relationships: []unified.ResourceRelationship{ { SourceID: "vm:42", TargetID: "node-1", Type: unified.RelRunsOn, Confidence: 1, Active: true, Discoverer: "proxmox_adapter", ObservedAt: now, LastSeenAt: now, Metadata: map[string]any{ "source": "live", "cluster": "pve-prod", }, }, }, } node := unified.Resource{ ID: "node-1", Type: unified.ResourceTypeAgent, Name: "pve-node-1", Status: unified.StatusOnline, LastSeen: now, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{resource, node}, }) store, err := h.getStore("default") if err != nil { t.Fatalf("getStore: %v", err) } if err := store.RecordChange(unified.ResourceChange{ ID: "chg-42", ResourceID: "vm:42", ObservedAt: now, OccurredAt: &now, Kind: unified.ChangeRestart, From: "offline", To: "online", SourceType: unified.SourcePlatformEvent, SourceAdapter: unified.AdapterProxmox, Confidence: unified.ConfidenceHigh, Reason: "vm started", RelatedResources: []string{"node-1"}, Metadata: map[string]any{"source": "snapshot", "ticket": "INC-1234"}, }); err != nil { t.Fatalf("RecordChange: %v", err) } for i, offset := range []time.Duration{2 * time.Minute, 4 * time.Minute} { sourceAdapter := unified.AdapterProxmox if i == 1 { sourceAdapter = unified.AdapterDocker } if err := store.RecordChange(unified.ResourceChange{ ID: fmt.Sprintf("chg-42-extra-%d", i+1), ResourceID: "vm:42", ObservedAt: now.Add(-offset), Kind: unified.ChangeAnomaly, SourceType: unified.SourcePulseDiff, SourceAdapter: sourceAdapter, Confidence: unified.ConfidenceMedium, Reason: "history backfill", RelatedResources: []string{"node-1"}, }); err != nil { t.Fatalf("RecordChange extra %d: %v", i+1, err) } } if err := store.RecordChange(unified.ResourceChange{ ID: "chg-node-direct", ResourceID: "node-1", ObservedAt: now.Add(-30 * time.Second), Kind: unified.ChangeStateTransition, SourceType: unified.SourcePulseDiff, SourceAdapter: unified.AdapterProxmox, Confidence: unified.ConfidenceMedium, Reason: "node state refreshed", }); err != nil { t.Fatalf("RecordChange node direct: %v", err) } if err := store.RecordChange(unified.ResourceChange{ ID: "chg-vmware-activity", ResourceID: "vc-1:vm:vm-201", ObservedAt: now.Add(-15 * time.Second), Kind: unified.ChangeActivity, SourceType: unified.SourcePlatformEvent, SourceAdapter: unified.AdapterVMware, Confidence: unified.ConfidenceHigh, Reason: "Reconfigure virtual machine (success)", Metadata: map[string]any{ unified.MetadataActivityType: "vmware_task", "vmwareConnectionId": "vc-1", "vmwareEntityType": "vm", "vmwareManagedObjectId": "vm-201", }, }); err != nil { t.Fatalf("RecordChange VMware activity: %v", err) } t.Run("facets", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/facets?limit=1", nil) h.HandleResourceRoutes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var payload struct { ResourceID string `json:"resourceId"` Capabilities []unified.ResourceCapability `json:"capabilities"` Relationships []unified.ResourceRelationship `json:"relationships"` RecentChanges []unified.ResourceChange `json:"recentChanges"` Counts struct { RecentChanges int `json:"recentChanges"` RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"` RecentChangeSourceTypes map[unified.ChangeSourceType]int `json:"recentChangeSourceTypes"` RecentChangeSourceAdapters map[unified.ChangeSourceAdapter]int `json:"recentChangeSourceAdapters"` } `json:"counts"` } if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode facets: %v", err) } if payload.ResourceID != "vm:42" || payload.Counts.RecentChanges != 3 || len(payload.RecentChanges) != 1 { t.Fatalf("unexpected facets payload: %#v", payload) } if len(payload.Capabilities) != 1 || payload.Capabilities[0].Name != "restart" { t.Fatalf("unexpected facets capabilities: %#v", payload.Capabilities) } if len(payload.Relationships) != 1 || payload.Relationships[0].TargetID != "node-1" { t.Fatalf("unexpected facets relationships: %#v", payload.Relationships) } if got := payload.Counts.RecentChangeKinds; len(got) != 2 || got[unified.ChangeRestart] != 1 || got[unified.ChangeAnomaly] != 2 { t.Fatalf("unexpected recent change kind counts: %#v", got) } if got := payload.Counts.RecentChangeSourceTypes; len(got) != 2 || got[unified.SourcePlatformEvent] != 1 || got[unified.SourcePulseDiff] != 2 { t.Fatalf("unexpected recent change source type counts: %#v", got) } if got := payload.Counts.RecentChangeSourceAdapters; len(got) != 2 || got[unified.AdapterProxmox] != 2 || got[unified.AdapterDocker] != 1 { t.Fatalf("unexpected recent change source adapter counts: %#v", got) } if got := payload.RecentChanges[0].Metadata["ticket"]; got != "INC-1234" { t.Fatalf("unexpected change metadata: %#v", payload.RecentChanges[0].Metadata) } }) t.Run("timeline", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/timeline?limit=10", nil) h.HandleResourceRoutes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var payload struct { ResourceID string `json:"resourceId"` RecentChanges []unified.ResourceChange `json:"recentChanges"` Count int `json:"count"` } if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode timeline: %v", err) } if payload.ResourceID != "vm:42" || payload.Count != 3 || len(payload.RecentChanges) != 3 { t.Fatalf("unexpected timeline payload: %#v", payload) } if payload.RecentChanges[0].ID != "chg-42" { t.Fatalf("unexpected timeline change: %#v", payload.RecentChanges[0]) } if got := payload.RecentChanges[0].Metadata["ticket"]; got != "INC-1234" { t.Fatalf("unexpected timeline metadata: %#v", payload.RecentChanges[0].Metadata) } if got := payload.RecentChanges[0].RelatedResources; len(got) != 1 || got[0] != "node-1" { t.Fatalf("unexpected timeline related resources: %#v", got) } }) t.Run("relationship-aware timeline", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/node-1/timeline?limit=10", nil) h.HandleResourceRoutes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var payload struct { ResourceID string `json:"resourceId"` RecentChanges []unified.ResourceChange `json:"recentChanges"` Count int `json:"count"` } if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode relationship-aware timeline: %v", err) } if payload.ResourceID != "node-1" || payload.Count != 4 || len(payload.RecentChanges) != 4 { t.Fatalf("unexpected relationship-aware timeline payload: %#v", payload) } if payload.RecentChanges[0].ID != "chg-42" || payload.RecentChanges[1].ID != "chg-node-direct" { t.Fatalf("relationship-aware timeline order = %#v, want related vm event then direct node event", payload.RecentChanges) } if got := payload.RecentChanges[0].ResourceID; got != "vm:42" { t.Fatalf("related timeline preserves originating resource ID = %q, want vm:42", got) } }) t.Run("filtered timeline", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/timeline?kind=restart&sourceType=platform_event", nil) h.HandleResourceRoutes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var payload struct { ResourceID string `json:"resourceId"` RecentChanges []unified.ResourceChange `json:"recentChanges"` Count int `json:"count"` } if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode filtered timeline: %v", err) } if payload.ResourceID != "vm:42" || payload.Count != 1 || len(payload.RecentChanges) != 1 { t.Fatalf("unexpected filtered timeline payload: %#v", payload) } if payload.RecentChanges[0].ID != "chg-42" { t.Fatalf("unexpected filtered timeline change: %#v", payload.RecentChanges[0]) } }) t.Run("filtered timeline by source adapter", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/timeline?sourceAdapter=docker_adapter", nil) h.HandleResourceRoutes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var payload struct { ResourceID string `json:"resourceId"` RecentChanges []unified.ResourceChange `json:"recentChanges"` Count int `json:"count"` } if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode filtered timeline adapter: %v", err) } if payload.ResourceID != "vm:42" || payload.Count != 1 || len(payload.RecentChanges) != 1 { t.Fatalf("unexpected filtered timeline adapter payload: %#v", payload) } if payload.RecentChanges[0].ID != "chg-42-extra-2" { t.Fatalf("unexpected adapter-filtered change: %#v", payload.RecentChanges[0]) } if got := payload.RecentChanges[0].RelatedResources; len(got) != 1 || got[0] != "node-1" { t.Fatalf("unexpected adapter-filtered related resources: %#v", got) } }) t.Run("global filtered timeline", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/timeline?kind=activity&sourceType=platform_event&sourceAdapter=vmware_adapter&limit=10", nil) h.HandleListResourceTimeline(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var payload struct { ResourceID string `json:"resourceId"` RecentChanges []unified.ResourceChange `json:"recentChanges"` Count int `json:"count"` } if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode global timeline: %v", err) } if payload.ResourceID != "" || payload.Count != 1 || len(payload.RecentChanges) != 1 { t.Fatalf("unexpected global timeline payload: %#v", payload) } if payload.RecentChanges[0].ID != "chg-vmware-activity" { t.Fatalf("unexpected global timeline change: %#v", payload.RecentChanges[0]) } if got := payload.RecentChanges[0].Metadata[unified.MetadataActivityType]; got != "vmware_task" { t.Fatalf("unexpected VMware activity metadata: %#v", payload.RecentChanges[0].Metadata) } }) t.Run("filtered facets", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/facets?kind=restart&sourceType=platform_event", nil) h.HandleResourceRoutes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var payload struct { ResourceID string `json:"resourceId"` RecentChanges []unified.ResourceChange `json:"recentChanges"` Counts struct { RecentChanges int `json:"recentChanges"` RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"` RecentChangeSourceTypes map[unified.ChangeSourceType]int `json:"recentChangeSourceTypes"` RecentChangeSourceAdapters map[unified.ChangeSourceAdapter]int `json:"recentChangeSourceAdapters"` } `json:"counts"` } if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode filtered facets: %v", err) } if payload.ResourceID != "vm:42" || payload.Counts.RecentChanges != 1 || len(payload.RecentChanges) != 1 { t.Fatalf("unexpected filtered facets payload: %#v", payload) } if got := payload.Counts.RecentChangeKinds; len(got) != 1 || got[unified.ChangeRestart] != 1 { t.Fatalf("unexpected filtered facet kind counts: %#v", got) } if got := payload.Counts.RecentChangeSourceTypes; len(got) != 1 || got[unified.SourcePlatformEvent] != 1 { t.Fatalf("unexpected filtered facet source type counts: %#v", got) } if got := payload.Counts.RecentChangeSourceAdapters; len(got) != 1 || got[unified.AdapterProxmox] != 1 { t.Fatalf("unexpected filtered facet source adapter counts: %#v", got) } if payload.RecentChanges[0].ID != "chg-42" { t.Fatalf("unexpected filtered facets change: %#v", payload.RecentChanges[0]) } }) t.Run("relationship-aware filtered facets", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/node-1/facets?kind=restart&limit=10", nil) h.HandleResourceRoutes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var payload struct { ResourceID string `json:"resourceId"` RecentChanges []unified.ResourceChange `json:"recentChanges"` Counts struct { RecentChanges int `json:"recentChanges"` RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"` } `json:"counts"` } if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode relationship-aware filtered facets: %v", err) } if payload.ResourceID != "node-1" || payload.Counts.RecentChanges != 1 || len(payload.RecentChanges) != 1 { t.Fatalf("unexpected relationship-aware filtered facets payload: %#v", payload) } if payload.RecentChanges[0].ID != "chg-42" { t.Fatalf("unexpected relationship-aware filtered facets change: %#v", payload.RecentChanges[0]) } if got := payload.Counts.RecentChangeKinds[unified.ChangeRestart]; got != 1 { t.Fatalf("relationship-aware restart facet count = %d, want 1", got) } }) t.Run("filtered facets by source adapter", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/facets?sourceAdapter=docker_adapter", nil) h.HandleResourceRoutes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var payload struct { ResourceID string `json:"resourceId"` RecentChanges []unified.ResourceChange `json:"recentChanges"` Counts struct { RecentChanges int `json:"recentChanges"` RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"` RecentChangeSourceTypes map[unified.ChangeSourceType]int `json:"recentChangeSourceTypes"` RecentChangeSourceAdapters map[unified.ChangeSourceAdapter]int `json:"recentChangeSourceAdapters"` } `json:"counts"` } if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode filtered facets adapter: %v", err) } if payload.ResourceID != "vm:42" || payload.Counts.RecentChanges != 1 || len(payload.RecentChanges) != 1 { t.Fatalf("unexpected filtered facets adapter payload: %#v", payload) } if got := payload.Counts.RecentChangeKinds; len(got) != 1 || got[unified.ChangeAnomaly] != 1 { t.Fatalf("unexpected adapter-filtered facet kind counts: %#v", got) } if got := payload.Counts.RecentChangeSourceTypes; len(got) != 1 || got[unified.SourcePulseDiff] != 1 { t.Fatalf("unexpected adapter-filtered facet source type counts: %#v", got) } if got := payload.Counts.RecentChangeSourceAdapters; len(got) != 1 || got[unified.AdapterDocker] != 1 { t.Fatalf("unexpected adapter-filtered facet source adapter counts: %#v", got) } if payload.RecentChanges[0].ID != "chg-42-extra-2" { t.Fatalf("unexpected adapter-filtered facets change: %#v", payload.RecentChanges[0]) } }) t.Run("invalid source adapter filter", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/timeline?sourceAdapter=unknown_adapter", nil) h.HandleResourceRoutes(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } }) } func containsSource(sources []unified.DataSource, target unified.DataSource) bool { for _, source := range sources { if source == target { return true } } return false } func TestResourceLinkMergesResources(t *testing.T) { now := time.Now().UTC() host := models.Host{ ID: "host-1", Hostname: "alpha", Status: "online", Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, } dockerHost := models.DockerHost{ ID: "docker-1", Hostname: "beta", Status: "online", CPUs: 4, Memory: models.Memory{Total: 4096, Used: 1024, Free: 3072, Usage: 0.25}, LastSeen: now, } snapshot := models.StateSnapshot{Hosts: []models.Host{host}, DockerHosts: []models.DockerHost{dockerHost}} cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) listRec := httptest.NewRecorder() listReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host", nil) h.HandleListResources(listRec, listReq) var listResp ResourcesResponse if err := json.NewDecoder(listRec.Body).Decode(&listResp); err != nil { t.Fatalf("decode list response: %v", err) } if len(listResp.Data) != 2 { t.Fatalf("expected 2 resources before link, got %d", len(listResp.Data)) } primaryID := listResp.Data[0].ID secondaryID := listResp.Data[1].ID linkPayload := map[string]string{"targetId": secondaryID, "reason": "manual merge"} payloadBytes, _ := json.Marshal(linkPayload) linkRec := httptest.NewRecorder() linkReq := httptest.NewRequest(http.MethodPost, "/api/resources/"+primaryID+"/link", bytes.NewReader(payloadBytes)) h.HandleLink(linkRec, linkReq) if linkRec.Code != http.StatusOK { t.Fatalf("link status = %d, body=%s", linkRec.Code, linkRec.Body.String()) } listRec2 := httptest.NewRecorder() listReq2 := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host", nil) h.HandleListResources(listRec2, listReq2) var listResp2 ResourcesResponse if err := json.NewDecoder(listRec2.Body).Decode(&listResp2); err != nil { t.Fatalf("decode list response: %v", err) } if len(listResp2.Data) != 1 { t.Fatalf("expected 1 resource after link, got %d", len(listResp2.Data)) } resource := listResp2.Data[0] if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceDocker) { t.Fatalf("expected merged sources, got %+v", resource.Sources) } } func TestResourceReportMergeCreatesExclusions(t *testing.T) { now := time.Now().UTC() sharedInterfaces := []models.HostNetworkInterface{ { Name: "eth0", MAC: "aa:bb:cc:dd:ee:ff", Addresses: []string{"10.0.0.5"}, }, } host := models.Host{ ID: "host-1", Hostname: "alpha", Status: "online", Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5}, LastSeen: now, NetworkInterfaces: sharedInterfaces, } dockerHost := models.DockerHost{ ID: "docker-1", Hostname: "alpha", Status: "online", CPUs: 4, Memory: models.Memory{Total: 4096, Used: 2048, Free: 2048, Usage: 0.5}, LastSeen: now, NetworkInterfaces: sharedInterfaces, } snapshot := models.StateSnapshot{Hosts: []models.Host{host}, DockerHosts: []models.DockerHost{dockerHost}} cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) listRec := httptest.NewRecorder() listReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host", nil) h.HandleListResources(listRec, listReq) if listRec.Code != http.StatusOK { t.Fatalf("list status = %d, body=%s", listRec.Code, listRec.Body.String()) } var listResp ResourcesResponse if err := json.NewDecoder(listRec.Body).Decode(&listResp); err != nil { t.Fatalf("decode list response: %v", err) } if len(listResp.Data) != 1 { t.Fatalf("expected 1 merged resource, got %d", len(listResp.Data)) } resourceID := listResp.Data[0].ID reportPayload := map[string]any{ "sources": []string{"agent", "docker"}, "notes": "incorrect merge", } reportBytes, _ := json.Marshal(reportPayload) reportRec := httptest.NewRecorder() reportReq := httptest.NewRequest(http.MethodPost, "/api/resources/"+resourceID+"/report-merge", bytes.NewReader(reportBytes)) h.HandleReportMerge(reportRec, reportReq) if reportRec.Code != http.StatusOK { t.Fatalf("report-merge status = %d, body=%s", reportRec.Code, reportRec.Body.String()) } listRec2 := httptest.NewRecorder() listReq2 := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host", nil) h.HandleListResources(listRec2, listReq2) if listRec2.Code != http.StatusOK { t.Fatalf("list status = %d, body=%s", listRec2.Code, listRec2.Body.String()) } var listResp2 ResourcesResponse if err := json.NewDecoder(listRec2.Body).Decode(&listResp2); err != nil { t.Fatalf("decode list response: %v", err) } if len(listResp2.Data) != 2 { t.Fatalf("expected 2 resources after report-merge, got %d", len(listResp2.Data)) } } func TestResourceListIncludesKubernetesPods(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ KubernetesClusters: []models.KubernetesCluster{ { ID: "cluster-1", AgentID: "agent-1", Name: "prod-k8s", Context: "prod", Status: "online", LastSeen: now, Version: "1.31.2", Hidden: false, Pods: []models.KubernetesPod{ { UID: "pod-1", Name: "api-7f8d", Namespace: "default", NodeName: "worker-1", Phase: "Running", Containers: []models.KubernetesPodContainer{ {Name: "api", Image: "ghcr.io/acme/api:1.2.3", Ready: true, State: "Running"}, }, }, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pod", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 kubernetes pod resource, got %d", len(resp.Data)) } resource := resp.Data[0] if resource.Type != unified.ResourceTypePod { t.Fatalf("resource type = %q, want %q", resource.Type, unified.ResourceTypePod) } if !containsSource(resource.Sources, unified.SourceK8s) { t.Fatalf("expected kubernetes source, got %+v", resource.Sources) } if resource.Kubernetes == nil || resource.Kubernetes.Namespace != "default" { t.Fatalf("expected kubernetes namespace metadata, got %+v", resource.Kubernetes) } if resource.DiscoveryTarget == nil { t.Fatalf("expected discovery target for kubernetes pod") } if resource.DiscoveryTarget.ResourceType != string(unified.ResourceTypePod) { t.Fatalf("discovery target type = %q, want %q", resource.DiscoveryTarget.ResourceType, unified.ResourceTypePod) } if resource.DiscoveryTarget.AgentID != "agent-1" { t.Fatalf("discovery target agentID = %q, want agent-1", resource.DiscoveryTarget.AgentID) } if resource.DiscoveryTarget.ResourceID != "pod-1" { t.Fatalf("discovery target resourceID = %q, want pod-1", resource.DiscoveryTarget.ResourceID) } if resource.MetricsTarget == nil { t.Fatalf("expected metrics target for kubernetes pod") } if resource.MetricsTarget.ResourceType != string(unified.ResourceTypePod) { t.Fatalf("metrics target type = %q, want %q", resource.MetricsTarget.ResourceType, unified.ResourceTypePod) } if resource.MetricsTarget.ResourceID != "k8s:cluster-1:pod:pod-1" { t.Fatalf("metrics target resourceID = %q, want %q", resource.MetricsTarget.ResourceID, "k8s:cluster-1:pod:pod-1") } } func TestResourceListProjectsKubernetesClusterAgentVersionOntoNode(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ KubernetesClusters: []models.KubernetesCluster{ { ID: "cluster-1", AgentID: "agent-1", Name: "prod-k8s", Context: "prod", Status: "online", LastSeen: now, Version: "1.31.2", AgentVersion: "5.1.34", Nodes: []models.KubernetesNode{ { UID: "node-1", Name: "worker-1", Ready: true, OSImage: "Ubuntu 24.04", }, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=k8s-node", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 kubernetes node resource, got %d", len(resp.Data)) } resource := resp.Data[0] if resource.Type != unified.ResourceTypeK8sNode { t.Fatalf("resource type = %q, want %q", resource.Type, unified.ResourceTypeK8sNode) } if resource.Agent != nil { t.Fatalf("expected pure k8s-node row without agent metadata, got %+v", resource.Agent) } if resource.Kubernetes == nil { t.Fatalf("expected kubernetes metadata") } if resource.Kubernetes.AgentID != "agent-1" { t.Fatalf("kubernetes agentID = %q, want agent-1", resource.Kubernetes.AgentID) } if resource.Kubernetes.AgentVersion != "5.1.34" { t.Fatalf("kubernetes agentVersion = %q, want 5.1.34", resource.Kubernetes.AgentVersion) } } func TestResourceListFiltersCanonicalKubernetesNamespace(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ KubernetesClusters: []models.KubernetesCluster{ { ID: "cluster-1", AgentID: "agent-1", Name: "prod-k8s", Context: "prod", Status: "online", LastSeen: now, Version: "1.31.2", Hidden: false, Pods: []models.KubernetesPod{ {UID: "pod-1", Name: "api-1", Namespace: "default", Phase: "Running"}, {UID: "pod-2", Name: "api-2", Namespace: "kube-system", Phase: "Running"}, }, Deployments: []models.KubernetesDeployment{ {UID: "dep-1", Name: "web", Namespace: "default", DesiredReplicas: 3, ReadyReplicas: 3}, {UID: "dep-2", Name: "dns", Namespace: "kube-system", DesiredReplicas: 2, ReadyReplicas: 2}, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pod,k8s-deployment&namespace=default", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 2 { t.Fatalf("expected 2 kubernetes resources for namespace=default, got %d", len(resp.Data)) } for _, resource := range resp.Data { if resource.Kubernetes == nil { t.Fatalf("expected kubernetes payload, got nil: %+v", resource) } if resource.Kubernetes.Namespace != "default" { t.Fatalf("expected namespace default, got %q (resource=%+v)", resource.Kubernetes.Namespace, resource) } } } func TestBuildDiscoveryTargetKubernetesPrefersAgentID(t *testing.T) { tests := []struct { name string resource unified.Resource wantType unified.ResourceType wantResourceID string }{ { name: "pod", resource: unified.Resource{ ID: "resource:pod:1", Type: unified.ResourceTypePod, Name: "api-1", Kubernetes: &unified.K8sData{ AgentID: "agent-k8s-1", ClusterID: "cluster-a", Namespace: "default", PodUID: "pod-uid-1", }, }, wantType: unified.ResourceTypePod, wantResourceID: "pod-uid-1", }, { name: "cluster", resource: unified.Resource{ ID: "resource:k8s-cluster:1", Type: unified.ResourceTypeK8sCluster, Name: "cluster-a", Kubernetes: &unified.K8sData{ AgentID: "agent-k8s-1", ClusterID: "cluster-a", }, }, wantType: unified.ResourceTypeK8sCluster, wantResourceID: "cluster-a", }, { name: "deployment", resource: unified.Resource{ ID: "resource:k8s-deployment:1", Type: unified.ResourceTypeK8sDeployment, Name: "web", Kubernetes: &unified.K8sData{ AgentID: "agent-k8s-1", ClusterID: "cluster-a", Namespace: "default", }, }, wantType: unified.ResourceTypeK8sDeployment, wantResourceID: "default/web", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { target := buildDiscoveryTarget(tt.resource) if target == nil { t.Fatalf("expected discovery target") } if target.ResourceType != string(tt.wantType) { t.Fatalf("resource type = %q, want %q", target.ResourceType, tt.wantType) } if target.AgentID != "agent-k8s-1" { t.Fatalf("agentID = %q, want agent-k8s-1", target.AgentID) } if target.ResourceID != tt.wantResourceID { t.Fatalf("resourceID = %q, want %q", target.ResourceID, tt.wantResourceID) } }) } } func TestBuildDiscoveryTargetProxmoxGuestUsesLinkedNodeAgentID(t *testing.T) { target := buildDiscoveryTarget(unified.Resource{ ID: "system-container:source:grafana", Type: unified.ResourceTypeSystemContainer, Name: "grafana", Proxmox: &unified.ProxmoxData{ NodeName: "delly", VMID: 124, LinkedAgentID: "agent-delly", }, }) if target == nil { t.Fatal("expected discovery target") } if target.ResourceType != "system-container" { t.Fatalf("resource type = %q, want system-container", target.ResourceType) } if target.AgentID != "agent-delly" { t.Fatalf("agentID = %q, want agent-delly", target.AgentID) } if target.ResourceID != "124" { t.Fatalf("resourceID = %q, want 124", target.ResourceID) } if target.Hostname != "grafana" { t.Fatalf("hostname = %q, want grafana", target.Hostname) } } func TestBuildDiscoveryTargetProxmoxGuestRequiresLinkedNodeAgentID(t *testing.T) { target := buildDiscoveryTarget(unified.Resource{ ID: "vm:source:db", Type: unified.ResourceTypeVM, Name: "db", Proxmox: &unified.ProxmoxData{ NodeName: "delly", VMID: 101, }, }) if target != nil { t.Fatalf("expected no discovery target without linked agent, got %+v", target) } } func TestBuildDiscoveryTargetDockerAppContainerUsesAgentAndContainerName(t *testing.T) { target := buildDiscoveryTarget(unified.Resource{ ID: "app-container:source:abc123", Type: unified.ResourceTypeAppContainer, Name: "customer-portal", Docker: &unified.DockerData{ AgentID: "agent-edge-01", ContainerID: "abc123def456", Hostname: "edge-apps-01", }, }) if target == nil { t.Fatal("expected discovery target") } if target.ResourceType != "app-container" { t.Fatalf("resource type = %q, want app-container", target.ResourceType) } if target.AgentID != "agent-edge-01" { t.Fatalf("agentID = %q, want agent-edge-01", target.AgentID) } if target.ResourceID != "customer-portal" { t.Fatalf("resourceID = %q, want customer-portal", target.ResourceID) } if target.Hostname != "edge-apps-01" { t.Fatalf("hostname = %q, want edge-apps-01", target.Hostname) } } func TestBuildDiscoveryTargetDockerHostUsesAgentID(t *testing.T) { target := buildDiscoveryTarget(unified.Resource{ ID: "agent:docker-host", Type: unified.ResourceTypeAgent, Name: "Edge Apps 01", Docker: &unified.DockerData{ AgentID: "agent-edge-01", Hostname: "edge-apps-01", }, }) if target == nil { t.Fatal("expected discovery target") } if target.ResourceType != "agent" { t.Fatalf("resource type = %q, want agent", target.ResourceType) } if target.AgentID != "agent-edge-01" || target.ResourceID != "agent-edge-01" { t.Fatalf("target = %+v, want agent-edge-01/agent-edge-01", target) } } func TestK8sNamespacesEndpointAggregatesPodsAndDeployments(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ KubernetesClusters: []models.KubernetesCluster{ { ID: "cluster-1", AgentID: "agent-1", Name: "prod-k8s", Context: "prod", Status: "online", LastSeen: now, Version: "1.31.2", Hidden: false, Pods: []models.KubernetesPod{ {UID: "pod-1", Name: "api-1", Namespace: "default", Phase: "Running"}, {UID: "pod-2", Name: "api-2", Namespace: "default", Phase: "Pending"}, {UID: "pod-3", Name: "dns-1", Namespace: "kube-system", Phase: "Running"}, }, Deployments: []models.KubernetesDeployment{ {UID: "dep-1", Name: "web", Namespace: "default", DesiredReplicas: 3, ReadyReplicas: 3, AvailableReplicas: 3}, {UID: "dep-2", Name: "dns", Namespace: "kube-system", DesiredReplicas: 2, ReadyReplicas: 1, AvailableReplicas: 1}, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/k8s/namespaces?cluster=prod-k8s", nil) h.HandleK8sNamespaces(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp struct { Cluster string `json:"cluster"` Data []struct { Namespace string `json:"namespace"` Pods struct { Total int `json:"total"` Online int `json:"online"` Warning int `json:"warning"` Offline int `json:"offline"` Unknown int `json:"unknown"` } `json:"pods"` Deployments struct { Total int `json:"total"` Online int `json:"online"` Warning int `json:"warning"` Offline int `json:"offline"` Unknown int `json:"unknown"` } `json:"deployments"` } `json:"data"` } if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.Cluster != "prod-k8s" { t.Fatalf("cluster = %q, want prod-k8s", resp.Cluster) } byNS := make(map[string]struct { Namespace string `json:"namespace"` Pods struct { Total int `json:"total"` Online int `json:"online"` Warning int `json:"warning"` Offline int `json:"offline"` Unknown int `json:"unknown"` } `json:"pods"` Deployments struct { Total int `json:"total"` Online int `json:"online"` Warning int `json:"warning"` Offline int `json:"offline"` Unknown int `json:"unknown"` } `json:"deployments"` }) for _, row := range resp.Data { byNS[row.Namespace] = row } if len(byNS) != 2 { t.Fatalf("expected 2 namespaces, got %d (%+v)", len(byNS), resp.Data) } // default: 2 pods (one running=online, one pending=warning), 1 deployment (ready=online) defaultRow, ok := byNS["default"] if !ok { t.Fatalf("expected default namespace row") } if defaultRow.Pods.Total != 2 || defaultRow.Pods.Online != 1 || defaultRow.Pods.Warning != 1 { t.Fatalf("default pods = %+v, want total=2 online=1 warning=1", defaultRow.Pods) } if defaultRow.Deployments.Total != 1 || defaultRow.Deployments.Online != 1 { t.Fatalf("default deployments = %+v, want total=1 online=1", defaultRow.Deployments) } kubeSystemRow, ok := byNS["kube-system"] if !ok { t.Fatalf("expected kube-system namespace row") } if kubeSystemRow.Pods.Total != 1 || kubeSystemRow.Pods.Online != 1 { t.Fatalf("kube-system pods = %+v, want total=1 online=1", kubeSystemRow.Pods) } if kubeSystemRow.Deployments.Total != 1 || kubeSystemRow.Deployments.Warning != 1 { t.Fatalf("kube-system deployments = %+v, want total=1 warning=1", kubeSystemRow.Deployments) } } func TestK8sNamespacesResponseUsesCanonicalEmptyCollections(t *testing.T) { payload, err := json.Marshal(emptyK8sNamespacesResponse()) if err != nil { t.Fatalf("marshal empty k8s namespaces response: %v", err) } var decoded map[string]any if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("decode empty k8s namespaces response: %v", err) } data, ok := decoded["data"].([]any) if !ok || len(data) != 0 { t.Fatalf("expected data to be an empty array, got %T (%v)", decoded["data"], decoded["data"]) } } func TestResourceAndStorageResponsesUseCanonicalEmptyCollections(t *testing.T) { tests := []struct { name string raw any keys []string }{ {name: "resources_data", raw: EmptyResourcesResponse(), keys: []string{"data"}}, {name: "resources_by_type", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "byType"}}, {name: "resources_by_status", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "byStatus"}}, {name: "resources_by_source", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "bySource"}}, {name: "resources_policy_sensitivity", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "sensitivityCounts"}}, {name: "resources_policy_routing", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "routingCounts"}}, {name: "resources_policy_redaction", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "redactionCounts"}}, {name: "storage_summary_by_platform", raw: EmptyStorageSummaryResponse(), keys: []string{"byPlatform"}}, {name: "storage_summary_by_resource_type", raw: EmptyStorageSummaryResponse(), keys: []string{"byResourceType"}}, {name: "storage_summary_by_incident_category", raw: EmptyStorageSummaryResponse(), keys: []string{"byIncidentCategory"}}, {name: "storage_summary_top_incidents", raw: EmptyStorageSummaryResponse(), keys: []string{"topIncidents"}}, {name: "storage_incidents_by_category", raw: EmptyStorageIncidentsResponse(), keys: []string{"byCategory"}}, {name: "storage_incidents_by_urgency", raw: EmptyStorageIncidentsResponse(), keys: []string{"byUrgency"}}, {name: "storage_incidents_sections", raw: EmptyStorageIncidentsResponse(), keys: []string{"sections"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { payload, err := json.Marshal(tc.raw) if err != nil { t.Fatalf("marshal %s: %v", tc.name, err) } var decoded map[string]any if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("decode %s: %v", tc.name, err) } var current any = decoded for _, key := range tc.keys { obj, ok := current.(map[string]any) if !ok { current = nil break } current = obj[key] } switch tc.name { case "resources_data", "storage_summary_top_incidents", "storage_incidents_sections": values, ok := current.([]any) if !ok || len(values) != 0 { t.Fatalf("expected %s to be an empty array, got %T (%v)", tc.name, current, current) } default: values, ok := current.(map[string]any) if !ok || len(values) != 0 { t.Fatalf("expected %s to be an empty object, got %T (%v)", tc.name, current, current) } } }) } payload, err := json.Marshal(StorageIncidentsResponse{ Sections: []StorageIncidentSection{{ Category: "health", Label: "Health", }}, }.NormalizeCollections()) if err != nil { t.Fatalf("marshal normalized storage incidents: %v", err) } var decoded map[string]any if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("decode normalized storage incidents: %v", err) } sections := decoded["sections"].([]any) section := sections[0].(map[string]any) resources, ok := section["resources"].([]any) if !ok || len(resources) != 0 { t.Fatalf("expected section resources to be an empty array, got %T (%v)", section["resources"], section["resources"]) } } func TestResourceListRejectsLegacyKubernetesTypeAlias(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ KubernetesClusters: []models.KubernetesCluster{ { ID: "cluster-1", AgentID: "agent-1", Name: "prod-k8s", Status: "online", LastSeen: now, Nodes: []models.KubernetesNode{ { UID: "node-1", Name: "worker-1", Ready: true, }, }, Pods: []models.KubernetesPod{ { UID: "pod-1", Name: "api-7f8d", Namespace: "default", Phase: "Running", }, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=k8s", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } if body := rec.Body.String(); !strings.Contains(body, "unsupported type filter token(s): k8s") { t.Fatalf("unexpected response body: %s", body) } } func TestResourceListReturnsCanonicalKubernetesMetricsTargets(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ KubernetesClusters: []models.KubernetesCluster{ { ID: "cluster-1", AgentID: "agent-1", Name: "prod-k8s", Status: "online", LastSeen: now, Nodes: []models.KubernetesNode{ { UID: "node-1", Name: "worker-1", Ready: true, }, }, Pods: []models.KubernetesPod{ { UID: "pod-1", Name: "api-7f8d", Namespace: "default", Phase: "Running", }, }, Deployments: []models.KubernetesDeployment{ { UID: "dep-1", Name: "web", Namespace: "default", DesiredReplicas: 2, ReadyReplicas: 2, }, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pod,k8s-node,k8s-deployment", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 3 { t.Fatalf("expected 3 kubernetes resources, got %d", len(resp.Data)) } wantByType := map[unified.ResourceType]unified.ResourceType{ unified.ResourceTypePod: unified.ResourceTypePod, unified.ResourceTypeK8sNode: unified.ResourceTypeK8sNode, unified.ResourceTypeK8sDeployment: unified.ResourceTypeK8sDeployment, } for _, resource := range resp.Data { wantTargetType, ok := wantByType[resource.Type] if !ok { t.Fatalf("unexpected resource type in response: %q", resource.Type) } if resource.MetricsTarget == nil { t.Fatalf("expected metrics target for %q", resource.Type) } if resource.MetricsTarget.ResourceType != string(wantTargetType) { t.Fatalf( "metrics target type for %q = %q, want %q", resource.Type, resource.MetricsTarget.ResourceType, wantTargetType, ) } } } func TestResourceListIncludesDockerSwarmServicesAndFiltersByCluster(t *testing.T) { now := time.Now().UTC() service := models.DockerService{ ID: "svc-1", Name: "web", Stack: "edge", Image: "nginx:1.27", Mode: "replicated", DesiredTasks: 3, RunningTasks: 2, EndpointPorts: []models.DockerServicePort{ {Protocol: "tcp", TargetPort: 80, PublishedPort: 8080, PublishMode: "ingress"}, }, } // Two Swarm nodes reporting the same service; unified ingest should de-dupe services per swarm cluster. host1 := models.DockerHost{ ID: "docker-1", AgentID: "agent-1", Hostname: "swarm-1", DisplayName: "swarm-1", Status: "online", CPUs: 4, TotalMemoryBytes: 8 * 1024 * 1024 * 1024, Memory: models.Memory{Total: 8 * 1024 * 1024 * 1024, Used: 2 * 1024 * 1024 * 1024, Free: 6 * 1024 * 1024 * 1024, Usage: 0.25}, LastSeen: now, IntervalSeconds: 5, Swarm: &models.DockerSwarmInfo{ ClusterID: "cluster-1", ClusterName: "prod-swarm", NodeID: "node-1", NodeRole: "manager", }, Services: []models.DockerService{service}, } host2 := models.DockerHost{ ID: "docker-2", AgentID: "agent-2", Hostname: "swarm-2", DisplayName: "swarm-2", Status: "online", CPUs: 4, TotalMemoryBytes: 8 * 1024 * 1024 * 1024, Memory: models.Memory{Total: 8 * 1024 * 1024 * 1024, Used: 1 * 1024 * 1024 * 1024, Free: 7 * 1024 * 1024 * 1024, Usage: 0.125}, LastSeen: now, IntervalSeconds: 5, Swarm: &models.DockerSwarmInfo{ ClusterID: "cluster-1", ClusterName: "prod-swarm", NodeID: "node-2", NodeRole: "worker", }, Services: []models.DockerService{service}, } snapshot := models.StateSnapshot{ DockerHosts: []models.DockerHost{host1, host2}, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) // Unfiltered-by-cluster: expect the service to show up exactly once. rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=docker-service", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 docker service resource (de-duped across swarm nodes), got %d", len(resp.Data)) } r := resp.Data[0] if r.Type != unified.ResourceTypeDockerService { t.Fatalf("resource type = %q, want %q", r.Type, unified.ResourceTypeDockerService) } if r.Docker == nil { t.Fatalf("expected docker payload on docker-service resource") } if r.Docker.ServiceID != "svc-1" || r.Name != "web" { t.Fatalf("unexpected service identity: name=%q serviceId=%q", r.Name, r.Docker.ServiceID) } if r.Identity.ClusterName != "prod-swarm" { t.Fatalf("identity.clusterName = %q, want prod-swarm", r.Identity.ClusterName) } // Cluster filter should also return the service. rec2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodGet, "/api/resources?type=docker-service&cluster=prod-swarm", nil) h.HandleListResources(rec2, req2) if rec2.Code != http.StatusOK { t.Fatalf("cluster status = %d, body=%s", rec2.Code, rec2.Body.String()) } var resp2 ResourcesResponse if err := json.NewDecoder(rec2.Body).Decode(&resp2); err != nil { t.Fatalf("decode cluster response: %v", err) } if len(resp2.Data) != 1 { t.Fatalf("expected 1 docker service resource for cluster filter, got %d", len(resp2.Data)) } } func TestResourceListIncludesPBSAndPMG(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ PBSInstances: []models.PBSInstance{ { ID: "pbs-1", Name: "pbs-main", Host: "https://pbs.example.com:8007", Status: "online", CPU: 14.2, Memory: 35.0, MemoryUsed: 4 * 1024 * 1024 * 1024, MemoryTotal: 12 * 1024 * 1024 * 1024, Uptime: 7200, ConnectionHealth: "connected", LastSeen: now, }, }, PMGInstances: []models.PMGInstance{ { ID: "pmg-1", Name: "pmg-main", Host: "https://pmg.example.com:8006", Status: "online", ConnectionHealth: "connected", LastSeen: now, LastUpdated: now, RelayDomains: []models.PMGRelayDomain{ {Domain: "example.com", Comment: "primary relay"}, }, DomainStats: []models.PMGDomainStat{ {Domain: "example.com", MailCount: 100, SpamCount: 5, VirusCount: 1, Bytes: 1234}, }, DomainStatsAsOf: now, MailStats: &models.PMGMailStats{ BytesIn: 1_500_000, BytesOut: 900_000, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pbs,pmg", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 2 { t.Fatalf("expected 2 resources, got %d", len(resp.Data)) } var gotPBS, gotPMG bool var pmgResourceID string for _, resource := range resp.Data { switch resource.Type { case unified.ResourceTypePBS: gotPBS = true if resource.PBS == nil { t.Fatalf("expected PBS payload, got nil") } if resource.DiscoveryTarget == nil || resource.DiscoveryTarget.ResourceType != "agent" { t.Fatalf("expected agent discovery target for PBS, got %+v", resource.DiscoveryTarget) } case unified.ResourceTypePMG: gotPMG = true pmgResourceID = resource.ID if resource.PMG == nil { t.Fatalf("expected PMG payload, got nil") } // List response should be summary-only (heavy fields pruned). if len(resource.PMG.RelayDomains) > 0 { t.Fatalf("expected relayDomains pruned from list response, got %+v", resource.PMG.RelayDomains) } if len(resource.PMG.DomainStats) > 0 { t.Fatalf("expected domainStats pruned from list response, got %+v", resource.PMG.DomainStats) } if resource.DiscoveryTarget == nil || resource.DiscoveryTarget.ResourceType != "agent" { t.Fatalf("expected agent discovery target for PMG, got %+v", resource.DiscoveryTarget) } } } if !gotPBS || !gotPMG { t.Fatalf("expected both PBS and PMG resources, got %+v", resp.Data) } // Detail response should include the heavy fields. if pmgResourceID == "" { t.Fatalf("expected pmg resource id to be set") } getRec := httptest.NewRecorder() getReq := httptest.NewRequest(http.MethodGet, "/api/resources/"+pmgResourceID, nil) h.HandleGetResource(getRec, getReq) if getRec.Code != http.StatusOK { t.Fatalf("get status = %d, body=%s", getRec.Code, getRec.Body.String()) } var pmgResource unified.Resource if err := json.NewDecoder(getRec.Body).Decode(&pmgResource); err != nil { t.Fatalf("decode pmg get response: %v", err) } if pmgResource.PMG == nil { t.Fatalf("expected PMG payload on get response, got nil") } if len(pmgResource.PMG.RelayDomains) == 0 { t.Fatalf("expected relayDomains on get response, got empty") } if len(pmgResource.PMG.DomainStats) == 0 { t.Fatalf("expected domainStats on get response, got empty") } } func TestResourceListIncludesStorageMetadata(t *testing.T) { snapshot := models.StateSnapshot{ Storage: []models.Storage{ { ID: "storage-1", Name: "ceph-rbd", Node: "pve-1", Instance: "cluster-a", Type: "rbd", Pool: "ceph/rbd-a", Content: "images,backup", Shared: true, Status: "available", Enabled: true, Active: true, Total: 1000, Used: 250, Free: 750, Usage: 25, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 storage resource, got %d", len(resp.Data)) } resource := resp.Data[0] if resource.Storage == nil { t.Fatalf("expected storage metadata payload") } if got, want := resource.Storage.Type, "rbd"; got != want { t.Fatalf("storage.type = %q, want %q", got, want) } if got, want := resource.Storage.Content, "images,backup"; got != want { t.Fatalf("storage.content = %q, want %q", got, want) } if got, want := resource.Storage.ContentTypes, []string{"images", "backup"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { t.Fatalf("storage.contentTypes = %v, want %v", got, want) } if got, want := resource.Storage.Pool, "ceph/rbd-a"; got != want { t.Fatalf("storage.pool = %q, want %q", got, want) } if !resource.Storage.Shared { t.Fatalf("expected storage.shared=true") } if !resource.Storage.IsCeph { t.Fatalf("expected storage.isCeph=true") } if resource.Storage.IsZFS { t.Fatalf("expected storage.isZfs=false") } if resource.Proxmox == nil || resource.Proxmox.NodeName != "pve-1" || resource.Proxmox.Instance != "cluster-a" { t.Fatalf("expected proxmox node/instance metadata to remain populated, got %+v", resource.Proxmox) } } func TestResourceListIncludesStorageConsumerImpact(t *testing.T) { snapshot := models.StateSnapshot{ Storage: []models.Storage{ { ID: "cluster-a-pve-1-local-lvm", Name: "local-lvm", Node: "pve-1", Instance: "cluster-a", Type: "lvmthin", Status: "available", Enabled: true, Active: true, }, { ID: "cluster-a-pve-1-media", Name: "media", Node: "pve-1", Instance: "cluster-a", Type: "dir", Status: "available", Enabled: true, Active: true, Path: "/mnt/pve/media", }, }, VMs: []models.VM{ { ID: "vm-100", Name: "app01", Node: "pve-1", Instance: "cluster-a", Status: "running", LastSeen: time.Now().UTC(), Disks: []models.Disk{ {Device: "local-lvm:vm-100-disk-0"}, {Device: "local-lvm:vm-100-disk-1"}, }, }, }, Containers: []models.Container{ { ID: "ct-200", Name: "media01", Node: "pve-1", Instance: "cluster-a", Status: "running", LastSeen: time.Now().UTC(), Disks: []models.Disk{ {Device: "/mnt/pve/media/subvol-200-disk-1"}, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 2 { t.Fatalf("expected 2 storage resources, got %d", len(resp.Data)) } local := findAPIResourceByNameAndNode(resp.Data, "local-lvm", "pve-1") if local.Storage == nil { t.Fatalf("expected local-lvm storage payload") } if got := local.Storage.ConsumerCount; got != 1 { t.Fatalf("local-lvm consumerCount = %d, want 1", got) } if got := local.Storage.ConsumerTypes; len(got) != 1 || got[0] != "vm" { t.Fatalf("local-lvm consumerTypes = %v, want [vm]", got) } if len(local.Storage.TopConsumers) != 1 { t.Fatalf("local-lvm topConsumers length = %d, want 1", len(local.Storage.TopConsumers)) } if consumer := local.Storage.TopConsumers[0]; consumer.Name != "app01" || consumer.ResourceType != unified.ResourceTypeVM || consumer.DiskCount != 2 { t.Fatalf("unexpected local-lvm top consumer %+v", consumer) } if got := local.Storage.ConsumerImpactSummary; got != "Affects 1 dependent resource: app01" { t.Fatalf("local-lvm consumerImpactSummary = %q", got) } media := findAPIResourceByNameAndNode(resp.Data, "media", "pve-1") if media.Storage == nil { t.Fatalf("expected media storage payload") } if got := media.Storage.ConsumerCount; got != 1 { t.Fatalf("media consumerCount = %d, want 1", got) } if len(media.Storage.TopConsumers) != 1 { t.Fatalf("media topConsumers length = %d, want 1", len(media.Storage.TopConsumers)) } if consumer := media.Storage.TopConsumers[0]; consumer.Name != "media01" || consumer.ResourceType != unified.ResourceTypeSystemContainer || consumer.DiskCount != 1 { t.Fatalf("unexpected media top consumer %+v", consumer) } if got := media.Storage.ConsumerImpactSummary; got != "Affects 1 dependent resource: media01" { t.Fatalf("media consumerImpactSummary = %q", got) } } func TestResourceListIncludesPBSStorageConsumerImpact(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ PBSInstances: []models.PBSInstance{ { ID: "pbs-1", Name: "pbs-main", Host: "https://pbs-main.local:8007", Status: "online", LastSeen: now, Datastores: []models.PBSDatastore{ { Name: "backup-store", Status: "online", }, }, }, }, PBSBackups: []models.PBSBackup{ { ID: "pbs-1/backup-store/vm/100", Instance: "pbs-main", Datastore: "backup-store", Namespace: "pve", BackupType: "vm", VMID: "100", BackupTime: now, }, { ID: "pbs-1/backup-store/ct/200", Instance: "pbs-main", Datastore: "backup-store", Namespace: "nat", BackupType: "ct", VMID: "200", BackupTime: now, }, }, VMs: []models.VM{ { ID: "vm-100", Name: "app01", Node: "pve-1", Instance: "pve", Status: "running", LastSeen: now, VMID: 100, }, }, Containers: []models.Container{ { ID: "ct-200", Name: "media01", Node: "pve-2", Instance: "pve-nat", Status: "running", LastSeen: now, VMID: 200, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 storage resource, got %d", len(resp.Data)) } datastore := findAPIStorageResourceByPlatform(resp.Data, "backup-store", "pbs", "datastore") if datastore.Storage == nil { t.Fatalf("expected PBS datastore storage payload, got %+v", datastore) } if got := datastore.Storage.ConsumerCount; got != 2 { t.Fatalf("backup-store consumerCount = %d, want 2", got) } if got := datastore.Storage.ConsumerTypes; len(got) != 2 || got[0] != "system-container" || got[1] != "vm" { t.Fatalf("backup-store consumerTypes = %v, want [system-container vm]", got) } if len(datastore.Storage.TopConsumers) != 2 { t.Fatalf("backup-store topConsumers length = %d, want 2", len(datastore.Storage.TopConsumers)) } if got := datastore.Storage.ConsumerImpactSummary; got != "Puts backups for 2 protected workloads at risk: media01, app01" { t.Fatalf("backup-store consumerImpactSummary = %q", got) } if !containsSource(datastore.Sources, unified.SourcePBS) { t.Fatalf("expected PBS source on datastore, got %+v", datastore.Sources) } if datastore.ParentID == nil { t.Fatalf("expected PBS datastore to remain parented under PBS instance") } if !hasAPIStorageConsumer(datastore.Storage.TopConsumers, "app01", unified.ResourceTypeVM, 1) { t.Fatalf("expected vm consumer on backup-store, got %+v", datastore.Storage.TopConsumers) } if !hasAPIStorageConsumer(datastore.Storage.TopConsumers, "media01", unified.ResourceTypeSystemContainer, 1) { t.Fatalf("expected container consumer on backup-store, got %+v", datastore.Storage.TopConsumers) } } func TestResourceListIncludesPBSPrimaryIncidentRollup(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ PBSInstances: []models.PBSInstance{ { ID: "pbs-1", Name: "pbs-main", Host: "https://pbs-main.local:8007", Status: "online", LastSeen: now, Datastores: []models.PBSDatastore{ { Name: "fast", Status: "online", Total: 100, Used: 96, Usage: 96, }, { Name: "archive", Status: "read_only", }, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pbs", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 pbs resource, got %d", len(resp.Data)) } resource := resp.Data[0] if resource.IncidentCount != 2 { t.Fatalf("incidentCount = %d, want 2", resource.IncidentCount) } if resource.IncidentCode != "capacity_runway_low" { t.Fatalf("incidentCode = %q, want capacity_runway_low", resource.IncidentCode) } if resource.IncidentSeverity != storagehealth.RiskCritical { t.Fatalf("incidentSeverity = %q, want %q", resource.IncidentSeverity, storagehealth.RiskCritical) } if resource.IncidentSummary != "PBS datastore fast is 96% full" { t.Fatalf("incidentSummary = %q", resource.IncidentSummary) } if resource.IncidentCategory != unified.IncidentCategoryRecoverability { t.Fatalf("incidentCategory = %q, want %q", resource.IncidentCategory, unified.IncidentCategoryRecoverability) } if resource.IncidentLabel != "Backup Coverage At Risk" { t.Fatalf("incidentLabel = %q", resource.IncidentLabel) } if resource.IncidentPriority != 4502 { t.Fatalf("incidentPriority = %d", resource.IncidentPriority) } if resource.IncidentImpactSummary != "Affects 2 backup datastores: archive, fast" { t.Fatalf("incidentImpactSummary = %q", resource.IncidentImpactSummary) } if resource.IncidentUrgency != unified.IncidentUrgencyNow { t.Fatalf("incidentUrgency = %q, want %q", resource.IncidentUrgency, unified.IncidentUrgencyNow) } if resource.IncidentAction != "Restore backup target health immediately to protect recoverability" { t.Fatalf("incidentAction = %q", resource.IncidentAction) } } func TestPBSResourceListIncludesProtectedWorkloadRollup(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ PBSInstances: []models.PBSInstance{ { ID: "pbs-1", Name: "pbs-main", Host: "https://pbs-main.local:8007", Status: "online", LastSeen: now, Datastores: []models.PBSDatastore{ { Name: "backup-store", Status: "online", Total: 100, Used: 96, }, }, JobHealthEvidence: []models.PBSJobHealthEvidence{{ ID: "sync-remote-a", Family: "sync", Store: "backup-store", LastRunState: "OK", LastRunUPID: "UPID:sync:1", LastRunEndtime: now.Add(-time.Hour).Unix(), NextRun: now.Add(time.Hour).Unix(), Confidence: "direct-task-match", EvidenceSource: "pbs-job-config", EvidenceScope: "configured-job", Freshness: models.PBSJobHealthFreshness{ ObservedAt: now, LastRunEndTime: now.Add(-time.Hour), NextRun: now.Add(time.Hour), State: "observed", }, Posture: "healthy", }}, }, }, PBSBackups: []models.PBSBackup{ { ID: "pbs-1/backup-store/vm/100", Instance: "pbs-main", Datastore: "backup-store", Namespace: "pve", BackupType: "vm", VMID: "100", BackupTime: now, }, { ID: "pbs-1/backup-store/ct/200", Instance: "pbs-main", Datastore: "backup-store", Namespace: "nat", BackupType: "ct", VMID: "200", BackupTime: now, }, }, VMs: []models.VM{ { ID: "vm-100", Name: "app01", Node: "pve-1", Instance: "pve", Status: "running", LastSeen: now, VMID: 100, }, }, Containers: []models.Container{ { ID: "ct-200", Name: "media01", Node: "pve-2", Instance: "pve-nat", Status: "running", LastSeen: now, VMID: 200, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pbs", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 PBS resource, got %d", len(resp.Data)) } pbs := resp.Data[0] if pbs.Type != unified.ResourceTypePBS || pbs.PBS == nil { t.Fatalf("expected PBS resource payload, got %+v", pbs) } if got := pbs.PBS.ProtectedWorkloadCount; got != 2 { t.Fatalf("protectedWorkloadCount = %d, want 2", got) } if got := pbs.PBS.ProtectedWorkloadTypes; len(got) != 2 || got[0] != "system-container" || got[1] != "vm" { t.Fatalf("protectedWorkloadTypes = %v, want [system-container vm]", got) } if got := pbs.PBS.ProtectedWorkloadNames; len(got) != 2 || got[0] != "media01" || got[1] != "app01" { t.Fatalf("protectedWorkloadNames = %v, want [media01 app01]", got) } if got := pbs.PBS.AffectedDatastoreCount; got != 1 { t.Fatalf("affectedDatastoreCount = %d, want 1", got) } if got := pbs.PBS.AffectedDatastores; len(got) != 1 || got[0] != "backup-store" { t.Fatalf("affectedDatastores = %v, want [backup-store]", got) } if got := pbs.PBS.AffectedDatastoreSummary; got != "Affects 1 backup datastore: backup-store" { t.Fatalf("affectedDatastoreSummary = %q", got) } if got := pbs.PBS.ProtectedWorkloadSummary; got != "Puts backups for 2 protected workloads at risk: media01, app01" { t.Fatalf("protectedWorkloadSummary = %q", got) } if got := pbs.PBS.PostureSummary; got != "Affects 1 backup datastore: backup-store. Puts backups for 2 protected workloads at risk: media01, app01" { t.Fatalf("postureSummary = %q", got) } if pbs.PBS.JobHealthEvidenceCount != 1 || len(pbs.PBS.JobHealthEvidence) != 1 { t.Fatalf("expected PBS job health evidence in API payload, got %+v", pbs.PBS) } if got := pbs.PBS.JobHealthEvidence[0]; got.Confidence != "direct-task-match" || got.EvidenceSource != "pbs-job-config" || got.EvidenceScope != "configured-job" || got.LastRunState != "OK" || got.LastRunUPID != "UPID:sync:1" { t.Fatalf("expected API payload to preserve PBS job evidence, got %+v", got) } } func TestResourceListIncludesHostUnraidStorage(t *testing.T) { snapshot := models.StateSnapshot{ Hosts: []models.Host{ { ID: "host-tower", Hostname: "tower", Status: "online", LastSeen: time.Now().UTC(), MachineID: "machine-tower", Disks: []models.Disk{ {Mountpoint: "/mnt/user", Total: 1000, Used: 400, Free: 600, Usage: 40}, }, Unraid: &models.HostUnraidStorage{ ArrayStarted: true, ArrayState: "STARTED", NumProtected: 1, Disks: []models.HostUnraidDisk{ {Name: "parity", Role: "parity", Status: "online"}, {Name: "disk1", Role: "data", Status: "online"}, }, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 storage resource, got %d", len(resp.Data)) } resource := resp.Data[0] if !containsSource(resource.Sources, unified.SourceAgent) { t.Fatalf("expected agent-backed storage source, got %+v", resource.Sources) } if resource.Storage == nil || resource.Storage.Type != "unraid-array" { t.Fatalf("expected unraid storage metadata, got %+v", resource.Storage) } if resource.Storage.Platform != "unraid" || resource.Storage.Protection != "single-parity" { t.Fatalf("expected unraid storage platform/protection, got %+v", resource.Storage) } if resource.Metrics == nil || resource.Metrics.Disk == nil || resource.Metrics.Disk.Percent != 40 { t.Fatalf("expected unraid storage capacity metric, got %+v", resource.Metrics) } if resource.MetricsTarget == nil || resource.MetricsTarget.ResourceType != "storage" || resource.MetricsTarget.ResourceID != "host-tower/storage:unraid-array" { t.Fatalf("expected unraid storage metrics target host-tower/storage:unraid-array, got %+v", resource.MetricsTarget) } } func TestResourceListReturnsCanonicalStorageMetricsTargets(t *testing.T) { t.Run("pbs datastore", func(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ PBSInstances: []models.PBSInstance{ { ID: "pbs-1", Name: "pbs-main", Host: "https://pbs.example.com:8007", Status: "online", LastSeen: now, Datastores: []models.PBSDatastore{ {Name: "archive", Status: "online", Total: 100, Used: 45, Free: 55, Usage: 45}, }, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } resource := findAPIStorageResourceByPlatform(resp.Data, "archive", "pbs", "datastore") if resource.ID == "" { t.Fatalf("expected PBS datastore resource in %#v", resp.Data) } if resource.MetricsTarget == nil || resource.MetricsTarget.ResourceType != "storage" || resource.MetricsTarget.ResourceID != "pbs-1/archive" { t.Fatalf("expected PBS storage metrics target pbs-1/archive, got %+v", resource.MetricsTarget) } }) t.Run("truenas pool", func(t *testing.T) { now := time.Now().UTC() cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: now}}) h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{ records: []unified.IngestRecord{ { SourceID: "pool:tank", Resource: unified.Resource{ ID: "storage:tank", Type: unified.ResourceTypeStorage, Name: "tank", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourceTrueNAS}, Metrics: &unified.ResourceMetrics{ Disk: &unified.MetricValue{Percent: 62}, }, Storage: &unified.StorageMeta{ Platform: "truenas", Topology: "pool", Protection: "zfs", }, }, }, }, ownedSources: []unified.DataSource{unified.SourceTrueNAS}, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage&source=truenas", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } resource := findAPIStorageResourceByPlatform(resp.Data, "tank", "truenas", "pool") if resource.ID == "" { t.Fatalf("expected TrueNAS pool resource in %#v", resp.Data) } if resource.MetricsTarget == nil || resource.MetricsTarget.ResourceType != "storage" || resource.MetricsTarget.ResourceID != "pool:tank" { t.Fatalf("expected TrueNAS storage metrics target pool:tank, got %+v", resource.MetricsTarget) } }) } func TestResourceStorageSummaryRollsUpIncidents(t *testing.T) { now := time.Now().UTC() cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ { ID: "pbs:main", Type: unified.ResourceTypePBS, Name: "pbs-main", Status: unified.StatusWarning, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourcePBS}, Incidents: []unified.ResourceIncident{{ Provider: "pulse", NativeID: "pbs-alert-1", Code: "pbs_datastore_state", Severity: storagehealth.RiskCritical, Summary: "PBS datastore archive is READ_ONLY", }}, PBS: &unified.PBSData{ ProtectedWorkloadCount: 2, AffectedDatastoreCount: 1, ProtectedWorkloadNames: []string{"media01", "app01"}, }, }, { ID: "storage:tower-array", Type: unified.ResourceTypeStorage, Name: "Tower Array", Status: unified.StatusWarning, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourceAgent}, Incidents: []unified.ResourceIncident{{ Provider: "pulse", NativeID: "unraid-alert-1", Code: "unraid_parity_unavailable", Severity: storagehealth.RiskWarning, Summary: "Unraid parity protection is unavailable", }}, Storage: &unified.StorageMeta{ Platform: "unraid", Topology: "array", Risk: &unified.StorageRisk{ Level: storagehealth.RiskWarning, Reasons: []unified.StorageRiskReason{{ Code: "unraid_parity_unavailable", Severity: storagehealth.RiskWarning, Summary: "Unraid parity protection is unavailable", }}, }, ProtectionReduced: true, ConsumerCount: 3, ConsumerImpactSummary: "Affects 3 dependent resources: media01, app01, and 1 more", }, }, { ID: "disk:serial-1", Type: unified.ResourceTypePhysicalDisk, Name: "SERIAL-1", Status: unified.StatusWarning, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourceAgent}, Incidents: []unified.ResourceIncident{{ Provider: "pulse", NativeID: "disk-alert-1", Code: "smart_pending_sectors", Severity: storagehealth.RiskWarning, Summary: "Disk has pending sectors", }}, PhysicalDisk: &unified.PhysicalDiskMeta{Serial: "SERIAL-1"}, }, }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/storage-summary", nil) h.HandleStorageSummary(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp StorageSummaryResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.TotalResources != 3 { t.Fatalf("totalResources = %d, want 3", resp.TotalResources) } if resp.RiskyResources != 3 || resp.CriticalResources != 1 || resp.WarningResources != 2 { t.Fatalf("unexpected severity counts %+v", resp) } if resp.ProtectionReducedCount != 1 { t.Fatalf("protectionReducedCount = %d, want 1", resp.ProtectionReducedCount) } if got := resp.ByIncidentCategory[unified.IncidentCategoryRecoverability]; got != 1 { t.Fatalf("recoverability count = %d, want 1", got) } if got := resp.ByIncidentCategory[unified.IncidentCategoryProtection]; got != 1 { t.Fatalf("protection count = %d, want 1", got) } if got := resp.ByIncidentCategory[unified.IncidentCategoryDiskHealth]; got != 1 { t.Fatalf("disk-health count = %d, want 1", got) } if got := resp.ByPlatform["pbs"]; got != 1 { t.Fatalf("pbs platform count = %d, want 1", got) } if got := resp.ByPlatform["unraid"]; got != 1 { t.Fatalf("unraid platform count = %d, want 1", got) } if len(resp.TopIncidents) != 3 { t.Fatalf("topIncidents len = %d, want 3", len(resp.TopIncidents)) } if resp.TopIncidents[0].ResourceID != "pbs:main" || resp.TopIncidents[0].IncidentCategory != unified.IncidentCategoryRecoverability { t.Fatalf("expected PBS recoverability incident first, got %+v", resp.TopIncidents[0]) } if resp.TopIncidents[1].ResourceID != "storage:tower-array" || !resp.TopIncidents[1].ProtectionReduced { t.Fatalf("expected unraid protection incident second, got %+v", resp.TopIncidents[1]) } if resp.TopIncidents[2].ResourceID != "disk:serial-1" { t.Fatalf("expected disk incident third, got %+v", resp.TopIncidents[2]) } } func TestResourceListIncludesTrueNASPhysicalDiskTemperature(t *testing.T) { previous := truenas.IsFeatureEnabled() truenas.SetFeatureEnabled(true) t.Cleanup(func() { truenas.SetFeatureEnabled(previous) }) now := time.Now().UTC() cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: now}}) h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{ records: truenas.NewProvider(truenas.DefaultFixtures()).Records(), ownedSources: []unified.DataSource{unified.SourceTrueNAS}, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=physical_disk&source=truenas", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) == 0 { t.Fatal("expected TrueNAS physical disk resources") } var sda unified.Resource foundSDA := false var sdc unified.Resource foundSDC := false for _, candidate := range resp.Data { if candidate.Name == "sda" { sda = candidate foundSDA = true } if candidate.Name == "sdc" { sdc = candidate foundSDC = true } } if !foundSDA { t.Fatalf("expected sda in TrueNAS physical disk response, got %+v", resp.Data) } if !foundSDC { t.Fatalf("expected sdc in TrueNAS physical disk response, got %+v", resp.Data) } if sda.PhysicalDisk == nil { t.Fatalf("expected sda physical-disk metadata, got %+v", sda) } if sda.PhysicalDisk.Temperature != 34 { t.Fatalf("expected sda temperature 34, got %+v", sda.PhysicalDisk) } if sda.MetricsTarget == nil || sda.MetricsTarget.ResourceType != "disk" || sda.MetricsTarget.ResourceID != "ZL0A1234" { t.Fatalf("expected canonical disk metrics target ZL0A1234, got %+v", sda.MetricsTarget) } if sdc.PhysicalDisk == nil || sdc.PhysicalDisk.Risk == nil { t.Fatalf("expected sdc disk risk payload, got %+v", sdc) } if sdc.PhysicalDisk.Risk.Level != storagehealth.RiskWarning { t.Fatalf("expected sdc risk level warning, got %+v", sdc.PhysicalDisk.Risk) } foundSmartReason := false for _, reason := range sdc.PhysicalDisk.Risk.Reasons { if reason.Code == "truenas_smart" { foundSmartReason = true break } } if !foundSmartReason { t.Fatalf("expected sdc SMART-backed risk reason, got %+v", sdc.PhysicalDisk.Risk.Reasons) } } func TestResourceListIncludesTrueNASAppsAsAppContainers(t *testing.T) { previous := truenas.IsFeatureEnabled() truenas.SetFeatureEnabled(true) t.Cleanup(func() { truenas.SetFeatureEnabled(previous) }) fixtures := truenas.DefaultFixtures() cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: time.Now().UTC()}}) h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{ records: truenas.NewProvider(fixtures).Records(), ownedSources: []unified.DataSource{unified.SourceTrueNAS}, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=app-container&source=truenas", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != len(fixtures.Apps) { t.Fatalf("expected %d TrueNAS app-container resources, got %d", len(fixtures.Apps), len(resp.Data)) } var nextcloud *unified.Resource for i := range resp.Data { resource := &resp.Data[i] if resource.Name == "Nextcloud" { nextcloud = resource break } } if nextcloud == nil { t.Fatalf("expected Nextcloud in TrueNAS app-container response, got %+v", resp.Data) } if !containsSource(nextcloud.Sources, unified.SourceTrueNAS) { t.Fatalf("expected TrueNAS source, got %+v", nextcloud.Sources) } if nextcloud.ParentName != "truenas-main" { t.Fatalf("expected Nextcloud parentName truenas-main, got %q", nextcloud.ParentName) } if nextcloud.Docker == nil { t.Fatal("expected docker payload on TrueNAS app resource") } if nextcloud.Docker.ContainerID != "nextcloud" { t.Fatalf("expected canonical app container ID %q, got %q", "nextcloud", nextcloud.Docker.ContainerID) } if nextcloud.Docker.Image != "docker.io/library/nextcloud:29.0.7" { t.Fatalf("expected Nextcloud image %q, got %q", "docker.io/library/nextcloud:29.0.7", nextcloud.Docker.Image) } if nextcloud.Docker.Runtime != "docker" { t.Fatalf("expected runtime docker, got %q", nextcloud.Docker.Runtime) } if nextcloud.MetricsTarget == nil || nextcloud.MetricsTarget.ResourceType != "app-container" || nextcloud.MetricsTarget.ResourceID != "system:truenas-main/app:nextcloud" { t.Fatalf("expected canonical TrueNAS app metrics target system:truenas-main/app:nextcloud, got %+v", nextcloud.MetricsTarget) } } func TestResourceListIncludesTrueNASVMsAsCanonicalWorkloads(t *testing.T) { previous := truenas.IsFeatureEnabled() truenas.SetFeatureEnabled(true) t.Cleanup(func() { truenas.SetFeatureEnabled(previous) }) fixtures := truenas.DefaultFixtures() cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: time.Now().UTC()}}) h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{ records: truenas.NewProvider(fixtures).Records(), ownedSources: []unified.DataSource{unified.SourceTrueNAS}, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=vm&source=truenas", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != len(fixtures.VMs) { t.Fatalf("expected %d TrueNAS VM resources, got %d", len(fixtures.VMs), len(resp.Data)) } var windows *unified.Resource for i := range resp.Data { resource := &resp.Data[i] if resource.Name == "windows-lab" { windows = resource break } } if windows == nil { t.Fatalf("expected windows-lab in TrueNAS VM response, got %+v", resp.Data) } if !containsSource(windows.Sources, unified.SourceTrueNAS) { t.Fatalf("expected TrueNAS source, got %+v", windows.Sources) } if windows.ParentName != "truenas-main" { t.Fatalf("expected windows-lab parentName truenas-main, got %q", windows.ParentName) } if windows.TrueNAS == nil || windows.TrueNAS.VM == nil { t.Fatalf("expected native VM payload on TrueNAS VM resource, got %+v", windows) } if windows.TrueNAS.VM.ID != "42" || windows.TrueNAS.VM.VCPUs != 4 || windows.TrueNAS.VM.MemoryBytes != 8*1024*1024*1024 { t.Fatalf("unexpected native TrueNAS VM payload: %+v", windows.TrueNAS.VM) } if windows.MetricsTarget == nil || windows.MetricsTarget.ResourceType != "vm" || windows.MetricsTarget.ResourceID != "system:truenas-main/vm:42" { t.Fatalf("expected canonical TrueNAS VM metrics target system:truenas-main/vm:42, got %+v", windows.MetricsTarget) } } func TestResourceListIncludesTrueNASSystemAsCanonicalHost(t *testing.T) { previous := truenas.IsFeatureEnabled() truenas.SetFeatureEnabled(true) t.Cleanup(func() { truenas.SetFeatureEnabled(previous) }) cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: time.Now().UTC()}}) h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{ records: truenas.NewProvider(truenas.DefaultFixtures()).Records(), ownedSources: []unified.DataSource{unified.SourceTrueNAS}, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent&source=truenas", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) == 0 { t.Fatal("expected TrueNAS system resources") } var system *unified.Resource for i := range resp.Data { if resp.Data[i].Name == "truenas-main" { system = &resp.Data[i] break } } if system == nil { t.Fatalf("expected truenas-main in response, got %+v", resp.Data) } if system.Agent == nil || system.Agent.Platform != "truenas" { t.Fatalf("expected canonical host payload on TrueNAS system resource, got %+v", system) } if system.Metrics == nil || system.Metrics.CPU == nil || system.Metrics.Memory == nil { t.Fatalf("expected canonical host metrics on TrueNAS system resource, got %+v", system) } if system.MetricsTarget == nil || system.MetricsTarget.ResourceType != "agent" || system.MetricsTarget.ResourceID != "truenas-main" { t.Fatalf("expected canonical TrueNAS host metrics target truenas-main, got %+v", system.MetricsTarget) } if system.TrueNAS == nil || len(system.TrueNAS.Services) == 0 { t.Fatalf("expected native TrueNAS services on system resource, got %+v", system.TrueNAS) } } func TestResourceStorageIncidentsGroupsCanonicalSections(t *testing.T) { now := time.Now().UTC() cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ { ID: "pbs:main", Type: unified.ResourceTypePBS, Name: "pbs-main", Status: unified.StatusWarning, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourcePBS}, Incidents: []unified.ResourceIncident{{ Provider: "pulse", NativeID: "pbs-alert-1", Code: "pbs_datastore_state", Severity: storagehealth.RiskCritical, Summary: "PBS datastore archive is READ_ONLY", }}, PBS: &unified.PBSData{ ProtectedWorkloadCount: 2, ProtectedWorkloadNames: []string{"media01", "app01"}, }, }, { ID: "storage:tower-array", Type: unified.ResourceTypeStorage, Name: "Tower Array", Status: unified.StatusWarning, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourceAgent}, Incidents: []unified.ResourceIncident{{ Provider: "pulse", NativeID: "unraid-alert-1", Code: "unraid_parity_unavailable", Severity: storagehealth.RiskWarning, Summary: "Unraid parity protection is unavailable", }}, Storage: &unified.StorageMeta{ Platform: "unraid", Topology: "array", Risk: &unified.StorageRisk{ Level: storagehealth.RiskWarning, Reasons: []unified.StorageRiskReason{{ Code: "unraid_parity_unavailable", Severity: storagehealth.RiskWarning, Summary: "Unraid parity protection is unavailable", }}, }, ProtectionReduced: true, }, }, { ID: "storage:tank", Type: unified.ResourceTypeStorage, Name: "tank", Status: unified.StatusWarning, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourceTrueNAS}, Incidents: []unified.ResourceIncident{{ Provider: "pulse", NativeID: "zfs-alert-1", Code: "raid_rebuilding", Severity: storagehealth.RiskWarning, Summary: "Pool tank is rebuilding", }}, Storage: &unified.StorageMeta{ Platform: "truenas", Topology: "pool", Risk: &unified.StorageRisk{ Level: storagehealth.RiskWarning, Reasons: []unified.StorageRiskReason{{ Code: "raid_rebuilding", Severity: storagehealth.RiskWarning, Summary: "Pool tank is rebuilding", }}, }, RebuildInProgress: true, }, }, { ID: "disk:serial-1", Type: unified.ResourceTypePhysicalDisk, Name: "SERIAL-1", Status: unified.StatusWarning, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourceAgent}, Incidents: []unified.ResourceIncident{{ Provider: "pulse", NativeID: "disk-alert-1", Code: "disk_health", Severity: storagehealth.RiskWarning, Summary: "Disk health risk detected", }}, PhysicalDisk: &unified.PhysicalDiskMeta{Serial: "SERIAL-1"}, }, }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/storage-incidents", nil) h.HandleStorageIncidents(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp StorageIncidentsResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.TotalResources != 4 || resp.CriticalResources != 1 || resp.WarningResources != 3 { t.Fatalf("unexpected counts %+v", resp) } if got := resp.ByUrgency[unified.IncidentUrgencyNow]; got != 1 { t.Fatalf("now count = %d, want 1", got) } if got := resp.ByUrgency[unified.IncidentUrgencyToday]; got != 2 { t.Fatalf("today count = %d, want 2", got) } if got := resp.ByUrgency[unified.IncidentUrgencyMonitor]; got != 1 { t.Fatalf("monitor count = %d, want 1", got) } if len(resp.Sections) != 4 { t.Fatalf("sections len = %d, want 4", len(resp.Sections)) } if resp.Sections[0].Category != unified.IncidentCategoryRecoverability || resp.Sections[0].PrimaryUrgency != unified.IncidentUrgencyNow { t.Fatalf("expected recoverability section first, got %+v", resp.Sections[0]) } if len(resp.Sections[0].Resources) != 1 || resp.Sections[0].Resources[0].ResourceID != "pbs:main" { t.Fatalf("expected PBS incident in first section, got %+v", resp.Sections[0].Resources) } if resp.Sections[1].Category != unified.IncidentCategoryProtection || !resp.Sections[1].Resources[0].ProtectionReduced { t.Fatalf("expected protection section second, got %+v", resp.Sections[1]) } if resp.Sections[2].Category != unified.IncidentCategoryRebuild || !resp.Sections[2].Resources[0].RebuildInProgress { t.Fatalf("expected rebuild section third, got %+v", resp.Sections[2]) } if resp.Sections[3].Category != unified.IncidentCategoryDiskHealth || resp.Sections[3].Resources[0].ResourceID != "disk:serial-1" { t.Fatalf("expected disk health section fourth, got %+v", resp.Sections[3]) } } func findAPIResourceByNameAndNode(resources []unified.Resource, name, node string) unified.Resource { for _, resource := range resources { if resource.Name != name || resource.Proxmox == nil || resource.Proxmox.NodeName != node { continue } return resource } return unified.Resource{} } func findAPIStorageResourceByPlatform(resources []unified.Resource, name, platform, topology string) unified.Resource { for _, resource := range resources { if resource.Name != name || resource.Storage == nil { continue } if resource.Storage.Platform != platform || resource.Storage.Topology != topology { continue } return resource } return unified.Resource{} } func hasAPIStorageConsumer(consumers []unified.StorageConsumerMeta, name string, resourceType unified.ResourceType, diskCount int) bool { for _, consumer := range consumers { if consumer.Name == name && consumer.ResourceType == resourceType && consumer.DiskCount == diskCount { return true } } return false } func TestResourceListIncludesTrueNASFromSupplementalProvider(t *testing.T) { now := time.Now().UTC() cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: now}}) h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{ records: []unified.IngestRecord{ { SourceID: "system:truenas-main", Resource: unified.Resource{ Type: unified.ResourceTypeAgent, Name: "truenas-main", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now, }, Identity: unified.ResourceIdentity{ MachineID: "tn-main", Hostnames: []string{"truenas-main"}, }, }, }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?source=truenas", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 truenas resource, got %d", len(resp.Data)) } resource := resp.Data[0] if resource.Type != "agent" { t.Fatalf("resource type = %q, want %q", resource.Type, "agent") } if !containsSource(resource.Sources, unified.SourceTrueNAS) { t.Fatalf("expected truenas source, got %+v", resource.Sources) } } func TestResourceListUnifiedSeedSkipsSupplementalReingest(t *testing.T) { now := time.Now().UTC() cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ { ID: "agent-truenas-seeded", Type: unified.ResourceTypeAgent, Name: "truenas-main", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourceTrueNAS}, Identity: unified.ResourceIdentity{ MachineID: "tn-main", Hostnames: []string{"truenas-main"}, }, }, }, }) h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{ records: []unified.IngestRecord{ { SourceID: "system:truenas-main", Resource: unified.Resource{ Type: unified.ResourceTypeAgent, Name: "truenas-main-duplicate", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now, }, Identity: unified.ResourceIdentity{ MachineID: "tn-main-duplicate", Hostnames: []string{"truenas-main-duplicate"}, }, }, }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?source=truenas", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected unified seed to avoid duplicate supplemental ingest, got %d resources", len(resp.Data)) } if got := resp.Data[0].Name; got != "truenas-main" { t.Fatalf("resource name = %q, want truenas-main", got) } } func TestResourceListUnifiedSeedIngestsOwnedSupplementalWhenSourceMissing(t *testing.T) { now := time.Now().UTC() cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ { ID: "agent-generic-seeded", Type: unified.ResourceTypeAgent, Name: "generic-host", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now, Sources: []unified.DataSource{unified.SourceAgent}, Identity: unified.ResourceIdentity{ MachineID: "generic-host", Hostnames: []string{"generic-host"}, }, }, }, }) h.SetSupplementalRecordsProvider(unified.SourceVMware, mockSupplementalRecordsProvider{ ownedSources: []unified.DataSource{unified.SourceVMware}, records: []unified.IngestRecord{ { SourceID: "vmware:host-1", Resource: unified.Resource{ Type: unified.ResourceTypeAgent, Name: "esxi-01.lab.local", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now, }, Identity: unified.ResourceIdentity{ MachineID: "esxi-01", Hostnames: []string{"esxi-01.lab.local"}, }, }, }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?source=vmware-vsphere", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 vmware resource from owned supplemental provider, got %d", len(resp.Data)) } if got := resp.Data[0].Name; got != "esxi-01.lab.local" { t.Fatalf("resource name = %q, want esxi-01.lab.local", got) } if !containsSource(resp.Data[0].Sources, unified.SourceVMware) { t.Fatalf("expected vmware source, got %+v", resp.Data[0].Sources) } } func TestResourceListSupplementalOwnerSuppressesSnapshotSource(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ LastUpdate: now, Hosts: []models.Host{ { ID: "host-snapshot-1", Hostname: "snapshot-host", Status: "online", LastSeen: now, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) h.SetSupplementalRecordsProvider(unified.SourceAgent, mockSupplementalRecordsProvider{ ownedSources: []unified.DataSource{unified.SourceAgent}, records: []unified.IngestRecord{ { SourceID: "host-provider-1", Resource: unified.Resource{ Type: unified.ResourceTypeAgent, Name: "provider-host", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now, }, Identity: unified.ResourceIdentity{ MachineID: "provider-machine", Hostnames: []string{"provider-host"}, }, }, }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources?source=agent", nil) h.HandleListResources(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var resp ResourcesResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if len(resp.Data) != 1 { t.Fatalf("expected 1 provider-owned resource, got %d", len(resp.Data)) } if resp.Data[0].Name != "provider-host" { t.Fatalf("resource name = %q, want provider-host", resp.Data[0].Name) } } func TestResourceListWithoutSupplementalProvider(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ LastUpdate: now, Hosts: []models.Host{ { ID: "host-1", Hostname: "agent-host", Status: "online", LastSeen: now, }, }, } cfg := &config.Config{DataPath: t.TempDir()} h := NewResourceHandlers(cfg) h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) truenasRec := httptest.NewRecorder() truenasReq := httptest.NewRequest(http.MethodGet, "/api/resources?source=truenas", nil) h.HandleListResources(truenasRec, truenasReq) if truenasRec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", truenasRec.Code, truenasRec.Body.String()) } var truenasResp ResourcesResponse if err := json.NewDecoder(truenasRec.Body).Decode(&truenasResp); err != nil { t.Fatalf("decode response: %v", err) } if len(truenasResp.Data) != 0 { t.Fatalf("expected 0 truenas resources without supplemental provider, got %d", len(truenasResp.Data)) } agentRec := httptest.NewRecorder() agentReq := httptest.NewRequest(http.MethodGet, "/api/resources?source=agent", nil) h.HandleListResources(agentRec, agentReq) if agentRec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", agentRec.Code, agentRec.Body.String()) } var agentResp ResourcesResponse if err := json.NewDecoder(agentRec.Body).Decode(&agentResp); err != nil { t.Fatalf("decode response: %v", err) } if len(agentResp.Data) != 1 { t.Fatalf("expected 1 agent resource, got %d", len(agentResp.Data)) } } func TestSupplementalSnapshotOwnedSources_TrueNASProviders(t *testing.T) { sources := supplementalSnapshotOwnedSources(map[unified.DataSource]SupplementalRecordsProvider{ unified.SourceTrueNAS: mockSupplementalRecordsAdapter{source: unified.SourceTrueNAS}, }, "default") if len(sources) != 1 || sources[0] != unified.SourceTrueNAS { t.Fatalf("expected owned sources [%q], got %#v", unified.SourceTrueNAS, sources) } } func TestParseSources_AcceptsVMwareAliases(t *testing.T) { for _, raw := range []string{"vmware", "vmware-vsphere"} { sources := parseSources(raw) if len(sources) != 1 { t.Fatalf("parseSources(%q) returned %d entries, want 1", raw, len(sources)) } if _, ok := sources[unified.SourceVMware]; !ok { t.Fatalf("parseSources(%q) did not normalize to vmware: %#v", raw, sources) } } }