diff --git a/internal/api/state_provider_branchcov0723am_test.go b/internal/api/state_provider_branchcov0723am_test.go new file mode 100644 index 000000000..79b0557f5 --- /dev/null +++ b/internal/api/state_provider_branchcov0723am_test.go @@ -0,0 +1,444 @@ +package api + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// branchcov0723amPopulatedSnapshot builds a snapshot that carries exactly one +// entry of every top-level collection SnapshotReadState routes into the +// unified registry. Values are chosen to be distinctive so a subtest can +// assert the source value lands in the matching ReadState accessor. +func branchcov0723amPopulatedSnapshot() models.StateSnapshot { + return models.StateSnapshot{ + Nodes: []models.Node{{ + ID: "node-branchcov", + Name: "node-branchcov", + Instance: "instance-branchcov", + Status: "online", + }}, + VMs: []models.VM{{ + ID: "vm-branchcov", + VMID: 4242, + Name: "vm-branchcov", + Status: "running", + }}, + Containers: []models.Container{{ + ID: "ct-branchcov", + Name: "ct-branchcov", + Status: "running", + }}, + DockerHosts: []models.DockerHost{{ + ID: "docker-host-branchcov", + Hostname: "docker-host-branchcov", + Status: "online", + Containers: []models.DockerContainer{{ + ID: "app-container-branchcov", + Name: "app-container-branchcov", + State: "running", + }}, + }}, + Hosts: []models.Host{{ + ID: "host-branchcov", + Hostname: "host-branchcov", + Status: "online", + }}, + Storage: []models.Storage{{ + ID: "storage-branchcov", + Name: "storage-branchcov", + Type: "lvm", + }}, + PhysicalDisks: []models.PhysicalDisk{{ + ID: "disk-branchcov", + DevPath: "/dev/sda", + Type: "sata", + }}, + PBSInstances: []models.PBSInstance{{ + ID: "pbs-branchcov", + Name: "pbs-branchcov", + }}, + PMGInstances: []models.PMGInstance{{ + ID: "pmg-branchcov", + Name: "pmg-branchcov", + }}, + KubernetesClusters: []models.KubernetesCluster{{ + ID: "k8s-branchcov", + Name: "k8s-branchcov", + }}, + } +} + +// findHostViewByHostname returns the first host view whose Hostname matches, +// or nil. Hosts() may synthesise linked-agent siblings, so callers locate the +// specific source host by hostname rather than by position. +func findHostViewByHostname(views []*unifiedresources.HostView, hostname string) *unifiedresources.HostView { + for _, v := range views { + if v != nil && v.Hostname() == hostname { + return v + } + } + return nil +} + +func findPhysicalDiskViewByDevPath(views []*unifiedresources.PhysicalDiskView, devPath string) *unifiedresources.PhysicalDiskView { + for _, v := range views { + if v != nil && v.DevPath() == devPath { + return v + } + } + return nil +} + +// TestBranchcov0723Am_SnapshotReadState_Zero drives SnapshotReadState with a +// zero-value snapshot and asserts every ReadState accessor returns an empty +// (length 0) result rather than nil-panicking. +func TestBranchcov0723Am_SnapshotReadState_Zero(t *testing.T) { + rs := SnapshotReadState(models.StateSnapshot{}) + if rs == nil { + t.Fatal("SnapshotReadState returned nil ReadState for zero snapshot") + } + + cases := []struct { + name string + got int + }{ + {"VMs", len(rs.VMs())}, + {"Containers", len(rs.Containers())}, + {"Nodes", len(rs.Nodes())}, + {"Hosts", len(rs.Hosts())}, + {"DockerHosts", len(rs.DockerHosts())}, + {"DockerContainers", len(rs.DockerContainers())}, + {"StoragePools", len(rs.StoragePools())}, + {"PhysicalDisks", len(rs.PhysicalDisks())}, + {"PBSInstances", len(rs.PBSInstances())}, + {"PMGInstances", len(rs.PMGInstances())}, + {"K8sClusters", len(rs.K8sClusters())}, + {"K8sNodes", len(rs.K8sNodes())}, + {"Pods", len(rs.Pods())}, + {"K8sDeployments", len(rs.K8sDeployments())}, + {"Workloads", len(rs.Workloads())}, + {"Infrastructure", len(rs.Infrastructure())}, + } + for _, c := range cases { + if c.got != 0 { + t.Errorf("%s() = %d items for zero snapshot, want 0", c.name, c.got) + } + } +} + +// TestBranchcov0723Am_SnapshotReadState_Populated drives SnapshotReadState +// with one entry per top-level collection and asserts each lands in the +// matching accessor with its source identity intact. Canonical registry IDs +// are generated, so identity is asserted through the source carriers the +// registry preserves verbatim (Name / Hostname / VMID / DevPath) plus the +// exact accessor count, which proves a 1:1 map with no cross-collection +// leakage. +func TestBranchcov0723Am_SnapshotReadState_Populated(t *testing.T) { + rs := SnapshotReadState(branchcov0723amPopulatedSnapshot()) + if rs == nil { + t.Fatal("SnapshotReadState returned nil ReadState") + } + + t.Run("VM", func(t *testing.T) { + vms := rs.VMs() + if len(vms) != 1 { + t.Fatalf("VMs() = %d views, want 1", len(vms)) + } + v := vms[0] + if v == nil { + t.Fatal("VM view is nil") + } + if v.Name() != "vm-branchcov" { + t.Errorf("VM Name = %q, want vm-branchcov", v.Name()) + } + if v.VMID() != 4242 { + t.Errorf("VM VMID = %d, want 4242", v.VMID()) + } + if v.Status() == "" { + t.Error("VM Status empty; source snapshot Status was \"running\"") + } + }) + + t.Run("Node", func(t *testing.T) { + nodes := rs.Nodes() + if len(nodes) != 1 { + t.Fatalf("Nodes() = %d views, want 1", len(nodes)) + } + n := nodes[0] + if n == nil { + t.Fatal("Node view is nil") + } + if n.Name() != "node-branchcov" { + t.Errorf("Node Name = %q, want node-branchcov", n.Name()) + } + }) + + t.Run("Container", func(t *testing.T) { + cts := rs.Containers() + if len(cts) != 1 { + t.Fatalf("Containers() = %d views, want 1", len(cts)) + } + c := cts[0] + if c == nil { + t.Fatal("Container view is nil") + } + if c.Name() != "ct-branchcov" { + t.Errorf("Container Name = %q, want ct-branchcov", c.Name()) + } + }) + + t.Run("DockerHost", func(t *testing.T) { + dhs := rs.DockerHosts() + if len(dhs) != 1 { + t.Fatalf("DockerHosts() = %d views, want 1", len(dhs)) + } + dh := dhs[0] + if dh == nil { + t.Fatal("DockerHost view is nil") + } + if dh.Hostname() != "docker-host-branchcov" { + t.Errorf("DockerHost Hostname = %q, want docker-host-branchcov", dh.Hostname()) + } + }) + + t.Run("DockerContainer", func(t *testing.T) { + dcs := rs.DockerContainers() + if len(dcs) != 1 { + t.Fatalf("DockerContainers() = %d views, want 1", len(dcs)) + } + dc := dcs[0] + if dc == nil { + t.Fatal("DockerContainer view is nil") + } + if dc.Name() != "app-container-branchcov" { + t.Errorf("DockerContainer Name = %q, want app-container-branchcov", dc.Name()) + } + }) + + t.Run("Host", func(t *testing.T) { + h := findHostViewByHostname(rs.Hosts(), "host-branchcov") + if h == nil { + t.Fatalf("Hosts() did not surface host-branchcov (got %d views)", len(rs.Hosts())) + } + if h.ID() == "" { + t.Error("Host canonical ID empty") + } + }) + + t.Run("StoragePool", func(t *testing.T) { + sps := rs.StoragePools() + if len(sps) != 1 { + t.Fatalf("StoragePools() = %d views, want 1", len(sps)) + } + s := sps[0] + if s == nil { + t.Fatal("StoragePool view is nil") + } + if s.Name() != "storage-branchcov" { + t.Errorf("StoragePool Name = %q, want storage-branchcov", s.Name()) + } + }) + + t.Run("PhysicalDisk", func(t *testing.T) { + pds := rs.PhysicalDisks() + if len(pds) != 1 { + t.Fatalf("PhysicalDisks() = %d views, want 1", len(pds)) + } + d := findPhysicalDiskViewByDevPath(pds, "/dev/sda") + if d == nil { + t.Fatalf("PhysicalDisks() did not surface DevPath /dev/sda") + } + if d.DevPath() != "/dev/sda" { + t.Errorf("PhysicalDisk DevPath = %q, want /dev/sda", d.DevPath()) + } + }) + + t.Run("PBSInstance", func(t *testing.T) { + pbs := rs.PBSInstances() + if len(pbs) != 1 { + t.Fatalf("PBSInstances() = %d views, want 1", len(pbs)) + } + p := pbs[0] + if p == nil { + t.Fatal("PBSInstance view is nil") + } + if p.Name() != "pbs-branchcov" { + t.Errorf("PBSInstance Name = %q, want pbs-branchcov", p.Name()) + } + }) + + t.Run("PMGInstance", func(t *testing.T) { + pmgs := rs.PMGInstances() + if len(pmgs) != 1 { + t.Fatalf("PMGInstances() = %d views, want 1", len(pmgs)) + } + p := pmgs[0] + if p == nil { + t.Fatal("PMGInstance view is nil") + } + if p.Name() != "pmg-branchcov" { + t.Errorf("PMGInstance Name = %q, want pmg-branchcov", p.Name()) + } + }) + + t.Run("K8sCluster", func(t *testing.T) { + clusters := rs.K8sClusters() + if len(clusters) != 1 { + t.Fatalf("K8sClusters() = %d views, want 1", len(clusters)) + } + k := clusters[0] + if k == nil { + t.Fatal("K8sCluster view is nil") + } + if k.Name() != "k8s-branchcov" { + t.Errorf("K8sCluster Name = %q, want k8s-branchcov", k.Name()) + } + }) +} + +// TestBranchcov0723Am_SnapshotReadState_SourceIndependence proves the registry +// built by SnapshotReadState copies snapshot data rather than aliasing the +// caller's slice elements: after the read state is built, mutating the source +// snapshot must not change the already-materialized views. (The reverse +// direction is structurally impossible: views reference registry-owned +// Resources, never the caller's snapshot, so no view mutation can reach the +// source.) +func TestBranchcov0723Am_SnapshotReadState_SourceIndependence(t *testing.T) { + snap := branchcov0723amPopulatedSnapshot() + rs := SnapshotReadState(snap) + + vms := rs.VMs() + if len(vms) != 1 || vms[0] == nil { + t.Fatalf("expected one VM view before mutation; got %d", len(vms)) + } + beforeName := vms[0].Name() + if beforeName != "vm-branchcov" { + t.Fatalf("VM Name before mutation = %q, want vm-branchcov", beforeName) + } + + // Mutate the caller's snapshot in place. SnapshotReadState received (and + // IngestSnapshot received) the snapshot by value and the registry copied + // field values into its own Resources, so this must not leak through. + snap.VMs[0].Name = "mutated-after-read" + snap.VMs[0].VMID = 9999 + + // The caller's own snapshot reflects the mutation (sanity), but the + // already-built ReadState must be isolated from it. + if snap.VMs[0].Name != "mutated-after-read" { + t.Fatalf("test harness sanity check failed: caller snapshot Name = %q", snap.VMs[0].Name) + } + after := rs.VMs() + if len(after) != 1 || after[0] == nil { + t.Fatalf("after mutating source snapshot, ReadState has %d VMs, want 1", len(after)) + } + if after[0].Name() != beforeName { + t.Errorf("ReadState VM Name = %q after source mutation, want %q (source aliasing)", after[0].Name(), beforeName) + } + if after[0].VMID() != 4242 { + t.Errorf("ReadState VM VMID = %d after source mutation, want 4242 (source aliasing)", after[0].VMID()) + } +} + +// TestBranchcov0723Am_APITokenUsage_NormalizeCollections covers both arms of +// (u APITokenUsage).NormalizeCollections: nil Agents normalised to a non-nil +// empty slice, and a populated Agents slice preserved. Because the method has +// a value receiver, the caller's struct must be unchanged regardless of input. +func TestBranchcov0723Am_APITokenUsage_NormalizeCollections(t *testing.T) { + cases := []struct { + name string + input APITokenUsage + wantAgents int + wantNil bool + }{ + { + name: "nil_agents_normalized_to_empty", + input: APITokenUsage{TokenID: "tok-1", AgentCount: 0, Agents: nil}, + wantAgents: 0, + wantNil: false, + }, + { + name: "empty_slice_preserved_non_nil", + input: APITokenUsage{TokenID: "tok-2", Agents: []string{}}, + wantAgents: 0, + wantNil: false, + }, + { + name: "populated_agents_preserved", + input: APITokenUsage{TokenID: "tok-3", AgentCount: 2, Agents: []string{"agent-a", "agent-b"}}, + wantAgents: 2, + wantNil: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + original := tc.input + got := tc.input.NormalizeCollections() + + if (got.Agents == nil) != tc.wantNil { + t.Errorf("Agents nil = %v, want %v", got.Agents == nil, tc.wantNil) + } + if len(got.Agents) != tc.wantAgents { + t.Errorf("len(Agents) = %d, want %d", len(got.Agents), tc.wantAgents) + } + if got.TokenID != original.TokenID { + t.Errorf("TokenID = %q, want %q", got.TokenID, original.TokenID) + } + if got.AgentCount != original.AgentCount { + t.Errorf("AgentCount = %d, want %d", got.AgentCount, original.AgentCount) + } + }) + } +} + +// TestBranchcov0723Am_DockerAgentAttention_NormalizeCollections covers both +// arms of (a DockerAgentAttention).NormalizeCollections: nil Issues normalised +// to a non-nil empty slice, and a populated Issues slice preserved. Value +// receiver => caller's struct is unchanged. +func TestBranchcov0723Am_DockerAgentAttention_NormalizeCollections(t *testing.T) { + cases := []struct { + name string + input DockerAgentAttention + wantIssues int + wantNil bool + }{ + { + name: "nil_issues_normalized_to_empty", + input: DockerAgentAttention{AgentID: "agent-1", Issues: nil}, + wantIssues: 0, + wantNil: false, + }, + { + name: "empty_slice_preserved_non_nil", + input: DockerAgentAttention{AgentID: "agent-2", Issues: []string{}}, + wantIssues: 0, + wantNil: false, + }, + { + name: "populated_issues_preserved", + input: DockerAgentAttention{AgentID: "agent-3", Issues: []string{"stale", "no-token"}}, + wantIssues: 2, + wantNil: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + original := tc.input + got := tc.input.NormalizeCollections() + + if (got.Issues == nil) != tc.wantNil { + t.Errorf("Issues nil = %v, want %v", got.Issues == nil, tc.wantNil) + } + if len(got.Issues) != tc.wantIssues { + t.Errorf("len(Issues) = %d, want %d", len(got.Issues), tc.wantIssues) + } + if got.AgentID != original.AgentID { + t.Errorf("AgentID = %q, want %q", got.AgentID, original.AgentID) + } + if got.Name != original.Name || got.Status != original.Status { + t.Errorf("scalar fields changed: Name=%q Status=%q", got.Name, got.Status) + } + }) + } +} diff --git a/internal/api/unified_agent_zip_branchcov0723am_test.go b/internal/api/unified_agent_zip_branchcov0723am_test.go new file mode 100644 index 000000000..df64937f0 --- /dev/null +++ b/internal/api/unified_agent_zip_branchcov0723am_test.go @@ -0,0 +1,317 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "strings" + "testing" +) + +// zipEntry0723Am is a single in-memory zip member used by buildZipBytes0723Am. +type zipEntry0723Am struct { + name string + content []byte +} + +// buildZipBytes0723Am builds an in-memory zip archive containing entries in the +// given order and returns its raw bytes. Test-only fixture builder for +// extractFromZip coverage. +func buildZipBytes0723Am(t *testing.T, entries ...zipEntry0723Am) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, e := range entries { + w, err := zw.Create(e.name) + if err != nil { + t.Fatalf("create zip entry %q: %v", e.name, err) + } + if _, err := w.Write(e.content); err != nil { + t.Fatalf("write zip entry %q: %v", e.name, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("close zip: %v", err) + } + return buf.Bytes() +} + +// zipLocalHeaderDataOffset0723Am parses the first local file header of archive +// and returns the byte offset where that entry's compressed data begins. +func zipLocalHeaderDataOffset0723Am(t *testing.T, archive []byte) int { + t.Helper() + const localHeaderSig = 0x04034b50 + if len(archive) < 30 { + t.Fatal("archive too small to contain a local file header") + } + if binary.LittleEndian.Uint32(archive[0:4]) != localHeaderSig { + t.Fatal("archive does not start with a local file header signature") + } + nameLen := int(binary.LittleEndian.Uint16(archive[26:28])) + extraLen := int(binary.LittleEndian.Uint16(archive[28:30])) + return 30 + nameLen + extraLen +} + +// zipCentralDirOffset0723Am returns the byte offset of the first central +// directory entry, read from the trailing EOCD record (archive/zip writes no +// comment, so the EOCD is the last 22 bytes). +func zipCentralDirOffset0723Am(t *testing.T, archive []byte) int { + t.Helper() + const eocdSig = 0x06054b50 + if len(archive) < 22 { + t.Fatal("archive too small to contain an EOCD record") + } + eocd := len(archive) - 22 + if binary.LittleEndian.Uint32(archive[eocd:eocd+4]) != eocdSig { + t.Fatal("EOCD signature not found at end of archive") + } + cdOff := int(binary.LittleEndian.Uint32(archive[eocd+16 : eocd+20])) + if cdOff+12 > len(archive) || binary.LittleEndian.Uint32(archive[cdOff:cdOff+4]) != 0x02014b50 { + t.Fatalf("central directory signature not found at offset %d", cdOff) + } + return cdOff +} + +// patchZipMethodUnsupported0723Am returns a copy of archive with both the local +// and central-directory compression methods of the first entry set to 99 (an +// unsupported method), so that file.Open() fails while zip.NewReader still +// parses the structure. +func patchZipMethodUnsupported0723Am(t *testing.T, archive []byte) []byte { + t.Helper() + const unsupported = uint16(99) + out := append([]byte(nil), archive...) + binary.LittleEndian.PutUint16(out[8:10], unsupported) + cdOff := zipCentralDirOffset0723Am(t, out) + binary.LittleEndian.PutUint16(out[cdOff+10:cdOff+12], unsupported) + return out +} + +func TestBranchcov0723AmExtractFromZip(t *testing.T) { + want := []byte("hello-agent-binary") + + t.Run("valid_zip_returns_exact_entry_bytes", func(t *testing.T) { + archive := buildZipBytes0723Am(t, zipEntry0723Am{name: "pulse-agent.exe", content: want}) + got, err := extractFromZip(archive, "pulse-agent.exe") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("extractFromZip returned %q, want %q", got, want) + } + }) + + t.Run("missing_entry_returns_not_found_error", func(t *testing.T) { + archive := buildZipBytes0723Am(t, zipEntry0723Am{name: "other.bin", content: []byte("not-the-one")}) + got, err := extractFromZip(archive, "pulse-agent.exe") + if err == nil { + t.Fatalf("expected error for missing entry, got content %q", got) + } + if !strings.Contains(err.Error(), "not found in zip") { + t.Fatalf("expected 'not found in zip' error, got %v", err) + } + if got != nil { + t.Fatalf("expected nil content on error, got %q", got) + } + }) + + t.Run("entry_found_when_not_first", func(t *testing.T) { + archive := buildZipBytes0723Am(t, + zipEntry0723Am{name: "README.txt", content: []byte("readme")}, + zipEntry0723Am{name: "LICENSE", content: []byte("license")}, + zipEntry0723Am{name: "pulse-agent.exe", content: want}, + zipEntry0723Am{name: "tail.bin", content: []byte("tail")}, + ) + got, err := extractFromZip(archive, "pulse-agent.exe") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("extractFromZip returned %q, want %q", got, want) + } + }) + + t.Run("empty_archive_returns_open_error", func(t *testing.T) { + got, err := extractFromZip([]byte{}, "pulse-agent.exe") + if err == nil { + t.Fatalf("expected error for empty archive, got content %q", got) + } + if !strings.Contains(err.Error(), "failed to open zip") { + t.Fatalf("expected 'failed to open zip' error, got %v", err) + } + }) + + t.Run("non_zip_bytes_returns_open_error", func(t *testing.T) { + archive := []byte("this is definitely not a zip file") + got, err := extractFromZip(archive, "pulse-agent.exe") + if err == nil { + t.Fatalf("expected error for non-zip bytes, got content %q", got) + } + if !strings.Contains(err.Error(), "failed to open zip") { + t.Fatalf("expected 'failed to open zip' error, got %v", err) + } + }) + + t.Run("name_match_is_case_sensitive", func(t *testing.T) { + // extractFromZip compares filepath.Base(file.Name) to entryName using a + // verbatim byte comparison; differing case does not match. + archive := buildZipBytes0723Am(t, zipEntry0723Am{name: "Pulse-Agent.EXE", content: want}) + if _, err := extractFromZip(archive, "pulse-agent.exe"); err == nil { + t.Fatal("expected not-found for case-mismatched entry name, got no error") + } + got, err := extractFromZip(archive, "Pulse-Agent.EXE") + if err != nil { + t.Fatalf("unexpected error for exact-case name: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("extractFromZip returned %q, want %q", got, want) + } + }) + + t.Run("name_matched_on_filepath_base", func(t *testing.T) { + // The match is on filepath.Base(file.Name), so a leading directory + // segment is stripped and a nested entry still resolves. + archive := buildZipBytes0723Am(t, zipEntry0723Am{name: "dist/pulse-agent.exe", content: want}) + got, err := extractFromZip(archive, "pulse-agent.exe") + if err != nil { + t.Fatalf("expected base-name match for nested entry, got error: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("extractFromZip returned %q, want %q", got, want) + } + }) + + t.Run("zero_length_entry_returns_empty_bytes", func(t *testing.T) { + archive := buildZipBytes0723Am(t, zipEntry0723Am{name: "pulse-agent.exe", content: nil}) + got, err := extractFromZip(archive, "pulse-agent.exe") + if err != nil { + t.Fatalf("unexpected error for zero-length entry: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected empty byte slice, got %d bytes", len(got)) + } + }) + + t.Run("oversized_entry_returns_size_limit_error", func(t *testing.T) { + // Deflate compresses all-zero content to a few KB, so the archive stays + // small while the decompressed entry exceeds maxAgentBinarySize. + big := bytes.Repeat([]byte{0}, maxAgentBinarySize+1) + archive := buildZipBytes0723Am(t, zipEntry0723Am{name: "pulse-agent.exe", content: big}) + got, err := extractFromZip(archive, "pulse-agent.exe") + if err == nil { + t.Fatalf("expected size-limit error, got %d bytes", len(got)) + } + if !strings.Contains(err.Error(), "exceeded size limit") { + t.Fatalf("expected 'exceeded size limit' error, got %v", err) + } + }) + + t.Run("unsupported_compression_method_returns_open_error", func(t *testing.T) { + // Rewrite the method to an unsupported value in both headers; the + // structure still parses but file.Open() rejects the method. + archive := patchZipMethodUnsupported0723Am(t, + buildZipBytes0723Am(t, zipEntry0723Am{name: "pulse-agent.exe", content: want})) + got, err := extractFromZip(archive, "pulse-agent.exe") + if err == nil { + t.Fatalf("expected open error for unsupported method, got %d bytes", len(got)) + } + if !strings.Contains(err.Error(), "failed opening binary in zip") { + t.Fatalf("expected 'failed opening binary in zip' error, got %v", err) + } + }) + + t.Run("corrupt_compressed_data_returns_read_error", func(t *testing.T) { + // Flip bits inside the compressed-data region; file.Open() still + // succeeds but decompression fails mid-read. + base := buildZipBytes0723Am(t, + zipEntry0723Am{name: "pulse-agent.exe", content: bytes.Repeat([]byte("payload-"), 32)}) + out := append([]byte(nil), base...) + dataOff := zipLocalHeaderDataOffset0723Am(t, out) + cdOff := zipCentralDirOffset0723Am(t, out) + n := 8 + if max := cdOff - dataOff - 1; max < n { + n = max + } + if n <= 0 { + t.Fatal("no compressed-data bytes available to corrupt") + } + for i := 0; i < n; i++ { + out[dataOff+i] ^= 0xFF + } + got, err := extractFromZip(out, "pulse-agent.exe") + if err == nil { + t.Fatalf("expected read error for corrupt data, got %d bytes", len(got)) + } + if !strings.Contains(err.Error(), "failed reading binary from zip") { + t.Fatalf("expected 'failed reading binary from zip' error, got %v", err) + } + }) +} + +func TestBranchcov0723AmIsTrustedProxyIP(t *testing.T) { + cases := []struct { + name string + envCIDR string + ipStr string + want bool + }{ + { + name: "empty string returns false", + envCIDR: "10.0.0.0/8", + ipStr: "", + want: false, + }, + { + name: "syntactically invalid IP returns false", + envCIDR: "10.0.0.0/8", + ipStr: "not-an-ip", + want: false, + }, + { + name: "IPv4 inside trusted range returns true", + envCIDR: "10.0.0.0/8", + ipStr: "10.1.2.3", + want: true, + }, + { + name: "IPv4 outside trusted range returns false", + envCIDR: "10.0.0.0/8", + ipStr: "192.168.1.1", + want: false, + }, + { + name: "IPv6 inside trusted range returns true", + envCIDR: "2001:db8::/32", + ipStr: "2001:db8::1", + want: true, + }, + { + name: "IPv6 outside trusted range returns false", + envCIDR: "2001:db8::/32", + ipStr: "2001:dead::1", + want: false, + }, + { + name: "surrounding whitespace is trimmed then matched", + envCIDR: "10.0.0.0/8", + ipStr: " 10.1.2.3 ", + want: true, + }, + { + name: "bracketed IPv6 is unbracketed then matched", + envCIDR: "2001:db8::/32", + ipStr: "[2001:db8::1]", + want: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", tt.envCIDR) + resetTrustedProxyConfig() + + if got := IsTrustedProxyIP(tt.ipStr); got != tt.want { + t.Errorf("IsTrustedProxyIP(%q) = %v, want %v", tt.ipStr, got, tt.want) + } + }) + } +} diff --git a/internal/monitoring/reload_outcome_counts_branchcov0723am_test.go b/internal/monitoring/reload_outcome_counts_branchcov0723am_test.go new file mode 100644 index 000000000..7d692d614 --- /dev/null +++ b/internal/monitoring/reload_outcome_counts_branchcov0723am_test.go @@ -0,0 +1,269 @@ +package monitoring + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBranchcov0723Am_AccumulateAlertOutcomeCounts drives every branch of +// accumulateAlertOutcomeCounts: the nil-counts guard, the empty-history path +// (which must not perturb pre-existing counts), and every alert classification +// the loop distinguishes, including the precise cutoff boundary semantics of +// `!AckTime.Before(cutoff)`. +func TestBranchcov0723Am_AccumulateAlertOutcomeCounts(t *testing.T) { + cutoff := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + t.Run("nil_counts_is_noop", func(t *testing.T) { + // The guard at the top of the function is the only thing that prevents + // a nil-pointer dereference once the loop begins mutating fields, so it + // has no observable side effect beyond returning safely. Pass a rich + // history so the guard is provably the gate. + richHistory := []alerts.Alert{ + {AckTime: &cutoff, OperationalRecord: &operationaltrust.OperationalRecord{State: operationaltrust.OperationalResolved}}, + } + assert.NotPanics(t, func() { + accumulateAlertOutcomeCounts(nil, richHistory, cutoff) + }) + }) + + t.Run("empty_history_preserves_existing_counts", func(t *testing.T) { + // Pre-existing totals (resource counts and prior alert totals) must be + // preserved untouched when there is no history to fold in, and the three + // alert-outcome fields must not be incremented. + counts := InstallSnapshotCounts{ + PVENodes: 3, + AlertsFired30d: 7, + AlertsAcknowledged30d: 2, + AlertsResolved30d: 1, + } + accumulateAlertOutcomeCounts(&counts, nil, cutoff) + assert.Equal(t, InstallSnapshotCounts{ + PVENodes: 3, + AlertsFired30d: 7, + AlertsAcknowledged30d: 2, + AlertsResolved30d: 1, + }, counts) + + // Same behaviour with an explicit empty (non-nil) slice. + counts2 := InstallSnapshotCounts{ + PVENodes: 3, + AlertsFired30d: 7, + AlertsAcknowledged30d: 2, + AlertsResolved30d: 1, + } + accumulateAlertOutcomeCounts(&counts2, []alerts.Alert{}, cutoff) + assert.Equal(t, counts, counts2) + }) + + t.Run("classifications_and_cutoff_boundary", func(t *testing.T) { + // AckTime exactly at the cutoff must be counted (Before is strict), an + // AckTime strictly before must not, and each OperationalRecord + // classification must increment only the resolved field. + atCutoff := cutoff + beforeCutoff := cutoff.Add(-time.Second) + afterCutoff := cutoff.Add(time.Second) + + counts := InstallSnapshotCounts{} + accumulateAlertOutcomeCounts(&counts, []alerts.Alert{ + // 1) Acknowledged strictly before cutoff: fired, NOT acked, no OR. + {AckTime: &beforeCutoff}, + // 2) Acknowledged exactly at cutoff: fired, acked. + {AckTime: &atCutoff}, + // 3) Acknowledged after cutoff: fired, acked. + {AckTime: &afterCutoff}, + // 4) Nil AckTime: fired, NOT acked, nil OR. + {}, + // 5) Resolved operational record, acknowledged: fired, acked, resolved. + { + AckTime: &afterCutoff, + OperationalRecord: &operationaltrust.OperationalRecord{State: operationaltrust.OperationalResolved}, + }, + // 6) Non-resolved operational record, nil ack: fired, NOT acked, NOT resolved. + { + OperationalRecord: &operationaltrust.OperationalRecord{State: operationaltrust.OperationalOpen}, + }, + }, cutoff) + + assert.Equal(t, 6, counts.AlertsFired30d, "every history entry is fired") + assert.Equal(t, 3, counts.AlertsAcknowledged30d, "acked: at-cutoff, after-cutoff, resolved+after") + assert.Equal(t, 1, counts.AlertsResolved30d, "only the resolved operational record counts") + }) +} + +// TestBranchcov0723Am_AccumulateInstallOutcomeCounts covers the guard arms and +// every arm reachable with a zero-value or minimally-wired Monitor. The +// notification-manager arm (and its error arm) are intentionally skipped: they +// require a sqlite-backed NotificationManager with seeded audit rows to assert +// meaningfully, which is fully-wired-live-Monitor territory. +func TestBranchcov0723Am_AccumulateInstallOutcomeCounts(t *testing.T) { + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + + // sentryCounts is a baseline that must survive any no-op accumulation call + // so a regression that spuriously mutates counts is caught. + sentryCounts := func() InstallSnapshotCounts { + return InstallSnapshotCounts{ + PVENodes: 4, + ActiveAlerts: 9, + AlertsFired30d: 5, + NotificationAttempts7d: 11, + } + } + + t.Run("nil_counts_is_noop", func(t *testing.T) { + // Guard fires before any manager access; only observable effect is safe + // return. Use a Monitor with a real alert manager so the guard is + // provably the only gate in front of the nil pointer. + alertMgr := alerts.NewManagerWithDataDir(t.TempDir()) + defer alertMgr.Stop() + mon := &Monitor{alertManager: alertMgr} + assert.NotPanics(t, func() { + accumulateInstallOutcomeCounts(nil, mon, now) + }) + }) + + t.Run("nil_monitor_leaves_counts_untouched", func(t *testing.T) { + counts := sentryCounts() + accumulateInstallOutcomeCounts(&counts, nil, now) + assert.Equal(t, sentryCounts(), counts) + }) + + t.Run("zero_monitor_zero_now_uses_now_recompute", func(t *testing.T) { + // now.IsZero() true-arm: the function recomputes now internally. With a + // zero-value Monitor both managers are nil, so no counts accrue. + counts := sentryCounts() + assert.NotPanics(t, func() { + accumulateInstallOutcomeCounts(&counts, &Monitor{}, time.Time{}) + }) + assert.Equal(t, sentryCounts(), counts) + }) + + t.Run("zero_monitor_real_now_skips_nil_managers", func(t *testing.T) { + // now.IsZero() false-arm plus the nil alertManager and nil + // notificationManager branches: nothing is accumulated. + counts := sentryCounts() + mon := &Monitor{} + require.Nil(t, mon.GetAlertManager()) + require.Nil(t, mon.GetNotificationManager()) + accumulateInstallOutcomeCounts(&counts, mon, now) + assert.Equal(t, sentryCounts(), counts) + }) + + t.Run("alert_manager_with_empty_history_increments_nothing", func(t *testing.T) { + // Covers the alertManager != nil true-arm. A freshly-constructed alert + // manager has no history, so GetAlertHistorySince returns nothing and + // no alert-outcome counts accrue. (Deeper history-driven counting is + // exercised directly on accumulateAlertOutcomeCounts above; history + // cannot be injected into an alerts.Manager from outside the alerts + // package without driving the full evaluation pipeline.) + alertMgr := alerts.NewManagerWithDataDir(t.TempDir()) + defer alertMgr.Stop() + + mon := &Monitor{alertManager: alertMgr} + require.NotNil(t, mon.GetAlertManager()) + + counts := sentryCounts() + accumulateInstallOutcomeCounts(&counts, mon, now) + assert.Equal(t, sentryCounts(), counts, "empty history must not change any count") + }) +} + +// TestBranchcov0723Am_AggregateInstallSnapshotCounts covers the uncovered arms +// of AggregateInstallSnapshotCounts that the existing +// TestReloadableMonitorAggregateInstallSnapshotCountsIncludesProvisionedTenants +// does not reach: nil mtMonitor, nil persistence (default-only path), the +// ListOrganizations error fallback, and the GetMonitor-skip arm. +func TestBranchcov0723Am_AggregateInstallSnapshotCounts(t *testing.T) { + t.Run("nil_mtmonitor_returns_empty", func(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + rm, err := NewReloadableMonitor(cfg, config.NewMultiTenantPersistence(cfg.DataPath), nil) + require.NoError(t, err) + rm.mtMonitor = nil + + assert.Equal(t, InstallSnapshotCounts{}, rm.AggregateInstallSnapshotCounts()) + }) + + t.Run("nil_persistence_aggregates_default_only", func(t *testing.T) { + // persistence == nil keeps orgIDs as ["default"]. Pre-seed the default + // monitor so GetMonitor returns it without touching persistence. + cfg := &config.Config{DataPath: t.TempDir()} + rm, err := NewReloadableMonitor(cfg, nil, nil) + require.NoError(t, err) + + mtm := rm.GetMultiTenantMonitor() + require.NotNil(t, mtm) + mtm.monitors["default"] = testTelemetryMonitor( + []models.Node{{ID: "n1", Name: "n1", Instance: "pve-1"}}, + []models.VM{{ID: "vm1", VMID: 1, Name: "vm1", Instance: "pve-1"}}, + nil, nil, nil, nil, nil, 2, + ) + + counts := rm.AggregateInstallSnapshotCounts() + assert.Equal(t, 1, counts.PVENodes, "default org aggregated on nil-persistence path") + assert.Equal(t, 1, counts.VMs) + assert.Equal(t, 2, counts.ActiveAlerts) + }) + + t.Run("list_organizations_error_falls_back_to_default", func(t *testing.T) { + // Make the orgs entry a regular file so ReadDir fails with a + // non-IsNotExist error, triggering the ListOrganizations error branch + // and the fallback to orgIDs=["default"]. + baseDir := t.TempDir() + cfg := &config.Config{DataPath: baseDir} + persistence := config.NewMultiTenantPersistence(baseDir) + + rm, err := NewReloadableMonitor(cfg, persistence, nil) + require.NoError(t, err) + + orgsPath := filepath.Join(persistence.BaseDataDir(), "orgs") + // The orgs entry may not exist yet at construction time; only remove if + // present, then replace it with a regular file so ReadDir fails. + if err := os.Remove(orgsPath); err != nil && !os.IsNotExist(err) { + require.NoError(t, err) + } + require.NoError(t, os.WriteFile(orgsPath, []byte("not a directory"), 0o644)) + + mtm := rm.GetMultiTenantMonitor() + require.NotNil(t, mtm) + mtm.monitors["default"] = testTelemetryMonitor( + []models.Node{{ID: "n1", Name: "n1", Instance: "pve-1"}}, + nil, nil, nil, nil, nil, nil, 0, + ) + + counts := rm.AggregateInstallSnapshotCounts() + assert.Equal(t, 1, counts.PVENodes, "default aggregated despite tenant listing failure") + }) + + t.Run("deleting_org_is_skipped", func(t *testing.T) { + // Provision a second org so persistence lists it, then mark it as + // being-deleted so GetMonitor errors and it is skipped while default is + // still aggregated. + baseDir := t.TempDir() + cfg := &config.Config{DataPath: baseDir} + persistence := config.NewMultiTenantPersistence(baseDir) + _, err := persistence.GetPersistence("ghost") + require.NoError(t, err) + + rm, err := NewReloadableMonitor(cfg, persistence, nil) + require.NoError(t, err) + + mtm := rm.GetMultiTenantMonitor() + require.NotNil(t, mtm) + mtm.monitors["default"] = testTelemetryMonitor( + []models.Node{{ID: "n1", Name: "n1", Instance: "pve-1"}}, + nil, nil, nil, nil, nil, nil, 0, + ) + mtm.tenantDeleting["ghost"] = struct{}{} + + counts := rm.AggregateInstallSnapshotCounts() + assert.Equal(t, 1, counts.PVENodes, "default aggregated, deleting org contributed nothing") + }) +} diff --git a/internal/telemetry/telemetry_classifiers_branchcov0723am_test.go b/internal/telemetry/telemetry_classifiers_branchcov0723am_test.go new file mode 100644 index 000000000..5bd7bd39a --- /dev/null +++ b/internal/telemetry/telemetry_classifiers_branchcov0723am_test.go @@ -0,0 +1,490 @@ +package telemetry + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/updates" +) + +// TestBranchcov0723Am_DeploymentMethod covers every branch of the deployment +// method precedence chain, including the cfg-takes-precedence-over-env rule, +// whitespace/case normalization, the env-var fallback, and both IsDocker +// fallbacks when nothing matches. +func TestBranchcov0723Am_DeploymentMethod(t *testing.T) { + tests := []struct { + name string + cfg Config + envMethod string + want string + }{ + { + name: "cfg closed value docker_compose returned verbatim", + cfg: Config{DeploymentMethod: "docker_compose"}, + envMethod: "", + want: "docker_compose", + }, + { + name: "cfg closed value docker_run returned verbatim", + cfg: Config{DeploymentMethod: "docker_run"}, + envMethod: "", + want: "docker_run", + }, + { + name: "cfg closed value container_other returned verbatim", + cfg: Config{DeploymentMethod: "container_other"}, + envMethod: "", + want: "container_other", + }, + { + name: "cfg closed value systemd returned verbatim", + cfg: Config{DeploymentMethod: "systemd"}, + envMethod: "", + want: "systemd", + }, + { + name: "cfg closed value binary_other returned verbatim", + cfg: Config{DeploymentMethod: "binary_other"}, + envMethod: "", + want: "binary_other", + }, + { + name: "cfg closed value other returned verbatim", + cfg: Config{DeploymentMethod: "other"}, + envMethod: "", + want: "other", + }, + { + name: "cfg value trimmed and lowercased before matching", + cfg: Config{DeploymentMethod: " Docker_Compose "}, + envMethod: "", + want: "docker_compose", + }, + { + name: "cfg empty falls back to env closed value", + cfg: Config{}, + envMethod: "docker_run", + want: "docker_run", + }, + { + name: "cfg whitespace-only falls back to env closed value", + cfg: Config{DeploymentMethod: " "}, + envMethod: "systemd", + want: "systemd", + }, + { + name: "cfg value wins over conflicting env value", + cfg: Config{DeploymentMethod: "systemd"}, + envMethod: "docker_run", + want: "systemd", + }, + { + name: "cfg invalid value ignores env and falls back to docker branch", + cfg: Config{DeploymentMethod: "tarball", IsDocker: true}, + envMethod: "systemd", + want: "container_other", + }, + { + name: "cfg invalid value ignores env and falls back to binary branch", + cfg: Config{DeploymentMethod: "tarball", IsDocker: false}, + envMethod: "systemd", + want: "binary_other", + }, + { + name: "nothing set and docker falls back to container_other", + cfg: Config{IsDocker: true}, + envMethod: "", + want: "container_other", + }, + { + name: "nothing set and binary falls back to binary_other", + cfg: Config{IsDocker: false}, + envMethod: "", + want: "binary_other", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("PULSE_DEPLOYMENT_METHOD", tt.envMethod) + if got := deploymentMethod(tt.cfg); got != tt.want { + t.Fatalf("deploymentMethod(%+v) = %q, want %q", tt.cfg, got, tt.want) + } + }) + } +} + +// TestBranchcov0723Am_DurationBucket covers the empty-boundaries case, values +// below/between/past boundaries, the exact-at-boundary semantics (the source +// uses a strict `<` comparison so a value equal to an upper bound falls through +// to the next bucket), one-unit-either-side of every boundary, and a negative +// duration. +func TestBranchcov0723Am_DurationBucket(t *testing.T) { + boundaries := []durationBoundary{ + {upper: 10 * time.Second, label: "a"}, + {upper: 1 * time.Minute, label: "b"}, + {upper: 1 * time.Hour, label: "c"}, + } + const overflow = "over" + + tests := []struct { + name string + value time.Duration + want string + }{ + {"empty boundaries slice returns overflow regardless of value", 5 * time.Second, overflow}, // boundaries == nil below + {"value well below first boundary", 5 * time.Second, "a"}, + {"value one nanosecond below first boundary", 10*time.Second - time.Nanosecond, "a"}, + {"value exactly on first boundary falls through to next bucket", 10 * time.Second, "b"}, + {"value one nanosecond above first boundary", 10*time.Second + time.Nanosecond, "b"}, + {"value strictly between first and second boundary", 30 * time.Second, "b"}, + {"value one nanosecond below second boundary", 1*time.Minute - time.Nanosecond, "b"}, + {"value exactly on second boundary falls through to next bucket", 1 * time.Minute, "c"}, + {"value one nanosecond above second boundary", 1*time.Minute + time.Nanosecond, "c"}, + {"value one nanosecond below last boundary", 1*time.Hour - time.Nanosecond, "c"}, + {"value exactly on last boundary returns overflow", 1 * time.Hour, overflow}, + {"value one nanosecond above last boundary returns overflow", 1*time.Hour + time.Nanosecond, overflow}, + {"value well past last boundary returns overflow", 2 * time.Hour, overflow}, + {"negative duration lands in the first bucket", -1 * time.Second, "a"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got string + if tt.name == "empty boundaries slice returns overflow regardless of value" { + got = durationBucket(tt.value, nil, overflow) + } else { + got = durationBucket(tt.value, boundaries, overflow) + } + if got != tt.want { + t.Fatalf("durationBucket(%v) = %q, want %q", tt.value, got, tt.want) + } + }) + } + + // Anchor the strict-`<` boundary semantics against the real + // knownInstallAge boundaries used in production so the at-boundary + // behaviour is asserted on actual production values. + t.Run("real knownInstallAge 24h boundary lands one bucket up", func(t *testing.T) { + realBoundaries := []durationBoundary{ + {24 * time.Hour, "under_1d"}, + {7 * 24 * time.Hour, "1_7d"}, + {30 * 24 * time.Hour, "8_30d"}, + {90 * 24 * time.Hour, "31_90d"}, + {365 * 24 * time.Hour, "91_365d"}, + } + if got := durationBucket(24*time.Hour-time.Nanosecond, realBoundaries, "over_365d"); got != "under_1d" { + t.Fatalf("just below 24h = %q, want under_1d", got) + } + if got := durationBucket(24*time.Hour, realBoundaries, "over_365d"); got != "1_7d" { + t.Fatalf("exactly 24h = %q, want 1_7d (strict < falls through)", got) + } + }) +} + +// TestBranchcov0723Am_ActivationStage covers every stage the function can +// return in the precedence order the source uses: each outcome trigger +// individually, then monitoring, connected, secured, the default started stage +// for a zero-value Ping, and a precedence check that the highest stage wins. +func TestBranchcov0723Am_ActivationStage(t *testing.T) { + tests := []struct { + name string + ping Ping + want string + }{ + {"zero ping defaults to started", Ping{}, "started"}, + {"auth configured reaches secured", Ping{AuthConfigured: true}, "secured"}, + {"configured connections reach connected", Ping{ConfiguredConnections: 1}, "connected"}, + {"a single monitored resource reaches monitoring", Ping{PVENodes: 1}, "monitoring"}, + {"active alerts trigger outcome_observed", Ping{ActiveAlerts: 1}, "outcome_observed"}, + {"fired alerts trigger outcome_observed", Ping{AlertsFired30d: 1}, "outcome_observed"}, + {"resolved alerts trigger outcome_observed", Ping{AlertsResolved30d: 1}, "outcome_observed"}, + {"notification deliveries trigger outcome_observed", Ping{NotificationDeliveries7d: 1}, "outcome_observed"}, + {"monitoring outranks connected", Ping{PVENodes: 1, ConfiguredConnections: 9}, "monitoring"}, + { + "outcome outranks every lower stage", + Ping{ActiveAlerts: 1, AlertsFired30d: 1, AlertsResolved30d: 1, NotificationDeliveries7d: 1, PVENodes: 5, ConfiguredConnections: 9, AuthConfigured: true}, + "outcome_observed", + }, + // AlertsAcknowledged30d is intentionally NOT a trigger in the source; + // with only that field set the stage must not jump to outcome_observed. + {"alerts acknowledged alone is not an outcome trigger", Ping{AlertsAcknowledged30d: 1}, "started"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := activationStage(tt.ping); got != tt.want { + t.Fatalf("activationStage(%+v) = %q, want %q", tt.ping, got, tt.want) + } + }) + } +} + +// TestBranchcov0723Am_ActivationStageRank covers every known stage plus an +// unknown and empty string, asserts the exact sentinel ranks, and proves the +// ranks are strictly ordered (and that unknown collapses to the started rank). +func TestBranchcov0723Am_ActivationStageRank(t *testing.T) { + tests := []struct { + stage string + want int + }{ + {"started", 1}, + {"secured", 2}, + {"connected", 3}, + {"monitoring", 4}, + {"outcome_observed", 5}, + {"", 1}, + {"bogus_stage", 1}, + } + for _, tt := range tests { + t.Run(tt.stage, func(t *testing.T) { + if got := activationStageRank(tt.stage); got != tt.want { + t.Fatalf("activationStageRank(%q) = %d, want %d", tt.stage, got, tt.want) + } + }) + } + + t.Run("ranks strictly ordered by precedence", func(t *testing.T) { + stages := []string{"started", "secured", "connected", "monitoring", "outcome_observed"} + prev := -1 + for _, s := range stages { + r := activationStageRank(s) + if r <= prev { + t.Fatalf("rank(%q) = %d not strictly greater than previous %d", s, r, prev) + } + prev = r + } + if rUnknown, rStarted := activationStageRank("nope"), activationStageRank("started"); rUnknown != rStarted { + t.Fatalf("unknown rank %d should equal started rank %d", rUnknown, rStarted) + } + }) +} + +// TestBranchcov0723Am_ValidActivationStage returns true for each known stage +// and false for unknown, empty, whitespace, and wrong-case inputs. +func TestBranchcov0723Am_ValidActivationStage(t *testing.T) { + tests := []struct { + stage string + want bool + }{ + {"started", true}, + {"secured", true}, + {"connected", true}, + {"monitoring", true}, + {"outcome_observed", true}, + {"", false}, + {" ", false}, + {"Started", false}, + {"outcome_observed ", false}, + {"bogus", false}, + } + for _, tt := range tests { + t.Run(tt.stage, func(t *testing.T) { + if got := validActivationStage(tt.stage); got != tt.want { + t.Fatalf("validActivationStage(%q) = %v, want %v", tt.stage, got, tt.want) + } + }) + } +} + +// TestBranchcov0723Am_MonitoredResourceCount covers a zero Ping, a Ping with +// each counted field populated individually (proving each field contributes +// exactly one), and a Ping with all counted fields populated. +func TestBranchcov0723Am_MonitoredResourceCount(t *testing.T) { + resourceFields := []struct { + name string + set func(p *Ping) + }{ + {"PVENodes", func(p *Ping) { p.PVENodes = 1 }}, + {"PBSInstances", func(p *Ping) { p.PBSInstances = 1 }}, + {"PMGInstances", func(p *Ping) { p.PMGInstances = 1 }}, + {"VMs", func(p *Ping) { p.VMs = 1 }}, + {"Containers", func(p *Ping) { p.Containers = 1 }}, + {"AgentHosts", func(p *Ping) { p.AgentHosts = 1 }}, + {"DockerHosts", func(p *Ping) { p.DockerHosts = 1 }}, + {"DockerContainers", func(p *Ping) { p.DockerContainers = 1 }}, + {"KubernetesClusters", func(p *Ping) { p.KubernetesClusters = 1 }}, + {"KubernetesNodes", func(p *Ping) { p.KubernetesNodes = 1 }}, + {"KubernetesPods", func(p *Ping) { p.KubernetesPods = 1 }}, + {"KubernetesDeployments", func(p *Ping) { p.KubernetesDeployments = 1 }}, + {"StoragePools", func(p *Ping) { p.StoragePools = 1 }}, + {"PhysicalDisks", func(p *Ping) { p.PhysicalDisks = 1 }}, + {"CephClusters", func(p *Ping) { p.CephClusters = 1 }}, + {"NetworkShares", func(p *Ping) { p.NetworkShares = 1 }}, + {"TrueNASSystems", func(p *Ping) { p.TrueNASSystems = 1 }}, + {"TrueNASVMs", func(p *Ping) { p.TrueNASVMs = 1 }}, + {"TrueNASApps", func(p *Ping) { p.TrueNASApps = 1 }}, + {"VMwareHosts", func(p *Ping) { p.VMwareHosts = 1 }}, + {"VMwareVMs", func(p *Ping) { p.VMwareVMs = 1 }}, + {"VMwareDatastores", func(p *Ping) { p.VMwareDatastores = 1 }}, + {"AvailabilityTargets", func(p *Ping) { p.AvailabilityTargets = 1 }}, + } + + t.Run("zero ping counts nothing", func(t *testing.T) { + if got := monitoredResourceCount(Ping{}); got != 0 { + t.Fatalf("monitoredResourceCount(zero Ping) = %d, want 0", got) + } + }) + + for _, f := range resourceFields { + t.Run(f.name+"/contributes_one", func(t *testing.T) { + var p Ping + f.set(&p) + if got := monitoredResourceCount(p); got != 1 { + t.Fatalf("monitoredResourceCount with only %s set = %d, want 1", f.name, got) + } + }) + } + + t.Run("all counted fields sum to total", func(t *testing.T) { + var p Ping + for _, f := range resourceFields { + f.set(&p) + } + if got, want := monitoredResourceCount(p), len(resourceFields); got != want { + t.Fatalf("monitoredResourceCount(all fields) = %d, want %d", got, want) + } + }) +} + +// TestBranchcov0723Am_EstateSizeBucket covers 0, a negative value, each bucket +// interior, every boundary value, and a very large count. +func TestBranchcov0723Am_EstateSizeBucket(t *testing.T) { + tests := []struct { + name string + resources int + want string + }{ + {"zero is empty", 0, "empty"}, + {"negative is empty", -5, "empty"}, + {"one is in 1_10", 1, "1_10"}, + {"upper boundary of 1_10 inclusive", 10, "1_10"}, + {"lower boundary of 11_50 inclusive", 11, "11_50"}, + {"upper boundary of 11_50 inclusive", 50, "11_50"}, + {"lower boundary of 51_200 inclusive", 51, "51_200"}, + {"upper boundary of 51_200 inclusive", 200, "51_200"}, + {"lower boundary of 201_1000 inclusive", 201, "201_1000"}, + {"upper boundary of 201_1000 inclusive", 1000, "201_1000"}, + {"one above top boundary is over_1000", 1001, "over_1000"}, + {"very large count is over_1000", 1_000_000, "over_1000"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := estateSizeBucket(tt.resources); got != tt.want { + t.Fatalf("estateSizeBucket(%d) = %q, want %q", tt.resources, got, tt.want) + } + }) + } +} + +// TestBranchcov0723Am_ClassifyUpdateFailureCategory covers every category arm +// the source can return, including the status-precedence over error text, both +// OR operands of the disk_space and extract arms, the lowercasing that makes +// matching case-insensitive, first-match precedence between categories, and the +// unknown/default arm via nil error, unmatched text, and a non-failure status. +func TestBranchcov0723Am_ClassifyUpdateFailureCategory(t *testing.T) { + errOf := func(code, message, details string) *updates.UpdateError { + return &updates.UpdateError{Code: code, Message: message, Details: details} + } + + tests := []struct { + name string + entry updates.UpdateHistoryEntry + want string + }{ + { + name: "rolled back status short circuits before error text", + entry: updates.UpdateHistoryEntry{Status: updates.StatusRolledBack, Error: errOf("", "download timed out", "")}, + want: "rolled_back", + }, + { + name: "cancelled status short circuits before error text", + entry: updates.UpdateHistoryEntry{Status: updates.StatusCancelled}, + want: "cancelled", + }, + { + name: "failed with nil error is unknown", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed}, + want: "unknown", + }, + { + name: "signature substring in code", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("SIGNATURE_INVALID", "", "")}, + want: "signature", + }, + { + name: "checksum substring in message", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("", "checksum verify failed", "")}, + want: "checksum", + }, + { + name: "download substring in details", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("", "", "download stream reset")}, + want: "download", + }, + { + name: "disk space substring in message", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("", "not enough disk space to extract", "")}, + want: "disk_space", + }, + { + name: "insufficient disk substring without disk space", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("", "insufficient disk quota remaining", "")}, + want: "disk_space", + }, + { + name: "extract substring in message", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("", "could not extract layer", "")}, + want: "extract", + }, + { + name: "archive substring in code", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("ARCHIVE_CORRUPT", "", "")}, + want: "extract", + }, + { + name: "backup substring in details", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("", "", "backup snapshot failed")}, + want: "backup", + }, + { + name: "apply substring in message", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("", "apply step rolled back", "")}, + want: "apply", + }, + { + name: "restart substring in message", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("", "service restart timed out", "")}, + want: "restart", + }, + { + name: "unmatched failure text falls through to unknown", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("HTTP_503", "upstream refused", "raw log stays local")}, + want: "unknown", + }, + { + name: "uppercase error text is lowercased before matching", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("", "DOWNLOAD FAILED", "")}, + want: "download", + }, + { + name: "first matching category wins between signature and checksum", + entry: updates.UpdateHistoryEntry{Status: updates.StatusFailed, Error: errOf("", "signature then checksum", "")}, + want: "signature", + }, + { + name: "success status with nil error is unknown", + entry: updates.UpdateHistoryEntry{Status: updates.StatusSuccess}, + want: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyUpdateFailureCategory(tt.entry); got != tt.want { + t.Fatalf("classifyUpdateFailureCategory(status=%q, err=%+v) = %q, want %q", + tt.entry.Status, tt.entry.Error, got, tt.want) + } + }) + } +} diff --git a/internal/telemetry/telemetry_lifecycle_branchcov0723am_test.go b/internal/telemetry/telemetry_lifecycle_branchcov0723am_test.go new file mode 100644 index 000000000..bf7852832 --- /dev/null +++ b/internal/telemetry/telemetry_lifecycle_branchcov0723am_test.go @@ -0,0 +1,645 @@ +package telemetry + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" +) + +// decodeLifecycleRaw reads and JSON-decodes the lifecycle state file WITHOUT +// applying the validActivationStage normalization that the production +// readLifecycleRecord performs. It is used to assert the exact on-disk state +// independently of the function under test. +func decodeLifecycleRaw(t *testing.T, dir string) lifecycleRecord { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, lifecycleStateFile)) + if err != nil { + t.Fatalf("read lifecycle state: %v", err) + } + var rec lifecycleRecord + if err := json.Unmarshal(data, &rec); err != nil { + t.Fatalf("unmarshal lifecycle state: %v", err) + } + return rec +} + +// lifecycleIsZero reports whether r is the zero lifecycleRecord value. +func lifecycleIsZero(r lifecycleRecord) bool { + return r.FirstObservedAt.IsZero() && + r.FirstMonitoredResourceAt == nil && + r.HighestObservedActivation == "" +} + +// unwritableDataDir returns a dataDir whose parent component is a regular +// file, so that any os.MkdirAll / write attempt on it fails deterministically. +// The blocking file lives under t.TempDir() and is cleaned up automatically. +func unwritableDataDir(t *testing.T) string { + t.Helper() + blocker := filepath.Join(t.TempDir(), "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0600); err != nil { + t.Fatalf("create blocker file: %v", err) + } + return filepath.Join(blocker, "child") +} + +// TestBranchcov0723Am_ReadLifecycleRecord covers every return path of +// readLifecycleRecord: a missing directory, an existing directory with no +// file, a present-but-malformed file, a present file whose activation stage is +// invalid (it must be blanked while the other fields are kept), and a fully +// valid record whose every field round-trips. +func TestBranchcov0723Am_ReadLifecycleRecord(t *testing.T) { + t.Run("missing directory returns zero record", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "does-not-exist") + if got := readLifecycleRecord(dir); !lifecycleIsZero(got) { + t.Fatalf("readLifecycleRecord(missing dir) = %+v, want zero value", got) + } + }) + + t.Run("existing directory with file absent returns zero record", func(t *testing.T) { + dir := t.TempDir() + if got := readLifecycleRecord(dir); !lifecycleIsZero(got) { + t.Fatalf("readLifecycleRecord(no file) = %+v, want zero value", got) + } + }) + + t.Run("malformed JSON returns zero record", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, lifecycleStateFile), []byte("{not json"), 0600); err != nil { + t.Fatalf("write file: %v", err) + } + if got := readLifecycleRecord(dir); !lifecycleIsZero(got) { + t.Fatalf("readLifecycleRecord(malformed) = %+v, want zero value", got) + } + }) + + t.Run("invalid activation stage is blanked but other fields kept", func(t *testing.T) { + dir := t.TempDir() + firstObs := time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC) + raw := fmt.Sprintf(`{"first_observed_at":%q,"highest_observed_activation":"totally_bogus"}`, firstObs.Format(time.RFC3339Nano)) + if err := os.WriteFile(filepath.Join(dir, lifecycleStateFile), []byte(raw), 0600); err != nil { + t.Fatalf("write file: %v", err) + } + got := readLifecycleRecord(dir) + if !got.FirstObservedAt.Equal(firstObs) { + t.Fatalf("FirstObservedAt = %v, want %v (must be preserved)", got.FirstObservedAt, firstObs) + } + if got.HighestObservedActivation != "" { + t.Fatalf("HighestObservedActivation = %q, want empty after blanking", got.HighestObservedActivation) + } + if got.FirstMonitoredResourceAt != nil { + t.Fatalf("FirstMonitoredResourceAt = %v, want nil", got.FirstMonitoredResourceAt) + } + }) + + t.Run("valid record round-trips every field", func(t *testing.T) { + dir := t.TempDir() + firstObs := time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC) + firstMon := time.Date(2026, 1, 16, 8, 0, 0, 0, time.UTC) + seed := lifecycleRecord{ + FirstObservedAt: firstObs, + FirstMonitoredResourceAt: &firstMon, + HighestObservedActivation: "monitoring", + } + encoded, err := json.Marshal(seed) + if err != nil { + t.Fatalf("marshal seed: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, lifecycleStateFile), encoded, 0600); err != nil { + t.Fatalf("write file: %v", err) + } + + got := readLifecycleRecord(dir) + if !got.FirstObservedAt.Equal(firstObs) { + t.Fatalf("FirstObservedAt = %v, want %v", got.FirstObservedAt, firstObs) + } + if got.HighestObservedActivation != "monitoring" { + t.Fatalf("HighestObservedActivation = %q, want monitoring", got.HighestObservedActivation) + } + if got.FirstMonitoredResourceAt == nil { + t.Fatal("FirstMonitoredResourceAt = nil, want non-nil pointer") + } + if !got.FirstMonitoredResourceAt.Equal(firstMon) { + t.Fatalf("FirstMonitoredResourceAt = %v, want %v", *got.FirstMonitoredResourceAt, firstMon) + } + }) +} + +// TestBranchcov0723Am_WriteLifecycleRecord covers the happy path (writing into +// a fresh directory, which must be created) with a full round-trip read-back, +// and the os.MkdirAll failure path when the dataDir's parent is a regular file. +func TestBranchcov0723Am_WriteLifecycleRecord(t *testing.T) { + t.Run("happy path round-trips every field and creates the directory", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested") + firstObs := time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC) + firstMon := time.Date(2026, 2, 4, 5, 6, 7, 0, time.UTC) + seed := lifecycleRecord{ + FirstObservedAt: firstObs, + FirstMonitoredResourceAt: &firstMon, + HighestObservedActivation: "outcome_observed", + } + if err := writeLifecycleRecord(dir, seed); err != nil { + t.Fatalf("writeLifecycleRecord: %v", err) + } + + got := decodeLifecycleRaw(t, dir) + if !got.FirstObservedAt.Equal(firstObs) { + t.Fatalf("FirstObservedAt = %v, want %v", got.FirstObservedAt, firstObs) + } + if got.HighestObservedActivation != "outcome_observed" { + t.Fatalf("HighestObservedActivation = %q, want outcome_observed", got.HighestObservedActivation) + } + if got.FirstMonitoredResourceAt == nil || !got.FirstMonitoredResourceAt.Equal(firstMon) { + t.Fatalf("FirstMonitoredResourceAt = %v, want %v", got.FirstMonitoredResourceAt, firstMon) + } + if _, err := os.Stat(filepath.Join(dir, lifecycleStateFile)); err != nil { + t.Fatalf("lifecycle file not created under fresh nested dir: %v", err) + } + }) + + t.Run("dataDir whose parent is a regular file returns non-nil error", func(t *testing.T) { + dir := unwritableDataDir(t) + if err := writeLifecycleRecord(dir, lifecycleRecord{}); err == nil { + t.Fatal("writeLifecycleRecord with unwritable dataDir = nil error, want non-nil") + } + }) + + t.Run("read only data dir fails at temp file creation", func(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "read-only") + if err := os.MkdirAll(sub, 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.Chmod(sub, 0500); err != nil { + t.Fatalf("chmod read-only: %v", err) + } + t.Cleanup(func() { + if err := os.Chmod(sub, 0700); err != nil { + t.Errorf("restore dir mode: %v", err) + } + }) + // MkdirAll on the existing directory is a no-op even when read-only, + // so the failure surfaces at os.CreateTemp inside writeLifecycleRecord. + err := writeLifecycleRecord(sub, lifecycleRecord{HighestObservedActivation: "started"}) + if err == nil { + t.Skip("temp file creation succeeded despite read-only dir (e.g. running as root); CreateTemp error arm not exercisable here") + } + // No final lifecycle file should exist. + if _, err := os.Stat(filepath.Join(sub, lifecycleStateFile)); err == nil { + t.Fatal("lifecycle file must not exist after a failed write") + } + }) +} + +// TestBranchcov0723Am_ApplyLifecycle covers the nil-ping no-op, the first-ever +// ping with and without monitored resources (which selects the +// present-at-first-observation vs not_observed time-to-monitoring arms), a +// subsequent ping that preserves FirstObservedAt while recording the first +// monitored resource later, a subsequent lower-stage ping that preserves the +// highest observed activation, and the outcome-observed signal path. +func TestBranchcov0723Am_ApplyLifecycle(t *testing.T) { + t.Run("nil ping is a no-op and writes nothing", func(t *testing.T) { + dir := t.TempDir() + applyLifecycle(nil, dir, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + if _, err := os.Stat(filepath.Join(dir, lifecycleStateFile)); !os.IsNotExist(err) { + t.Fatalf("nil ping must not create a lifecycle file; stat err = %v", err) + } + }) + + t.Run("first ever ping without monitoring records first observed as not observed", func(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + ping := &Ping{AuthConfigured: true} + applyLifecycle(ping, dir, now) + + if ping.ActivationStage != "secured" { + t.Fatalf("ActivationStage = %q, want secured", ping.ActivationStage) + } + if ping.MonitoringActive { + t.Fatal("MonitoringActive = true, want false (no monitored resources)") + } + if ping.OutcomeObserved30d { + t.Fatal("OutcomeObserved30d = true, want false") + } + if ping.KnownInstallAgeBucket != "under_1d" { + t.Fatalf("KnownInstallAgeBucket = %q, want under_1d", ping.KnownInstallAgeBucket) + } + if ping.EstateSizeBucket != "empty" { + t.Fatalf("EstateSizeBucket = %q, want empty", ping.EstateSizeBucket) + } + if ping.TimeToFirstMonitoredResourceBucket != "not_observed" { + t.Fatalf("TimeToFirstMonitoredResourceBucket = %q, want not_observed", ping.TimeToFirstMonitoredResourceBucket) + } + + got := decodeLifecycleRaw(t, dir) + if !got.FirstObservedAt.Equal(now) { + t.Fatalf("persisted FirstObservedAt = %v, want %v", got.FirstObservedAt, now) + } + if got.HighestObservedActivation != "secured" { + t.Fatalf("persisted HighestObservedActivation = %q, want secured", got.HighestObservedActivation) + } + if got.FirstMonitoredResourceAt != nil { + t.Fatalf("persisted FirstMonitoredResourceAt = %v, want nil", got.FirstMonitoredResourceAt) + } + }) + + t.Run("first ever ping with monitoring marks present at first observation", func(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + ping := &Ping{PVENodes: 3} + applyLifecycle(ping, dir, now) + + if ping.ActivationStage != "monitoring" { + t.Fatalf("ActivationStage = %q, want monitoring", ping.ActivationStage) + } + if !ping.MonitoringActive { + t.Fatal("MonitoringActive = false, want true") + } + if ping.EstateSizeBucket != "1_10" { + t.Fatalf("EstateSizeBucket = %q, want 1_10", ping.EstateSizeBucket) + } + if ping.TimeToFirstMonitoredResourceBucket != "present_at_first_observation" { + t.Fatalf("TimeToFirstMonitoredResourceBucket = %q, want present_at_first_observation", ping.TimeToFirstMonitoredResourceBucket) + } + + got := decodeLifecycleRaw(t, dir) + if !got.FirstObservedAt.Equal(now) { + t.Fatalf("persisted FirstObservedAt = %v, want %v", got.FirstObservedAt, now) + } + if got.FirstMonitoredResourceAt == nil || !got.FirstMonitoredResourceAt.Equal(now) { + t.Fatalf("persisted FirstMonitoredResourceAt = %v, want %v", got.FirstMonitoredResourceAt, now) + } + if got.HighestObservedActivation != "monitoring" { + t.Fatalf("persisted HighestObservedActivation = %q, want monitoring", got.HighestObservedActivation) + } + }) + + t.Run("subsequent ping preserves first observed and records first monitored later", func(t *testing.T) { + dir := t.TempDir() + start := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + seed := lifecycleRecord{ + FirstObservedAt: start, + HighestObservedActivation: "secured", + } + encoded, err := json.Marshal(seed) + if err != nil { + t.Fatalf("marshal seed: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, lifecycleStateFile), encoded, 0600); err != nil { + t.Fatalf("write seed: %v", err) + } + + later := start.Add(2 * time.Hour) + ping := &Ping{AuthConfigured: true, PVENodes: 1} + applyLifecycle(ping, dir, later) + + if ping.ActivationStage != "monitoring" { + t.Fatalf("ActivationStage = %q, want monitoring (upgraded)", ping.ActivationStage) + } + if !ping.MonitoringActive { + t.Fatal("MonitoringActive = false, want true") + } + if ping.KnownInstallAgeBucket != "under_1d" { + t.Fatalf("KnownInstallAgeBucket = %q, want under_1d", ping.KnownInstallAgeBucket) + } + if ping.TimeToFirstMonitoredResourceBucket != "1_6h" { + t.Fatalf("TimeToFirstMonitoredResourceBucket = %q, want 1_6h", ping.TimeToFirstMonitoredResourceBucket) + } + + got := decodeLifecycleRaw(t, dir) + if !got.FirstObservedAt.Equal(start) { + t.Fatalf("persisted FirstObservedAt = %v, want preserved %v", got.FirstObservedAt, start) + } + if got.HighestObservedActivation != "monitoring" { + t.Fatalf("persisted HighestObservedActivation = %q, want monitoring", got.HighestObservedActivation) + } + if got.FirstMonitoredResourceAt == nil || !got.FirstMonitoredResourceAt.Equal(later) { + t.Fatalf("persisted FirstMonitoredResourceAt = %v, want %v", got.FirstMonitoredResourceAt, later) + } + }) + + t.Run("subsequent lower stage ping preserves highest observed activation and first monitored", func(t *testing.T) { + dir := t.TempDir() + start := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + firstMon := start.Add(30 * time.Minute) + seed := lifecycleRecord{ + FirstObservedAt: start, + FirstMonitoredResourceAt: &firstMon, + HighestObservedActivation: "monitoring", + } + encoded, err := json.Marshal(seed) + if err != nil { + t.Fatalf("marshal seed: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, lifecycleStateFile), encoded, 0600); err != nil { + t.Fatalf("write seed: %v", err) + } + + later := start.Add(48 * time.Hour) + ping := &Ping{AuthConfigured: true} + applyLifecycle(ping, dir, later) + + if ping.ActivationStage != "monitoring" { + t.Fatalf("ActivationStage = %q, want preserved monitoring", ping.ActivationStage) + } + if ping.MonitoringActive { + t.Fatal("MonitoringActive = true, want false") + } + if ping.KnownInstallAgeBucket != "1_7d" { + t.Fatalf("KnownInstallAgeBucket = %q, want 1_7d", ping.KnownInstallAgeBucket) + } + if ping.TimeToFirstMonitoredResourceBucket != "15m_1h" { + t.Fatalf("TimeToFirstMonitoredResourceBucket = %q, want 15m_1h", ping.TimeToFirstMonitoredResourceBucket) + } + + got := decodeLifecycleRaw(t, dir) + if !got.FirstObservedAt.Equal(start) { + t.Fatalf("persisted FirstObservedAt = %v, want %v", got.FirstObservedAt, start) + } + if got.HighestObservedActivation != "monitoring" { + t.Fatalf("persisted HighestObservedActivation = %q, want monitoring", got.HighestObservedActivation) + } + if got.FirstMonitoredResourceAt == nil || !got.FirstMonitoredResourceAt.Equal(firstMon) { + t.Fatalf("persisted FirstMonitoredResourceAt = %v, want preserved %v", got.FirstMonitoredResourceAt, firstMon) + } + }) + + t.Run("outcome signals set OutcomeObserved30d and upgrade stage to outcome observed", func(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + ping := &Ping{PVENodes: 1, AlertsFired30d: 2} + applyLifecycle(ping, dir, now) + + if !ping.OutcomeObserved30d { + t.Fatal("OutcomeObserved30d = false, want true") + } + if !ping.MonitoringActive { + t.Fatal("MonitoringActive = false, want true") + } + if ping.ActivationStage != "outcome_observed" { + t.Fatalf("ActivationStage = %q, want outcome_observed", ping.ActivationStage) + } + + got := decodeLifecycleRaw(t, dir) + if got.HighestObservedActivation != "outcome_observed" { + t.Fatalf("persisted HighestObservedActivation = %q, want outcome_observed", got.HighestObservedActivation) + } + }) + + t.Run("corrupted record with first monitored before first observed clamps elapsed to zero", func(t *testing.T) { + dir := t.TempDir() + firstObs := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + beforeFirstObs := firstObs.Add(-30 * time.Minute) + seed := lifecycleRecord{ + FirstObservedAt: firstObs, + FirstMonitoredResourceAt: &beforeFirstObs, + HighestObservedActivation: "monitoring", + } + encoded, err := json.Marshal(seed) + if err != nil { + t.Fatalf("marshal seed: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, lifecycleStateFile), encoded, 0600); err != nil { + t.Fatalf("write seed: %v", err) + } + + // No monitored resources -> MonitoringActive stays false and the stored + // (corrupted) FirstMonitoredResourceAt is preserved, exercising the + // negative-elapsed clamp. + ping := &Ping{AuthConfigured: true} + applyLifecycle(ping, dir, firstObs.Add(time.Hour)) + + if ping.TimeToFirstMonitoredResourceBucket != "under_15m" { + t.Fatalf("TimeToFirstMonitoredResourceBucket = %q, want under_15m (negative elapsed clamped to 0)", ping.TimeToFirstMonitoredResourceBucket) + } + if ping.ActivationStage != "monitoring" { + t.Fatalf("ActivationStage = %q, want preserved monitoring", ping.ActivationStage) + } + }) + + t.Run("unwritable data dir still populates ping and tolerates write failure", func(t *testing.T) { + dir := unwritableDataDir(t) + now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + ping := &Ping{AuthConfigured: true} + // Must not panic; the in-memory record is still applied to the ping even + // though persistence fails. + applyLifecycle(ping, dir, now) + + if ping.ActivationStage != "secured" { + t.Fatalf("ActivationStage = %q, want secured", ping.ActivationStage) + } + if ping.KnownInstallAgeBucket != "under_1d" { + t.Fatalf("KnownInstallAgeBucket = %q, want under_1d", ping.KnownInstallAgeBucket) + } + if ping.TimeToFirstMonitoredResourceBucket != "not_observed" { + t.Fatalf("TimeToFirstMonitoredResourceBucket = %q, want not_observed", ping.TimeToFirstMonitoredResourceBucket) + } + }) +} + +// TestBranchcov0723Am_ParseInstallIDRecord covers empty/whitespace input, +// garbage, a legacy plaintext UUID, valid JSON with an invalid UUID, valid JSON +// with a valid UUID but a zero IssuedAt, malformed JSON, a fully valid record, +// and the whitespace-trimming of the install_id field. +func TestBranchcov0723Am_ParseInstallIDRecord(t *testing.T) { + id := uuid.New().String() + issuedAt := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + input []byte + wantOK bool + }{ + {"empty input returns false", []byte(""), false}, + {"whitespace only input returns false", []byte(" \n\t "), false}, + {"garbage non json returns false", []byte("not-a-uuid"), false}, + {"legacy plaintext uuid returns false", []byte(id + "\n"), false}, + {"valid json but invalid uuid returns false", []byte(`{"install_id":"not-a-uuid","issued_at":"2026-03-28T12:00:00Z"}`), false}, + {"valid json valid uuid but zero issued at returns false", []byte(`{"install_id":"` + id + `"}`), false}, + {"malformed json returns false", []byte(`{"install_id":}`), false}, + {"valid record with valid uuid and issued at returns true", []byte(fmt.Sprintf(`{"install_id":%q,"issued_at":%q}`, id, issuedAt.Format(time.RFC3339Nano))), true}, + {"whitespace around install id is trimmed and record still valid", []byte(fmt.Sprintf(`{"install_id":%q,"issued_at":%q}`, " "+id+" ", issuedAt.Format(time.RFC3339Nano))), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + record, ok := parseInstallIDRecord(tt.input) + if ok != tt.wantOK { + t.Fatalf("parseInstallIDRecord(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) + } + if tt.wantOK { + if record.InstallID != id { + t.Fatalf("record.InstallID = %q, want %q", record.InstallID, id) + } + if !record.IssuedAt.Equal(issuedAt) { + t.Fatalf("record.IssuedAt = %v, want %v", record.IssuedAt, issuedAt) + } + } + }) + } +} + +// TestBranchcov0723Am_ShouldKeepInstallIDRecord covers each keep/discard arm: +// an invalid UUID, a zero IssuedAt, a future IssuedAt, an expired record at the +// exact rotation-window boundary (strict <), and a recent valid record. +func TestBranchcov0723Am_ShouldKeepInstallIDRecord(t *testing.T) { + id := uuid.New().String() + now := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + record installIDRecord + now time.Time + want bool + }{ + {"invalid uuid discarded", installIDRecord{InstallID: "nope", IssuedAt: now.Add(-time.Hour)}, now, false}, + {"zero issued at discarded", installIDRecord{InstallID: id}, now, false}, + {"future issued at discarded", installIDRecord{InstallID: id, IssuedAt: now.Add(time.Hour)}, now, false}, + {"expired at exact rotation window boundary discarded", installIDRecord{InstallID: id, IssuedAt: now.Add(-installIDRotationWindow)}, now, false}, + {"recent valid record kept", installIDRecord{InstallID: id, IssuedAt: now.Add(-time.Hour)}, now, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldKeepInstallIDRecord(tt.record, tt.now); got != tt.want { + t.Fatalf("shouldKeepInstallIDRecord(%+v, now) = %v, want %v", tt.record, got, tt.want) + } + }) + } +} + +// TestBranchcov0723Am_WriteInstallIDRecordAt covers the happy path (persisting +// a record that round-trips exactly) and the os.MkdirAll failure path when the +// dataDir's parent is a regular file. +func TestBranchcov0723Am_WriteInstallIDRecordAt(t *testing.T) { + t.Run("happy path persists record exactly", func(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC) + record := installIDRecord{InstallID: uuid.New().String(), IssuedAt: now} + if err := writeInstallIDRecordAt(dir, record); err != nil { + t.Fatalf("writeInstallIDRecordAt: %v", err) + } + got := decodeInstallIDRecordFile(t, filepath.Join(dir, installIDFile)) + if got.InstallID != record.InstallID { + t.Fatalf("persisted InstallID = %q, want %q", got.InstallID, record.InstallID) + } + if !got.IssuedAt.Equal(now) { + t.Fatalf("persisted IssuedAt = %v, want %v", got.IssuedAt, now) + } + }) + + t.Run("dataDir whose parent is a regular file returns non-nil error", func(t *testing.T) { + dir := unwritableDataDir(t) + err := writeInstallIDRecordAt(dir, installIDRecord{InstallID: uuid.New().String(), IssuedAt: time.Now()}) + if err == nil { + t.Fatal("writeInstallIDRecordAt with unwritable dataDir = nil error, want non-nil") + } + }) +} + +// TestBranchcov0723Am_ResetInstallIDAt covers the happy path (a new non-empty +// ID persisted with the given IssuedAt) and the write-failure path (empty ID +// and non-nil error). +func TestBranchcov0723Am_ResetInstallIDAt(t *testing.T) { + t.Run("happy path writes new id with given issued at", func(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC) + id, err := resetInstallIDAt(dir, now) + if err != nil { + t.Fatalf("resetInstallIDAt: %v", err) + } + if id == "" { + t.Fatal("expected non-empty install id") + } + got := decodeInstallIDRecordFile(t, filepath.Join(dir, installIDFile)) + if got.InstallID != id { + t.Fatalf("persisted InstallID = %q, want %q", got.InstallID, id) + } + if !got.IssuedAt.Equal(now) { + t.Fatalf("persisted IssuedAt = %v, want %v", got.IssuedAt, now) + } + }) + + t.Run("write error returns empty id and non-nil error", func(t *testing.T) { + dir := unwritableDataDir(t) + id, err := resetInstallIDAt(dir, time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC)) + if err == nil { + t.Fatal("resetInstallIDAt with unwritable dataDir = nil error, want non-nil") + } + if id != "" { + t.Fatalf("resetInstallIDAt returned id %q, want empty on write failure", id) + } + }) +} + +// TestBranchcov0723Am_GetOrCreateInstallIDAt covers the previously-uncovered +// write-failure arm: when persistence fails the caller still receives a fresh, +// valid, non-empty ID for the session, even though nothing lands on disk. +// (The create-when-absent, reuse-when-present, and rotation arms are already +// exercised by sibling tests in telemetry_test.go.) +func TestBranchcov0723Am_GetOrCreateInstallIDAt(t *testing.T) { + t.Run("write failure still returns a generated id for the session", func(t *testing.T) { + dir := unwritableDataDir(t) + now := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + id := getOrCreateInstallIDAt(dir, now) + if id == "" { + t.Fatal("getOrCreateInstallIDAt returned empty id; a fresh id should be generated even when persistence fails") + } + if _, err := uuid.Parse(id); err != nil { + t.Fatalf("generated id %q is not a valid uuid: %v", id, err) + } + if _, err := os.Stat(filepath.Join(dir, installIDFile)); err == nil { + t.Fatal("install id file should not be accessible after a write failure") + } + }) +} + +// TestBranchcov0723Am_InstallIDWrappers covers the thin non-"At" wrappers +// ResetInstallID and getOrCreateInstallID. Both take an explicit dataDir, so +// they are pointed at t.TempDir() and never touch a real config path. They use +// time.Now() internally, so the assertions are on non-emptiness, persistence, +// reuse, and rotation (ID change) rather than exact timestamps. +func TestBranchcov0723Am_InstallIDWrappers(t *testing.T) { + t.Run("getOrCreateInstallID creates then reuses across calls", func(t *testing.T) { + dir := t.TempDir() + first := getOrCreateInstallID(dir) + if first == "" { + t.Fatal("getOrCreateInstallID returned empty id on first call") + } + if _, err := uuid.Parse(first); err != nil { + t.Fatalf("first id %q is not a valid uuid: %v", first, err) + } + got := decodeInstallIDRecordFile(t, filepath.Join(dir, installIDFile)) + if got.InstallID != first { + t.Fatalf("persisted id = %q, want %q", got.InstallID, first) + } + if second := getOrCreateInstallID(dir); second != first { + t.Fatalf("second getOrCreateInstallID = %q, want same %q", second, first) + } + }) + + t.Run("ResetInstallID rotates and the new id persists and is reused", func(t *testing.T) { + dir := t.TempDir() + original := getOrCreateInstallID(dir) + if original == "" { + t.Fatal("getOrCreateInstallID returned empty id") + } + rotated, err := ResetInstallID(dir) + if err != nil { + t.Fatalf("ResetInstallID: %v", err) + } + if rotated == "" { + t.Fatal("ResetInstallID returned empty id") + } + if rotated == original { + t.Fatalf("ResetInstallID returned the same id %q, want a new rotated one", rotated) + } + if next := getOrCreateInstallID(dir); next != rotated { + t.Fatalf("getOrCreateInstallID after reset = %q, want persisted %q", next, rotated) + } + }) +}