From 278673aa0f1cd10fe5fd40f1f00e4d67287c500a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 18 Jul 2026 22:31:08 +0100 Subject: [PATCH] Add Go branch-coverage tests for twelve pure backend helpers New *_branchcov0718_test.go files raise coverage of previously-uncovered pure functions across ten packages. Covered areas include securityutil SSRF and URL validation, truenas path and telemetry parse helpers, storagehealth SMART and physical-disk risk assessment, vmware inventory sort keys and error classifiers, servicediscovery token filtering and readiness, telemetry evidence-from-history, models ToFrontend converters and frontend NormalizeCollections normalizers, actionplanner type predicates and canonical resource-id sort, config API-token accessors, and licensing state accessors. Test-only, with no runtime or subsystem-contract change. Verified in a clean worktree at HEAD with go vet and package tests green, gofmt clean, and every named target function moved from 0 percent to covered. --- .../planner_branchcov0718_test.go | 385 ++++++++++ .../config/api_tokens_branchcov0718_test.go | 188 +++++ .../models/converters_branchcov0718_test.go | 463 ++++++++++++ .../models_frontend_branchcov0718_test.go | 690 ++++++++++++++++++ .../formatters_branchcov0718_test.go | 465 ++++++++++++ .../readiness_branchcov0718_test.go | 501 +++++++++++++ .../storagehealth/risk_branchcov0718_test.go | 570 +++++++++++++++ ...ntelligence_evidence_branchcov0718_test.go | 397 ++++++++++ internal/truenas/client_branchcov0718_test.go | 432 +++++++++++ .../client_signals_branchcov0718_test.go | 403 ++++++++++ .../service_state_branchcov0718_test.go | 360 +++++++++ .../outbound_http_branchcov0718_test.go | 313 ++++++++ 12 files changed, 5167 insertions(+) create mode 100644 internal/actionplanner/planner_branchcov0718_test.go create mode 100644 internal/config/api_tokens_branchcov0718_test.go create mode 100644 internal/models/converters_branchcov0718_test.go create mode 100644 internal/models/models_frontend_branchcov0718_test.go create mode 100644 internal/servicediscovery/formatters_branchcov0718_test.go create mode 100644 internal/servicediscovery/readiness_branchcov0718_test.go create mode 100644 internal/storagehealth/risk_branchcov0718_test.go create mode 100644 internal/telemetry/pulse_intelligence_evidence_branchcov0718_test.go create mode 100644 internal/truenas/client_branchcov0718_test.go create mode 100644 internal/vmware/client_signals_branchcov0718_test.go create mode 100644 pkg/licensing/service_state_branchcov0718_test.go create mode 100644 pkg/securityutil/outbound_http_branchcov0718_test.go diff --git a/internal/actionplanner/planner_branchcov0718_test.go b/internal/actionplanner/planner_branchcov0718_test.go new file mode 100644 index 000000000..6bab2aadc --- /dev/null +++ b/internal/actionplanner/planner_branchcov0718_test.go @@ -0,0 +1,385 @@ +package actionplanner + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +func TestIsIntegerBranchCoverage0718(t *testing.T) { + steps := []struct { + name string + value any + want bool + }{ + {"int", int(42), true}, + {"int8", int8(42), true}, + {"int16", int16(42), true}, + {"int32", int32(42), true}, + {"int64", int64(42), true}, + {"uint", uint(42), true}, + {"uint8", uint8(42), true}, + {"uint16", uint16(42), true}, + {"uint32", uint32(42), true}, + {"uint64", uint64(42), true}, + {"float32_whole", float32(42), true}, + {"float32_fractional", float32(42.5), false}, + {"float64_whole", float64(42), true}, + {"float64_negative_whole", float64(-7), true}, + {"float64_fractional", float64(42.5), false}, + {"json_number_whole", json.Number("42"), true}, + {"json_number_negative_whole", json.Number("-7"), true}, + {"json_number_fractional", json.Number("42.5"), false}, + {"json_number_overflow", json.Number("9999999999999999999999"), false}, + {"string", "42", false}, + {"bool_true", true, false}, + {"nil", nil, false}, + {"map_string_any", map[string]any{"a": 1}, false}, + {"slice_int", []int{1}, false}, + {"struct", struct{}{}, false}, + } + for _, step := range steps { + t.Run(step.name, func(t *testing.T) { + got := isInteger(step.value) + if got != step.want { + t.Fatalf("isInteger(%T %v) = %v, want %v", step.value, step.value, got, step.want) + } + }) + } +} + +func TestIsNumberBranchCoverage0718(t *testing.T) { + steps := []struct { + name string + value any + want bool + }{ + {"int", int(42), true}, + {"int8", int8(42), true}, + {"int16", int16(42), true}, + {"int32", int32(42), true}, + {"int64", int64(42), true}, + {"uint", uint(42), true}, + {"uint8", uint8(42), true}, + {"uint16", uint16(42), true}, + {"uint32", uint32(42), true}, + {"uint64", uint64(42), true}, + {"float32", float32(42.5), true}, + {"float64", float64(42.5), true}, + {"json_number", json.Number("42.5"), true}, + {"string", "42", false}, + {"bool_true", true, false}, + {"nil", nil, false}, + {"map_string_any", map[string]any{"a": 1}, false}, + {"slice_int", []int{1}, false}, + {"struct", struct{}{}, false}, + } + for _, step := range steps { + t.Run(step.name, func(t *testing.T) { + got := isNumber(step.value) + if got != step.want { + t.Fatalf("isNumber(%T %v) = %v, want %v", step.value, step.value, got, step.want) + } + }) + } +} + +func TestIsMapBranchCoverage0718(t *testing.T) { + steps := []struct { + name string + value any + want bool + }{ + {"map_string_any", map[string]any{"a": 1}, true}, + {"empty_map_string_any", map[string]any{}, true}, + {"map_int_string", map[int]string{1: "a"}, true}, + {"map_string_string", map[string]string{"a": "b"}, true}, + {"nil", nil, false}, + {"slice_int", []int{1}, false}, + {"array", [3]int{1, 2, 3}, false}, + {"string", "foo", false}, + {"int", 42, false}, + {"bool_true", true, false}, + {"struct", struct{}{}, false}, + } + for _, step := range steps { + t.Run(step.name, func(t *testing.T) { + got := isMap(step.value) + if got != step.want { + t.Fatalf("isMap(%T %v) = %v, want %v", step.value, step.value, got, step.want) + } + }) + } +} + +func TestIsSliceBranchCoverage0718(t *testing.T) { + steps := []struct { + name string + value any + want bool + }{ + {"slice_int", []int{1, 2}, true}, + {"slice_string", []string{"a"}, true}, + {"empty_slice", []int{}, true}, + {"nil_slice", []int(nil), true}, + {"array", [3]int{1, 2, 3}, true}, + {"nil", nil, false}, + {"map_string_any", map[string]any{"a": 1}, false}, + {"string", "foo", false}, + {"int", 42, false}, + {"bool_true", true, false}, + {"struct", struct{}{}, false}, + } + for _, step := range steps { + t.Run(step.name, func(t *testing.T) { + got := isSlice(step.value) + if got != step.want { + t.Fatalf("isSlice(%T %v) = %v, want %v", step.value, step.value, got, step.want) + } + }) + } +} + +func TestSortedCanonicalResourceIDsBranchCoverage0718(t *testing.T) { + t.Run("nil_input_returns_nonnil_empty", func(t *testing.T) { + got := sortedCanonicalResourceIDs(nil) + if got == nil { + t.Fatalf("sortedCanonicalResourceIDs(nil) = nil, want non-nil empty slice") + } + if len(got) != 0 { + t.Fatalf("sortedCanonicalResourceIDs(nil) = %#v, want empty", got) + } + }) + t.Run("empty_input_returns_nonnil_empty", func(t *testing.T) { + got := sortedCanonicalResourceIDs([]string{}) + if got == nil { + t.Fatalf("sortedCanonicalResourceIDs([]) = nil, want non-nil empty slice") + } + if len(got) != 0 { + t.Fatalf("sortedCanonicalResourceIDs([]) = %#v, want empty", got) + } + }) + t.Run("all_blank_input_returns_nonnil_empty", func(t *testing.T) { + got := sortedCanonicalResourceIDs([]string{"", " ", "\t"}) + if got == nil || len(got) != 0 { + t.Fatalf("sortedCanonicalResourceIDs(%#v) = %#v, want non-nil empty slice", []string{"", " ", "\t"}, got) + } + }) + t.Run("sorts_dedupes_and_trims", func(t *testing.T) { + input := []string{" vm:3 ", "vm:1", "vm:2", "vm:1", "", " ", "vm:3"} + got := sortedCanonicalResourceIDs(input) + want := []string{"vm:1", "vm:2", "vm:3"} + if len(got) != len(want) { + t.Fatalf("len = %d, want %d (got=%#v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("idx %d = %q, want %q (got=%#v)", i, got[i], want[i], got) + } + } + }) + t.Run("already_sorted_unchanged", func(t *testing.T) { + got := sortedCanonicalResourceIDs([]string{"vm:1", "vm:2", "vm:3"}) + want := []string{"vm:1", "vm:2", "vm:3"} + if len(got) != len(want) { + t.Fatalf("len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("idx %d = %q, want %q", i, got[i], want[i]) + } + } + }) + t.Run("reverse_order_sorted", func(t *testing.T) { + got := sortedCanonicalResourceIDs([]string{"vm:3", "vm:2", "vm:1"}) + want := []string{"vm:1", "vm:2", "vm:3"} + if len(got) != len(want) { + t.Fatalf("len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("idx %d = %q, want %q", i, got[i], want[i]) + } + } + }) +} + +func TestPlanWithRequirementBranchCoverage0718(t *testing.T) { + now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + resource := unified.Resource{ + ID: "vm:42", + Type: unified.ResourceTypeVM, + Name: "web-42", + Status: unified.StatusOnline, + Capabilities: []unified.ResourceCapability{{ + Name: "restart", + Type: unified.CapabilityTypeCommon, + Description: "Restart the VM", + MinimumApprovalLevel: unified.ApprovalAdmin, + }}, + } + actor := unified.ActionActor{ + SubjectID: "agent:oncall-helper", + Kind: unified.ActionActorService, + CredentialID: "service:test", + OrgID: "default", + } + + t.Run("happy_path_floor_requirement_deterministic", func(t *testing.T) { + req := unified.ActionRequest{ + RequestID: "agent-run-req-floor", + ResourceID: "vm:42", + CapabilityName: "restart", + Reason: "recover", + Actor: actor, + } + planner := Planner{Now: func() time.Time { return now }} + plan, err := planner.PlanWithRequirement(req, resource, unified.ApprovalRequirement{}) + if err != nil { + t.Fatalf("PlanWithRequirement() error = %v", err) + } + if plan.ActionID == "" { + t.Fatal("ActionID is empty") + } + if plan.PlanHash == "" { + t.Fatal("PlanHash is empty") + } + if !plan.Allowed { + t.Fatal("Allowed = false, want true") + } + if !plan.RequiresApproval { + t.Fatal("RequiresApproval = false, want true for ApprovalAdmin floor") + } + if plan.ApprovalPolicy != unified.ApprovalAdmin { + t.Fatalf("ApprovalPolicy = %q, want %q", plan.ApprovalPolicy, unified.ApprovalAdmin) + } + if plan.Preflight == nil { + t.Fatal("Preflight is nil") + } + if plan.Preflight.Target != "vm:42" { + t.Fatalf("Preflight.Target = %q, want vm:42", plan.Preflight.Target) + } + if !plan.PlannedAt.Equal(now) { + t.Fatalf("PlannedAt = %s, want %s", plan.PlannedAt, now) + } + if !plan.ExpiresAt.Equal(now.Add(DefaultPlanTTL)) { + t.Fatalf("ExpiresAt = %s, want %s", plan.ExpiresAt, now.Add(DefaultPlanTTL)) + } + again, err := planner.PlanWithRequirement(req, resource, unified.ApprovalRequirement{}) + if err != nil { + t.Fatalf("second call error = %v", err) + } + if again.ActionID != plan.ActionID || again.PlanHash != plan.PlanHash { + t.Fatalf("plan not deterministic: first=(%q,%q) second=(%q,%q)", + plan.ActionID, plan.PlanHash, again.ActionID, again.PlanHash) + } + }) + + t.Run("happy_path_explicit_requirement_version_used", func(t *testing.T) { + req := unified.ActionRequest{ + RequestID: "agent-run-req-explicit", + ResourceID: "vm:42", + CapabilityName: "restart", + Reason: "recover", + Actor: actor, + } + requested := unified.ApprovalRequirement{ + Version: 1, + Floor: unified.ApprovalAdmin, + } + planner := Planner{Now: func() time.Time { return now }} + plan, err := planner.PlanWithRequirement(req, resource, requested) + if err != nil { + t.Fatalf("PlanWithRequirement() error = %v", err) + } + if plan.ApprovalRequirement.Version != requested.Version { + t.Fatalf("plan requirement Version = %d, want %d", plan.ApprovalRequirement.Version, requested.Version) + } + if plan.ActionID == "" || plan.PlanHash == "" { + t.Fatalf("ActionID/PlanHash not populated: %#v", plan) + } + }) + + t.Run("missing_request_id_returns_validation_error", func(t *testing.T) { + req := unified.ActionRequest{ + ResourceID: "vm:42", + CapabilityName: "restart", + Reason: "recover", + Actor: actor, + } + planner := Planner{Now: func() time.Time { return now }} + _, err := planner.PlanWithRequirement(req, resource, unified.ApprovalRequirement{}) + validationErr, ok := AsValidationError(err) + if !ok { + t.Fatalf("PlanWithRequirement() error = %v, want validation error", err) + } + if validationErr.Field != "requestId" { + t.Fatalf("validation field = %q, want requestId", validationErr.Field) + } + }) + + t.Run("resource_id_mismatch_returns_validation_error", func(t *testing.T) { + req := unified.ActionRequest{ + RequestID: "agent-run-req-mismatch", + ResourceID: "vm:99", + CapabilityName: "restart", + Reason: "recover", + Actor: actor, + } + planner := Planner{Now: func() time.Time { return now }} + _, err := planner.PlanWithRequirement(req, resource, unified.ApprovalRequirement{}) + validationErr, ok := AsValidationError(err) + if !ok { + t.Fatalf("error = %v, want validation error", err) + } + if validationErr.Field != "resourceId" { + t.Fatalf("validation field = %q, want resourceId", validationErr.Field) + } + if !strings.Contains(validationErr.Message, "does not match") { + t.Fatalf("validation message = %q, want substring 'does not match'", validationErr.Message) + } + }) + + t.Run("missing_capability_returns_capability_not_found", func(t *testing.T) { + req := unified.ActionRequest{ + RequestID: "agent-run-req-no-cap", + ResourceID: "vm:42", + CapabilityName: "restart", + Reason: "recover", + Actor: actor, + } + emptyResource := unified.Resource{ID: "vm:42", Type: unified.ResourceTypeVM} + planner := Planner{Now: func() time.Time { return now }} + _, err := planner.PlanWithRequirement(req, emptyResource, unified.ApprovalRequirement{}) + if !errors.Is(err, ErrCapabilityNotFound) { + t.Fatalf("error = %v, want ErrCapabilityNotFound", err) + } + }) + + t.Run("resource_without_canonical_id_returns_validation_error", func(t *testing.T) { + req := unified.ActionRequest{ + RequestID: "agent-run-req-no-canonical", + ResourceID: "vm:42", + CapabilityName: "restart", + Reason: "recover", + Actor: actor, + } + blankResource := unified.Resource{Type: unified.ResourceTypeVM} + planner := Planner{Now: func() time.Time { return now }} + _, err := planner.PlanWithRequirement(req, blankResource, unified.ApprovalRequirement{}) + validationErr, ok := AsValidationError(err) + if !ok { + t.Fatalf("error = %v, want validation error", err) + } + if validationErr.Field != "resourceId" { + t.Fatalf("validation field = %q, want resourceId", validationErr.Field) + } + if !strings.Contains(validationErr.Message, "no canonical id") { + t.Fatalf("validation message = %q, want substring 'no canonical id'", validationErr.Message) + } + }) +} diff --git a/internal/config/api_tokens_branchcov0718_test.go b/internal/config/api_tokens_branchcov0718_test.go new file mode 100644 index 000000000..f0a55913d --- /dev/null +++ b/internal/config/api_tokens_branchcov0718_test.go @@ -0,0 +1,188 @@ +package config + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +// TestActiveAPITokenHashes_BranchCov0718 exercises every branch of +// (*Config).ActiveAPITokenHashes: empty config, single token, multiple tokens +// preserving order, records with empty hashes being filtered, all-empty result, +// and expired tokens still being returned (the method filters only on Hash != ""). +func TestActiveAPITokenHashes_BranchCov0718(t *testing.T) { + pastExpiry := time.Now().UTC().Add(-time.Hour) + + tests := []struct { + name string + config *Config + want []string + }{ + { + name: "nil token slice returns non-nil empty result", + config: &Config{}, + want: []string{}, + }, + { + name: "single token hash returned", + config: &Config{ + APITokens: []APITokenRecord{{ID: "t1", Hash: "hash-1"}}, + }, + want: []string{"hash-1"}, + }, + { + name: "multiple tokens preserve insertion order", + config: &Config{ + APITokens: []APITokenRecord{ + {ID: "t1", Hash: "hash-1"}, + {ID: "t2", Hash: "hash-2"}, + {ID: "t3", Hash: "hash-3"}, + }, + }, + want: []string{"hash-1", "hash-2", "hash-3"}, + }, + { + name: "empty-hash records are filtered out while non-empty are kept in order", + config: &Config{ + APITokens: []APITokenRecord{ + {ID: "t1", Hash: ""}, + {ID: "t2", Hash: "hash-2"}, + {ID: "t3", Hash: ""}, + {ID: "t4", Hash: "hash-4"}, + }, + }, + want: []string{"hash-2", "hash-4"}, + }, + { + name: "all empty hashes yields empty non-nil result", + config: &Config{ + APITokens: []APITokenRecord{ + {ID: "t1", Hash: ""}, + {ID: "t2", Hash: ""}, + }, + }, + want: []string{}, + }, + { + name: "expired tokens are still returned (method does not filter on expiry)", + config: &Config{ + APITokens: []APITokenRecord{ + {ID: "t1", Hash: "hash-1", ExpiresAt: &pastExpiry}, + {ID: "t2", Hash: "hash-2"}, + }, + }, + want: []string{"hash-1", "hash-2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.config.ActiveAPITokenHashes() + assert.NotNil(t, got, "result should be non-nil even when empty (make-allocated)") + assert.Equal(t, tt.want, got) + }) + } +} + +// TestHasAPITokenHash_BranchCov0718 covers (*Config).HasAPITokenHash across +// present, absent, empty-query, and the empty-query-matches-empty-hash-record +// edge case (a direct == comparison with no empty-string guard). +func TestHasAPITokenHash_BranchCov0718(t *testing.T) { + tests := []struct { + name string + config *Config + hash string + want bool + }{ + { + name: "empty config reports absent", + config: &Config{}, + hash: "hash-1", + want: false, + }, + { + name: "matching hash at first position", + config: &Config{ + APITokens: []APITokenRecord{{Hash: "hash-1"}, {Hash: "hash-2"}}, + }, + hash: "hash-1", + want: true, + }, + { + name: "matching hash at non-first position", + config: &Config{ + APITokens: []APITokenRecord{{Hash: "hash-1"}, {Hash: "hash-2"}}, + }, + hash: "hash-2", + want: true, + }, + { + name: "non-matching hash reports absent", + config: &Config{ + APITokens: []APITokenRecord{{Hash: "hash-1"}, {Hash: "hash-2"}}, + }, + hash: "not-present", + want: false, + }, + { + name: "empty query hash against populated config reports absent", + config: &Config{ + APITokens: []APITokenRecord{{Hash: "hash-1"}, {Hash: "hash-2"}}, + }, + hash: "", + want: false, + }, + { + name: "empty query hash matches a stored empty-hash record", + config: &Config{ + APITokens: []APITokenRecord{{Hash: ""}, {Hash: "hash-2"}}, + }, + hash: "", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.config.HasAPITokenHash(tt.hash)) + }) + } +} + +// TestNewAvailabilityTarget_BranchCov0718 asserts every default field set by +// NewAvailabilityTarget, validates the generated ID is a parseable UUID, and +// verifies successive calls produce distinct IDs. +func TestNewAvailabilityTarget_BranchCov0718(t *testing.T) { + t.Run("returns canonical defaults", func(t *testing.T) { + target := NewAvailabilityTarget() + + assert.NotEmpty(t, target.ID, "ID should be generated") + parsed, err := uuid.Parse(target.ID) + assert.NoError(t, err, "ID should be a parseable UUID") + assert.Equal(t, target.ID, parsed.String(), "ID should be in canonical UUID form") + + assert.Equal(t, AvailabilityTargetService, target.TargetKind) + assert.Equal(t, AvailabilityProbeICMP, target.Protocol) + assert.True(t, target.Enabled, "Enabled should default to true") + assert.Equal(t, DefaultAvailabilityPollIntervalSecs, target.PollIntervalSecs) + assert.Equal(t, DefaultAvailabilityTimeoutMillis, target.TimeoutMillis) + assert.Equal(t, DefaultAvailabilityFailureThreshold, target.FailureThreshold) + + // Fields left at their zero values by the constructor. + assert.Empty(t, target.Name) + assert.Empty(t, target.Address) + assert.Equal(t, 0, target.Port) + assert.Empty(t, target.Path) + assert.Empty(t, target.LinkedResourceID) + }) + + t.Run("each call generates a distinct UUID", func(t *testing.T) { + a := NewAvailabilityTarget() + b := NewAvailabilityTarget() + assert.NotEqual(t, a.ID, b.ID, "successive calls should produce distinct IDs") + assert.NotEmpty(t, a.ID) + assert.NotEmpty(t, b.ID) + }) +} diff --git a/internal/models/converters_branchcov0718_test.go b/internal/models/converters_branchcov0718_test.go new file mode 100644 index 000000000..3048f33ec --- /dev/null +++ b/internal/models/converters_branchcov0718_test.go @@ -0,0 +1,463 @@ +package models + +import ( + "testing" + "time" +) + +// This file exercises previously-uncovered branches of the ToFrontend converters +// in converters.go. It focuses on nested-collection mapping and conditional arms +// (nil vs populated, zero vs non-zero timestamps), not trivial field copies. + +func TestDockerSecretToFrontend_BranchCov0718(t *testing.T) { + now := time.Now() + updatedAt := now.Add(-time.Hour) + + t.Run("empty normalizes labels and omits timestamps", func(t *testing.T) { + s := DockerSecret{ID: "sec-1", Name: "empty"} + f := s.ToFrontend() + if f.ID != "sec-1" || f.Name != "empty" { + t.Fatalf("identity fields not mapped: %#v", f) + } + if f.CreatedAt != nil { + t.Fatalf("CreatedAt = %#v, want nil for zero time", f.CreatedAt) + } + if f.UpdatedAt != nil { + t.Fatalf("UpdatedAt = %#v, want nil when source pointer is nil", f.UpdatedAt) + } + if f.Labels == nil { + t.Fatal("Labels should normalize to a non-nil empty map") + } + if len(f.Labels) != 0 { + t.Fatalf("Labels = %#v, want empty map", f.Labels) + } + }) + + t.Run("populated maps all fields and clones labels", func(t *testing.T) { + s := DockerSecret{ + ID: "sec-2", + Name: "tls-cert", + DriverName: "file", + TemplatingDriver: "golang", + Labels: map[string]string{"env": "prod", "team": "infra"}, + CreatedAt: now, + UpdatedAt: &updatedAt, + } + f := s.ToFrontend() + if f.DriverName != "file" || f.TemplatingDriver != "golang" { + t.Fatalf("driver fields not mapped: %#v", f) + } + if f.CreatedAt == nil || *f.CreatedAt != now.Unix()*1000 { + t.Fatalf("CreatedAt = %#v, want %d", f.CreatedAt, now.Unix()*1000) + } + if f.UpdatedAt == nil || *f.UpdatedAt != updatedAt.Unix()*1000 { + t.Fatalf("UpdatedAt = %#v, want %d", f.UpdatedAt, updatedAt.Unix()*1000) + } + if len(f.Labels) != 2 || f.Labels["env"] != "prod" || f.Labels["team"] != "infra" { + t.Fatalf("Labels not copied: %#v", f.Labels) + } + // Labels must be a copy, not an alias of the source map. + f.Labels["env"] = "MUTATED" + if s.Labels["env"] != "prod" { + t.Fatal("frontend Labels must not alias the source map") + } + }) + + t.Run("zero updatedAt pointer is dropped", func(t *testing.T) { + zero := time.Time{} + s := DockerSecret{ID: "sec-3", UpdatedAt: &zero} + f := s.ToFrontend() + if f.UpdatedAt != nil { + t.Fatalf("UpdatedAt = %#v, want nil for zero time", f.UpdatedAt) + } + }) +} + +func TestDockerConfigToFrontend_BranchCov0718(t *testing.T) { + now := time.Now() + updatedAt := now.Add(-30 * time.Minute) + + t.Run("empty normalizes labels and omits timestamps", func(t *testing.T) { + c := DockerConfig{ID: "cfg-1", Name: "empty"} + f := c.ToFrontend() + if f.ID != "cfg-1" || f.Name != "empty" { + t.Fatalf("identity fields not mapped: %#v", f) + } + if f.CreatedAt != nil || f.UpdatedAt != nil { + t.Fatalf("timestamps should be nil for empty config: %#v", f) + } + if f.Labels == nil || len(f.Labels) != 0 { + t.Fatalf("Labels should normalize to non-nil empty map: %#v", f.Labels) + } + }) + + t.Run("populated maps all fields and clones labels", func(t *testing.T) { + c := DockerConfig{ + ID: "cfg-2", + Name: "nginx-conf", + TemplatingDriver: "golang", + Labels: map[string]string{"tier": "web", "owner": "sre"}, + CreatedAt: now, + UpdatedAt: &updatedAt, + } + f := c.ToFrontend() + if f.TemplatingDriver != "golang" { + t.Fatalf("TemplatingDriver = %q, want golang", f.TemplatingDriver) + } + if f.CreatedAt == nil || *f.CreatedAt != now.Unix()*1000 { + t.Fatalf("CreatedAt = %#v, want %d", f.CreatedAt, now.Unix()*1000) + } + if f.UpdatedAt == nil || *f.UpdatedAt != updatedAt.Unix()*1000 { + t.Fatalf("UpdatedAt = %#v, want %d", f.UpdatedAt, updatedAt.Unix()*1000) + } + if len(f.Labels) != 2 || f.Labels["tier"] != "web" { + t.Fatalf("Labels not copied: %#v", f.Labels) + } + f.Labels["tier"] = "MUTATED" + if c.Labels["tier"] != "web" { + t.Fatal("frontend Labels must not alias the source map") + } + }) +} + +func TestNodeToFrontend_TemperatureBranchCov0718(t *testing.T) { + temp := &Temperature{CPUPackage: 72.5, Available: true} + node := Node{ + ID: "node-temp", + Name: "pve-temp", + LastSeen: time.Now(), + Temperature: temp, + } + f := node.ToFrontend() + if f.Temperature == nil { + t.Fatal("Temperature should be mapped when Temperature.Available is true") + } + if f.Temperature != temp { + t.Fatalf("Temperature pointer = %p, want %p (should pass through)", f.Temperature, temp) + } + if f.Temperature.CPUPackage != 72.5 { + t.Fatalf("Temperature.CPUPackage = %v, want 72.5", f.Temperature.CPUPackage) + } + + // Available=false arm: temperature must NOT be mapped. + nodeUnavailable := Node{ + Name: "pve-no-temp", + LastSeen: time.Now(), + Temperature: &Temperature{CPUPackage: 50, Available: false}, + } + if fu := nodeUnavailable.ToFrontend(); fu.Temperature != nil { + t.Fatalf("Temperature should be nil when Available=false, got %#v", fu.Temperature) + } + + // nil arm: temperature must NOT be mapped. + nodeNil := Node{Name: "pve-nil-temp", LastSeen: time.Now()} + if fn := nodeNil.ToFrontend(); fn.Temperature != nil { + t.Fatalf("Temperature should be nil when source is nil, got %#v", fn.Temperature) + } +} + +func TestVMToFrontend_CollectionsBranchCov0718(t *testing.T) { + vm := VM{ + ID: "vm-coll", + Name: "vm-a", + LastSeen: time.Now(), + AgentVersion: "agent-9.9", + Disks: []Disk{{Total: 100, Used: 50}, {Total: 200, Used: 80}}, + NetworkInterfaces: []GuestNetworkInterface{ + {Name: "net0", Addresses: []string{"10.0.0.5"}}, + }, + } + f := vm.ToFrontend() + if f.AgentVersion != "agent-9.9" { + t.Fatalf("AgentVersion = %q, want agent-9.9", f.AgentVersion) + } + if len(f.Disks) != 2 || f.Disks[1].Total != 200 { + t.Fatalf("Disks array not mapped: %#v", f.Disks) + } + if len(f.NetworkInterfaces) != 1 || f.NetworkInterfaces[0].Name != "net0" { + t.Fatalf("NetworkInterfaces not mapped: %#v", f.NetworkInterfaces) + } + // NetworkInterfaces are copied element-wise; mutating a copy element must not affect source. + f.NetworkInterfaces[0].Name = "mutated" + if vm.NetworkInterfaces[0].Name != "net0" { + t.Fatal("frontend NetworkInterfaces must not alias the source slice elements") + } +} + +func TestContainerToFrontend_CollectionsBranchCov0718(t *testing.T) { + ct := Container{ + ID: "ct-coll", + Name: "ct-a", + LastSeen: time.Now(), + OSName: "Alpine", + Disks: []Disk{{Total: 300, Used: 100}}, + NetworkInterfaces: []GuestNetworkInterface{ + {Name: "eth0", Addresses: []string{"172.16.0.2"}}, + }, + } + f := ct.ToFrontend() + if f.OSName != "Alpine" { + t.Fatalf("OSName = %q, want Alpine", f.OSName) + } + if len(f.Disks) != 1 || f.Disks[0].Total != 300 { + t.Fatalf("Disks array not mapped: %#v", f.Disks) + } + if len(f.NetworkInterfaces) != 1 || f.NetworkInterfaces[0].Name != "eth0" { + t.Fatalf("NetworkInterfaces not mapped: %#v", f.NetworkInterfaces) + } +} + +func TestDockerHostToFrontend_SecretsConfigsSecurity_BranchCov0718(t *testing.T) { + now := time.Now() + updated := now.Add(-time.Hour) + host := DockerHost{ + ID: "dh-swarm", + Hostname: "swarm-node", + LastSeen: now, + Secrets: []DockerSecret{ + {ID: "sec-1", Name: "db-password", Labels: map[string]string{"env": "prod"}, CreatedAt: now, UpdatedAt: &updated}, + }, + Configs: []DockerConfig{ + {ID: "cfg-1", Name: "nginx-conf", Labels: map[string]string{"tier": "web"}, CreatedAt: now, UpdatedAt: &updated}, + }, + Security: &DockerHostSecurity{ + AuthorizationPlugins: []string{"authz", "opa"}, + MutatingCommandsBlocked: true, + MutatingCommandsBlockedReason: "policy", + }, + } + f := host.ToFrontend() + + // Secrets nested-conversion arm. + if len(f.Secrets) != 1 || f.Secrets[0].ID != "sec-1" { + t.Fatalf("Secrets not mapped via nested ToFrontend: %#v", f.Secrets) + } + if f.Secrets[0].CreatedAt == nil || *f.Secrets[0].CreatedAt != now.Unix()*1000 { + t.Fatalf("nested Secret CreatedAt not converted: %#v", f.Secrets[0].CreatedAt) + } + if f.Secrets[0].Labels["env"] != "prod" { + t.Fatalf("nested Secret labels not mapped: %#v", f.Secrets[0].Labels) + } + + // Configs nested-conversion arm. + if len(f.Configs) != 1 || f.Configs[0].ID != "cfg-1" { + t.Fatalf("Configs not mapped via nested ToFrontend: %#v", f.Configs) + } + if f.Configs[0].UpdatedAt == nil || *f.Configs[0].UpdatedAt != updated.Unix()*1000 { + t.Fatalf("nested Config UpdatedAt not converted: %#v", f.Configs[0].UpdatedAt) + } + if f.Configs[0].Labels["tier"] != "web" { + t.Fatalf("nested Config labels not mapped: %#v", f.Configs[0].Labels) + } + + // Security arm + AuthorizationPlugins clone independence. + if f.Security == nil || !f.Security.MutatingCommandsBlocked || f.Security.MutatingCommandsBlockedReason != "policy" { + t.Fatalf("Security not mapped: %#v", f.Security) + } + if len(f.Security.AuthorizationPlugins) != 2 || f.Security.AuthorizationPlugins[0] != "authz" { + t.Fatalf("AuthorizationPlugins not mapped: %#v", f.Security.AuthorizationPlugins) + } + f.Security.AuthorizationPlugins[0] = "MUTATED" + if host.Security.AuthorizationPlugins[0] != "authz" { + t.Fatal("frontend AuthorizationPlugins must not alias the source slice") + } +} + +func TestHostToFrontend_DiskIOAndCloneBranches_BranchCov0718(t *testing.T) { + lastChecked := time.Now().Add(-time.Hour) + lastAttempt := time.Now().Add(-30 * time.Minute) + lastSuccess := time.Now().Add(-15 * time.Minute) + + host := Host{ + ID: "host-full", + Hostname: "server-full", + LastSeen: time.Now(), + DiskIO: []DiskIO{{Device: "sda", ReadBytes: 1000, WriteBytes: 2000}, {Device: "sdb", ReadBytes: 3000}}, + AppliedConfig: &AgentConfigFingerprint{Version: "v1", Hash: "abc123"}, + AgentUpdate: &AgentUpdateStatus{ + State: "available", + AvailableVersion: "2.0.0", + LastCheckedAt: &lastChecked, + LastAttemptAt: &lastAttempt, + LastSuccessAt: &lastSuccess, + }, + AgentModules: []AgentModuleStatus{{Name: "docker", Enabled: true, State: "ok"}}, + } + f := host.ToFrontend() + + // DiskIO copy arm. + if len(f.DiskIO) != 2 || f.DiskIO[0].Device != "sda" || f.DiskIO[1].ReadBytes != 3000 { + t.Fatalf("DiskIO not mapped: %#v", f.DiskIO) + } + + // AppliedConfig deep clone (non-nil arm of cloneAgentConfigFingerprint). + if f.AppliedConfig == nil || f.AppliedConfig.Hash != "abc123" || f.AppliedConfig.Version != "v1" { + t.Fatalf("AppliedConfig not cloned: %#v", f.AppliedConfig) + } + f.AppliedConfig.Hash = "MUTATED" + if host.AppliedConfig.Hash != "abc123" { + t.Fatal("frontend AppliedConfig must not alias the source struct") + } + + // AgentUpdate deep clone with nested timestamp pointers. + if f.AgentUpdate == nil || f.AgentUpdate.AvailableVersion != "2.0.0" { + t.Fatalf("AgentUpdate not cloned: %#v", f.AgentUpdate) + } + if f.AgentUpdate.LastCheckedAt == nil || !f.AgentUpdate.LastCheckedAt.Equal(lastChecked) { + t.Fatalf("AgentUpdate.LastCheckedAt not deep-cloned: %#v", f.AgentUpdate.LastCheckedAt) + } + if f.AgentUpdate.LastAttemptAt == nil || !f.AgentUpdate.LastAttemptAt.Equal(lastAttempt) { + t.Fatalf("AgentUpdate.LastAttemptAt not deep-cloned: %#v", f.AgentUpdate.LastAttemptAt) + } + if f.AgentUpdate.LastSuccessAt == nil || !f.AgentUpdate.LastSuccessAt.Equal(lastSuccess) { + t.Fatalf("AgentUpdate.LastSuccessAt not deep-cloned: %#v", f.AgentUpdate.LastSuccessAt) + } + // The cloned timestamp pointers must not alias the source pointers. + *f.AgentUpdate.LastCheckedAt = time.Time{} + if !host.AgentUpdate.LastCheckedAt.Equal(lastChecked) { + t.Fatal("frontend AgentUpdate.LastCheckedAt pointer must not alias the source pointer") + } + + // AgentModules clone (non-empty arm of cloneAgentModuleStatuses). + if len(f.AgentModules) != 1 || f.AgentModules[0].Name != "docker" { + t.Fatalf("AgentModules not cloned: %#v", f.AgentModules) + } + f.AgentModules[0].Name = "MUTATED" + if host.AgentModules[0].Name != "docker" { + t.Fatal("frontend AgentModules must not alias the source slice elements") + } +} + +func TestDockerContainerToFrontend_FinishedLabelsUpdate_BranchCov0718(t *testing.T) { + now := time.Now() + finished := now.Add(-5 * time.Minute) + lastChecked := now.Add(-1 * time.Minute) + + c := DockerContainer{ + ID: "ct-branches", + Name: "web", + CreatedAt: now, + FinishedAt: &finished, + Labels: map[string]string{ + "com.docker.compose.service": "api", + "app": "shop", + }, + UpdateStatus: &DockerContainerUpdateStatus{ + UpdateAvailable: true, + CurrentDigest: "sha256:aaa", + LatestDigest: "sha256:bbb", + LastChecked: lastChecked, + Error: "rate limited", + }, + } + f := c.ToFrontend() + + // FinishedAt arm. + if f.FinishedAt == nil || *f.FinishedAt != finished.Unix()*1000 { + t.Fatalf("FinishedAt = %#v, want %d", f.FinishedAt, finished.Unix()*1000) + } + + // Labels copy arm + independence. + if len(f.Labels) != 2 || f.Labels["app"] != "shop" { + t.Fatalf("Labels not copied: %#v", f.Labels) + } + f.Labels["app"] = "MUTATED" + if c.Labels["app"] != "shop" { + t.Fatal("frontend Labels must not alias the source map") + } + + // UpdateStatus arm. + if f.UpdateStatus == nil || !f.UpdateStatus.UpdateAvailable || f.UpdateStatus.Error != "rate limited" { + t.Fatalf("UpdateStatus not mapped: %#v", f.UpdateStatus) + } + if f.UpdateStatus.CurrentDigest != "sha256:aaa" || f.UpdateStatus.LatestDigest != "sha256:bbb" { + t.Fatalf("UpdateStatus digests not mapped: %#v", f.UpdateStatus) + } + if f.UpdateStatus.LastChecked != lastChecked.Unix()*1000 { + t.Fatalf("UpdateStatus.LastChecked = %d, want %d", f.UpdateStatus.LastChecked, lastChecked.Unix()*1000) + } +} + +func TestDockerServiceToFrontend_UpdateStatusBranchCov0718(t *testing.T) { + completed := time.Now().Add(-time.Hour) + svc := DockerService{ + ID: "svc-upd", + Name: "api", + UpdateStatus: &DockerServiceUpdate{ + State: "completed", + Message: "done", + CompletedAt: &completed, + }, + } + f := svc.ToFrontend() + if f.UpdateStatus == nil { + t.Fatal("UpdateStatus should be mapped when source pointer is non-nil") + } + if f.UpdateStatus.State != "completed" || f.UpdateStatus.Message != "done" { + t.Fatalf("UpdateStatus fields not mapped: %#v", f.UpdateStatus) + } + if f.UpdateStatus.CompletedAt == nil || *f.UpdateStatus.CompletedAt != completed.Unix()*1000 { + t.Fatalf("UpdateStatus.CompletedAt = %#v, want %d", f.UpdateStatus.CompletedAt, completed.Unix()*1000) + } +} + +func TestDockerTaskToFrontend_CompletedAtBranchCov0718(t *testing.T) { + now := time.Now() + completed := now.Add(-2 * time.Minute) + task := DockerTask{ + ID: "task-done", + ServiceName: "web", + CreatedAt: now, + CompletedAt: &completed, + } + f := task.ToFrontend() + if f.CompletedAt == nil || *f.CompletedAt != completed.Unix()*1000 { + t.Fatalf("CompletedAt = %#v, want %d", f.CompletedAt, completed.Unix()*1000) + } +} + +func TestHostSensorSummaryToFrontend_GPUSmartBranchCov0718(t *testing.T) { + gpuTemp := 75.0 + gpuUtil := 50.0 + src := HostSensorSummary{ + GPU: []HostGPUSensor{ + { + ID: "gpu0", + Name: "nvidia-3080", + TemperatureCelsius: &gpuTemp, + UtilizationPercent: &gpuUtil, + }, + }, + SMART: []HostDiskSMART{ + {Device: "sda", Model: "Samsung SSD", Temperature: 42, Health: "PASSED"}, + }, + } + dest := hostSensorSummaryToFrontend(src) + if dest == nil { + t.Fatal("expected non-nil frontend for populated GPU+SMART sensors") + } + + // GPU mapping arm. + if len(dest.GPU) != 1 || dest.GPU[0].ID != "gpu0" || dest.GPU[0].Name != "nvidia-3080" { + t.Fatalf("GPU not mapped: %#v", dest.GPU) + } + if dest.GPU[0].TemperatureCelsius == nil || *dest.GPU[0].TemperatureCelsius != 75.0 { + t.Fatalf("GPU.TemperatureCelsius not cloned: %#v", dest.GPU[0].TemperatureCelsius) + } + if dest.GPU[0].UtilizationPercent == nil || *dest.GPU[0].UtilizationPercent != 50.0 { + t.Fatalf("GPU.UtilizationPercent not cloned: %#v", dest.GPU[0].UtilizationPercent) + } + // Cloned metric pointer must not alias the source pointer. + *dest.GPU[0].TemperatureCelsius = 999 + if gpuTemp != 75.0 { + t.Fatal("frontend GPU TemperatureCelsius pointer must not alias the source") + } + + // SMART mapping arm. + if len(dest.SMART) != 1 || dest.SMART[0].Device != "sda" || dest.SMART[0].Health != "PASSED" { + t.Fatalf("SMART not mapped: %#v", dest.SMART) + } + if dest.SMART[0].Temperature != 42 || dest.SMART[0].Model != "Samsung SSD" { + t.Fatalf("SMART fields not mapped: %#v", dest.SMART[0]) + } +} diff --git a/internal/models/models_frontend_branchcov0718_test.go b/internal/models/models_frontend_branchcov0718_test.go new file mode 100644 index 000000000..9a6e6a7b6 --- /dev/null +++ b/internal/models/models_frontend_branchcov0718_test.go @@ -0,0 +1,690 @@ +package models + +import ( + "testing" +) + +// This file raises BRANCH coverage for the NormalizeCollections methods on +// the Frontend types defined in models_frontend.go. +// +// For every target type we exercise BOTH arms of each nil-collection +// conditional: +// +// - nil arm: the collection field is left nil; after NormalizeCollections +// it MUST be a non-nil empty slice/map of the right type. +// - populated: the collection field is pre-populated; NormalizeCollections +// MUST preserve it (these methods do NOT sort or deduplicate). +// Where the method recurses into nested elements (DockerHost, +// KubernetesCluster, State, Storage.ZFSPool, Resource.Identity, +// Host.Sensors), the populated arm supplies a nested element +// with a nil sub-collection so the observable side-effect of +// the recursion (the sub-collection becoming non-nil) can be +// asserted, exercising the for-loop bodies and the `!= nil` +// guard branches. +// +// No source file or sibling test was modified. + +func TestFrontendNormalizeCollections_BranchCov0718(t *testing.T) { + // ---------------- NodeFrontend ---------------- + t.Run("NodeFrontend_nil_becomes_empty", func(t *testing.T) { + n := NodeFrontend{ID: "n-1"} // LoadAverage is nil + out := n.NormalizeCollections() + if out.LoadAverage == nil { + t.Fatalf("LoadAverage should be non-nil after normalize, got nil") + } + if len(out.LoadAverage) != 0 { + t.Fatalf("LoadAverage should be empty, got len=%d", len(out.LoadAverage)) + } + }) + t.Run("NodeFrontend_populated_preserved_no_dedup", func(t *testing.T) { + // Duplicates included on purpose: the method does NOT dedup/sort. + orig := []float64{3.0, 1.0, 3.0, 2.0} + n := NodeFrontend{ID: "n-1", LoadAverage: orig} + out := n.NormalizeCollections() + if len(out.LoadAverage) != len(orig) { + t.Fatalf("LoadAverage length changed: got %d want %d", len(out.LoadAverage), len(orig)) + } + for i := range orig { + if out.LoadAverage[i] != orig[i] { + t.Fatalf("LoadAverage[%d] = %v, want %v (no sort/dedup expected)", i, out.LoadAverage[i], orig[i]) + } + } + }) + + // ---------------- VMFrontend ---------------- + t.Run("VMFrontend_nil_becomes_empty", func(t *testing.T) { + v := VMFrontend{ID: "v-1"} + out := v.NormalizeCollections() + if v.Disks != nil || v.NetworkInterfaces != nil || v.IPAddresses != nil { + t.Fatalf("precondition: input collections must be nil") + } + if out.Disks == nil || out.NetworkInterfaces == nil || out.IPAddresses == nil { + t.Fatalf("Disks/NetworkInterfaces/IPAddresses must all be non-nil after normalize") + } + if len(out.Disks) != 0 || len(out.NetworkInterfaces) != 0 || len(out.IPAddresses) != 0 { + t.Fatalf("normalized empty collections must have len 0") + } + }) + t.Run("VMFrontend_populated_preserved", func(t *testing.T) { + v := VMFrontend{ + ID: "v-1", + Disks: []Disk{{Device: "/sda"}}, + NetworkInterfaces: []GuestNetworkInterface{{Name: "eth0"}}, + IPAddresses: []string{"10.0.0.1", "10.0.0.1"}, // duplicate on purpose + } + out := v.NormalizeCollections() + if len(out.Disks) != 1 || out.Disks[0].Device != "/sda" { + t.Fatalf("Disks not preserved: %+v", out.Disks) + } + if len(out.NetworkInterfaces) != 1 || out.NetworkInterfaces[0].Name != "eth0" { + t.Fatalf("NetworkInterfaces not preserved: %+v", out.NetworkInterfaces) + } + if len(out.IPAddresses) != 2 || out.IPAddresses[0] != "10.0.0.1" || out.IPAddresses[1] != "10.0.0.1" { + t.Fatalf("IPAddresses not preserved (no dedup expected): %+v", out.IPAddresses) + } + }) + + // ---------------- ContainerFrontend ---------------- + t.Run("ContainerFrontend_nil_becomes_empty", func(t *testing.T) { + c := ContainerFrontend{ID: "c-1"} + out := c.NormalizeCollections() + if out.Disks == nil || out.NetworkInterfaces == nil || out.IPAddresses == nil { + t.Fatalf("Disks/NetworkInterfaces/IPAddresses must all be non-nil after normalize") + } + }) + t.Run("ContainerFrontend_populated_preserved", func(t *testing.T) { + c := ContainerFrontend{ + ID: "c-1", + Disks: []Disk{{Device: "/data"}}, + NetworkInterfaces: []GuestNetworkInterface{{Name: "eth0"}}, + IPAddresses: []string{"10.0.0.2"}, + } + out := c.NormalizeCollections() + if len(out.Disks) != 1 || out.Disks[0].Device != "/data" { + t.Fatalf("Disks not preserved: %+v", out.Disks) + } + if len(out.NetworkInterfaces) != 1 { + t.Fatalf("NetworkInterfaces not preserved") + } + if len(out.IPAddresses) != 1 || out.IPAddresses[0] != "10.0.0.2" { + t.Fatalf("IPAddresses not preserved: %+v", out.IPAddresses) + } + }) + + // ---------------- DockerHostFrontend ---------------- + t.Run("DockerHostFrontend_nil_all_collections", func(t *testing.T) { + h := DockerHostFrontend{ID: "dh-1"} + out := h.NormalizeCollections() + if out.LoadAverage == nil || out.Disks == nil || out.NetworkInterfaces == nil || + out.Containers == nil || out.Services == nil || out.Tasks == nil || + out.Nodes == nil || out.Secrets == nil || out.Configs == nil { + t.Fatalf("all nine collection fields must be non-nil after normalize") + } + // Security is nil on input → must remain nil (no fabrication). + if out.Security != nil { + t.Fatalf("Security must remain nil when nil on input") + } + }) + t.Run("DockerHostFrontend_populated_recurses_into_nested", func(t *testing.T) { + // Every nested element starts with nil sub-collections so we can + // observe the parent's for-loop recursion turning them non-nil. + h := DockerHostFrontend{ + ID: "dh-1", + LoadAverage: []float64{1.5}, + Disks: []Disk{{Device: "/dev/sda"}}, + NetworkInterfaces: []HostNetworkInterface{{Name: "eno1"}}, + Containers: []DockerContainerFrontend{{ID: "ctr-1"}}, // Ports/Labels/Networks/Mounts nil + Services: []DockerServiceFrontend{{ID: "svc-1"}}, // Labels/EndpointPorts nil + Tasks: []DockerTaskFrontend{{ID: "task-1"}}, + Nodes: []DockerNodeFrontend{{ID: "node-1"}}, + Secrets: []DockerSecretFrontend{{ID: "sec-1"}}, // Labels nil + Configs: []DockerConfigFrontend{{ID: "cfg-1"}}, // Labels nil + Security: &DockerHostSecurityFrontend{}, // AuthorizationPlugins nil + } + out := h.NormalizeCollections() + + // Top-level populated collections preserved. + if len(out.LoadAverage) != 1 || out.LoadAverage[0] != 1.5 { + t.Fatalf("LoadAverage not preserved: %+v", out.LoadAverage) + } + if len(out.Disks) != 1 || out.Disks[0].Device != "/dev/sda" { + t.Fatalf("Disks not preserved: %+v", out.Disks) + } + if len(out.NetworkInterfaces) != 1 || out.NetworkInterfaces[0].Name != "eno1" { + t.Fatalf("NetworkInterfaces not preserved") + } + if len(out.Tasks) != 1 || out.Tasks[0].ID != "task-1" { + t.Fatalf("Tasks not preserved") + } + if len(out.Nodes) != 1 || out.Nodes[0].ID != "node-1" { + t.Fatalf("Nodes not preserved") + } + + // Recursion into containers/services/secrets/configs observable. + if len(out.Containers) != 1 { + t.Fatalf("Containers length changed") + } + c := out.Containers[0] + if c.Ports == nil || c.Labels == nil || c.Networks == nil || c.Mounts == nil { + t.Fatalf("nested DockerContainerFrontend collections not normalized: %+v", c) + } + if len(out.Services) != 1 || out.Services[0].Labels == nil || out.Services[0].EndpointPorts == nil { + t.Fatalf("nested DockerServiceFrontend collections not normalized") + } + if len(out.Secrets) != 1 || out.Secrets[0].Labels == nil { + t.Fatalf("nested DockerSecretFrontend.Labels not normalized") + } + if len(out.Configs) != 1 || out.Configs[0].Labels == nil { + t.Fatalf("nested DockerConfigFrontend.Labels not normalized") + } + + // The `if h.Security != nil` branch must have run and recursed. + if out.Security == nil { + t.Fatalf("Security must be preserved when non-nil on input") + } + if out.Security.AuthorizationPlugins == nil { + t.Fatalf("Security.AuthorizationPlugins should be normalized to non-nil") + } + }) + + // ---------------- DockerHostSecurityFrontend (was 0%) ---------------- + t.Run("DockerHostSecurityFrontend_nil", func(t *testing.T) { + s := DockerHostSecurityFrontend{MutatingCommandsBlocked: true} + out := s.NormalizeCollections() + if out.AuthorizationPlugins == nil { + t.Fatalf("AuthorizationPlugins should be non-nil after normalize") + } + if len(out.AuthorizationPlugins) != 0 { + t.Fatalf("AuthorizationPlugins should be empty, got len=%d", len(out.AuthorizationPlugins)) + } + if !out.MutatingCommandsBlocked { + t.Fatalf("scalar MutatingCommandsBlocked must be preserved") + } + }) + t.Run("DockerHostSecurityFrontend_populated_preserved", func(t *testing.T) { + s := DockerHostSecurityFrontend{ + AuthorizationPlugins: []string{"opa", "opa"}, // duplicate on purpose + } + out := s.NormalizeCollections() + if len(out.AuthorizationPlugins) != 2 || out.AuthorizationPlugins[0] != "opa" || out.AuthorizationPlugins[1] != "opa" { + t.Fatalf("AuthorizationPlugins not preserved (no dedup expected): %+v", out.AuthorizationPlugins) + } + }) + + // ---------------- ConnectedInfrastructureItemFrontend ---------------- + t.Run("ConnectedInfrastructureItemFrontend_nil", func(t *testing.T) { + i := ConnectedInfrastructureItemFrontend{ID: "ci-1"} + out := i.NormalizeCollections() + if out.Surfaces == nil { + t.Fatalf("Surfaces should be non-nil after normalize") + } + }) + t.Run("ConnectedInfrastructureItemFrontend_populated_preserved", func(t *testing.T) { + i := ConnectedInfrastructureItemFrontend{ + ID: "ci-1", + Surfaces: []ConnectedInfrastructureSurfaceFrontend{{ID: "sf-1", Kind: "agent"}}, + } + out := i.NormalizeCollections() + if len(out.Surfaces) != 1 || out.Surfaces[0].ID != "sf-1" || out.Surfaces[0].Kind != "agent" { + t.Fatalf("Surfaces not preserved: %+v", out.Surfaces) + } + }) + + // ---------------- KubernetesClusterFrontend ---------------- + t.Run("KubernetesClusterFrontend_nil", func(t *testing.T) { + c := KubernetesClusterFrontend{ID: "k-1"} + out := c.NormalizeCollections() + if out.Nodes == nil || out.Pods == nil || out.Deployments == nil { + t.Fatalf("Nodes/Pods/Deployments must all be non-nil after normalize") + } + }) + t.Run("KubernetesClusterFrontend_populated_recurses_into_nested", func(t *testing.T) { + c := KubernetesClusterFrontend{ + ID: "k-1", + Nodes: []KubernetesNodeFrontend{{UID: "n-1"}}, // Roles nil + Pods: []KubernetesPodFrontend{{UID: "p-1"}}, // Labels/Containers nil + Deployments: []KubernetesDeploymentFrontend{{UID: "d-1"}}, // Labels nil + } + out := c.NormalizeCollections() + + if len(out.Nodes) != 1 || out.Nodes[0].UID != "n-1" { + t.Fatalf("Nodes not preserved") + } + if out.Nodes[0].Roles == nil { + t.Fatalf("nested KubernetesNodeFrontend.Roles not normalized") + } + if len(out.Pods) != 1 || out.Pods[0].UID != "p-1" { + t.Fatalf("Pods not preserved") + } + if out.Pods[0].Labels == nil || out.Pods[0].Containers == nil { + t.Fatalf("nested KubernetesPodFrontend collections not normalized") + } + if len(out.Deployments) != 1 || out.Deployments[0].UID != "d-1" { + t.Fatalf("Deployments not preserved") + } + if out.Deployments[0].Labels == nil { + t.Fatalf("nested KubernetesDeploymentFrontend.Labels not normalized") + } + }) + + // ---------------- KubernetesNodeFrontend ---------------- + t.Run("KubernetesNodeFrontend_nil", func(t *testing.T) { + n := KubernetesNodeFrontend{UID: "n-1"} + out := n.NormalizeCollections() + if out.Roles == nil { + t.Fatalf("Roles should be non-nil after normalize") + } + }) + t.Run("KubernetesNodeFrontend_populated_preserved", func(t *testing.T) { + n := KubernetesNodeFrontend{UID: "n-1", Roles: []string{"control-plane", "worker", "control-plane"}} + out := n.NormalizeCollections() + if len(out.Roles) != 3 || out.Roles[0] != "control-plane" || out.Roles[2] != "control-plane" { + t.Fatalf("Roles not preserved (no dedup expected): %+v", out.Roles) + } + }) + + // ---------------- KubernetesPodFrontend ---------------- + t.Run("KubernetesPodFrontend_nil", func(t *testing.T) { + p := KubernetesPodFrontend{UID: "p-1"} + out := p.NormalizeCollections() + if out.Labels == nil || out.Containers == nil { + t.Fatalf("Labels/Containers should be non-nil after normalize") + } + }) + t.Run("KubernetesPodFrontend_populated_preserved", func(t *testing.T) { + p := KubernetesPodFrontend{ + UID: "p-1", + Labels: map[string]string{"app": "web"}, + Containers: []KubernetesPodContainerFrontend{{Name: "c-1"}}, + } + out := p.NormalizeCollections() + if len(out.Labels) != 1 || out.Labels["app"] != "web" { + t.Fatalf("Labels not preserved: %+v", out.Labels) + } + if len(out.Containers) != 1 || out.Containers[0].Name != "c-1" { + t.Fatalf("Containers not preserved: %+v", out.Containers) + } + }) + + // ---------------- KubernetesDeploymentFrontend ---------------- + t.Run("KubernetesDeploymentFrontend_nil", func(t *testing.T) { + d := KubernetesDeploymentFrontend{UID: "d-1"} + out := d.NormalizeCollections() + if out.Labels == nil { + t.Fatalf("Labels should be non-nil after normalize") + } + }) + t.Run("KubernetesDeploymentFrontend_populated_preserved", func(t *testing.T) { + d := KubernetesDeploymentFrontend{UID: "d-1", Labels: map[string]string{"app": "api"}} + out := d.NormalizeCollections() + if len(out.Labels) != 1 || out.Labels["app"] != "api" { + t.Fatalf("Labels not preserved: %+v", out.Labels) + } + }) + + // ---------------- DockerContainerFrontend ---------------- + t.Run("DockerContainerFrontend_nil", func(t *testing.T) { + c := DockerContainerFrontend{ID: "ctr-1"} + out := c.NormalizeCollections() + if out.Ports == nil || out.Labels == nil || out.Networks == nil || out.Mounts == nil { + t.Fatalf("Ports/Labels/Networks/Mounts must all be non-nil after normalize") + } + }) + t.Run("DockerContainerFrontend_populated_preserved", func(t *testing.T) { + c := DockerContainerFrontend{ + ID: "ctr-1", + Ports: []DockerContainerPortFrontend{{PrivatePort: 80, Protocol: "tcp"}}, + Labels: map[string]string{"io.docker.compose.service": "web"}, + Networks: []DockerContainerNetworkFrontend{{Name: "bridge", IPv4: "172.17.0.2"}}, + Mounts: []DockerContainerMountFrontend{{Type: "bind", Source: "/host"}}, + } + out := c.NormalizeCollections() + if len(out.Ports) != 1 || out.Ports[0].PrivatePort != 80 || out.Ports[0].Protocol != "tcp" { + t.Fatalf("Ports not preserved: %+v", out.Ports) + } + if len(out.Labels) != 1 || out.Labels["io.docker.compose.service"] != "web" { + t.Fatalf("Labels not preserved: %+v", out.Labels) + } + if len(out.Networks) != 1 || out.Networks[0].IPv4 != "172.17.0.2" { + t.Fatalf("Networks not preserved: %+v", out.Networks) + } + if len(out.Mounts) != 1 || out.Mounts[0].Source != "/host" { + t.Fatalf("Mounts not preserved: %+v", out.Mounts) + } + }) + + // ---------------- DockerServiceFrontend ---------------- + t.Run("DockerServiceFrontend_nil", func(t *testing.T) { + s := DockerServiceFrontend{ID: "svc-1"} + out := s.NormalizeCollections() + if out.Labels == nil || out.EndpointPorts == nil { + t.Fatalf("Labels/EndpointPorts should be non-nil after normalize") + } + }) + t.Run("DockerServiceFrontend_populated_preserved", func(t *testing.T) { + s := DockerServiceFrontend{ + ID: "svc-1", + Labels: map[string]string{"swarm": "true"}, + EndpointPorts: []DockerServicePortFrontend{{PublishedPort: 8080, TargetPort: 80}}, + } + out := s.NormalizeCollections() + if len(out.Labels) != 1 || out.Labels["swarm"] != "true" { + t.Fatalf("Labels not preserved: %+v", out.Labels) + } + if len(out.EndpointPorts) != 1 || out.EndpointPorts[0].TargetPort != 80 || out.EndpointPorts[0].PublishedPort != 8080 { + t.Fatalf("EndpointPorts not preserved: %+v", out.EndpointPorts) + } + }) + + // ---------------- DockerSecretFrontend (was 0%) ---------------- + t.Run("DockerSecretFrontend_nil", func(t *testing.T) { + s := DockerSecretFrontend{ID: "sec-1", Name: "tls"} + out := s.NormalizeCollections() + if out.Labels == nil { + t.Fatalf("Labels should be non-nil after normalize") + } + if len(out.Labels) != 0 { + t.Fatalf("Labels should be empty, got len=%d", len(out.Labels)) + } + if out.Name != "tls" { + t.Fatalf("Name scalar must be preserved") + } + }) + t.Run("DockerSecretFrontend_populated_preserved", func(t *testing.T) { + s := DockerSecretFrontend{ + ID: "sec-1", + Name: "tls", + Labels: map[string]string{"managed": "true"}, + } + out := s.NormalizeCollections() + if len(out.Labels) != 1 || out.Labels["managed"] != "true" { + t.Fatalf("Labels not preserved: %+v", out.Labels) + } + }) + + // ---------------- DockerConfigFrontend (was 0%) ---------------- + t.Run("DockerConfigFrontend_nil", func(t *testing.T) { + c := DockerConfigFrontend{ID: "cfg-1", Name: "conf"} + out := c.NormalizeCollections() + if out.Labels == nil { + t.Fatalf("Labels should be non-nil after normalize") + } + if len(out.Labels) != 0 { + t.Fatalf("Labels should be empty, got len=%d", len(out.Labels)) + } + if out.Name != "conf" { + t.Fatalf("Name scalar must be preserved") + } + }) + t.Run("DockerConfigFrontend_populated_preserved", func(t *testing.T) { + c := DockerConfigFrontend{ + ID: "cfg-1", + Name: "conf", + Labels: map[string]string{"env": "prod"}, + } + out := c.NormalizeCollections() + if len(out.Labels) != 1 || out.Labels["env"] != "prod" { + t.Fatalf("Labels not preserved: %+v", out.Labels) + } + }) + + // ---------------- HostFrontend ---------------- + t.Run("HostFrontend_nil_all_collections_and_nil_sensors", func(t *testing.T) { + h := HostFrontend{ID: "h-1"} + out := h.NormalizeCollections() + if out.LoadAverage == nil || out.Disks == nil || out.DiskIO == nil || + out.NetworkInterfaces == nil || out.Tags == nil { + t.Fatalf("LoadAverage/Disks/DiskIO/NetworkInterfaces/Tags must all be non-nil after normalize") + } + if out.Sensors != nil { + t.Fatalf("Sensors must remain nil when nil on input") + } + }) + t.Run("HostFrontend_populated_with_sensors_recurses", func(t *testing.T) { + h := HostFrontend{ + ID: "h-1", + LoadAverage: []float64{0.5, 0.4, 0.3}, + Disks: []Disk{{Device: "/"}}, + DiskIO: []DiskIO{{Device: "sda"}}, + NetworkInterfaces: []HostNetworkInterface{{Name: "eth0"}}, + Tags: []string{"prod", "prod"}, // duplicate on purpose + Sensors: &HostSensorSummaryFrontend{}, // all sub-collections nil + } + out := h.NormalizeCollections() + + if len(out.LoadAverage) != 3 || out.LoadAverage[2] != 0.3 { + t.Fatalf("LoadAverage not preserved: %+v", out.LoadAverage) + } + if len(out.Disks) != 1 || out.Disks[0].Device != "/" { + t.Fatalf("Disks not preserved") + } + if len(out.DiskIO) != 1 || out.DiskIO[0].Device != "sda" { + t.Fatalf("DiskIO not preserved") + } + if len(out.NetworkInterfaces) != 1 || out.NetworkInterfaces[0].Name != "eth0" { + t.Fatalf("NetworkInterfaces not preserved") + } + if len(out.Tags) != 2 || out.Tags[0] != "prod" || out.Tags[1] != "prod" { + t.Fatalf("Tags not preserved (no dedup expected): %+v", out.Tags) + } + + // The `if h.Sensors != nil` branch must have run and recursed. + if out.Sensors == nil { + t.Fatalf("Sensors must be preserved when non-nil on input") + } + s := out.Sensors + if s.TemperatureCelsius == nil || s.FanRPM == nil || s.PowerWatts == nil || + s.Additional == nil || s.GPU == nil || s.SMART == nil { + t.Fatalf("nested HostSensorSummaryFrontend collections not normalized: %+v", s) + } + }) + + // ---------------- HostSensorSummaryFrontend ---------------- + t.Run("HostSensorSummaryFrontend_nil_all_six", func(t *testing.T) { + s := HostSensorSummaryFrontend{} + out := s.NormalizeCollections() + if out.TemperatureCelsius == nil || out.FanRPM == nil || out.PowerWatts == nil || + out.Additional == nil || out.GPU == nil || out.SMART == nil { + t.Fatalf("all six collection fields must be non-nil after normalize") + } + }) + t.Run("HostSensorSummaryFrontend_populated_preserved", func(t *testing.T) { + temp := 65.0 + s := HostSensorSummaryFrontend{ + TemperatureCelsius: map[string]float64{"cpu": temp}, + FanRPM: map[string]float64{"fan1": 1500}, + PowerWatts: map[string]float64{"psu1": 220.5}, + Additional: map[string]float64{"voltage": 12.0}, + GPU: []HostGPUSensorFrontend{{ID: "gpu0", Name: "nvidia"}}, + SMART: []HostDiskSMARTFrontend{{Device: "sda", Temperature: 40}}, + } + out := s.NormalizeCollections() + if len(out.TemperatureCelsius) != 1 || out.TemperatureCelsius["cpu"] != temp { + t.Fatalf("TemperatureCelsius not preserved: %+v", out.TemperatureCelsius) + } + if len(out.FanRPM) != 1 || out.FanRPM["fan1"] != 1500 { + t.Fatalf("FanRPM not preserved: %+v", out.FanRPM) + } + if len(out.PowerWatts) != 1 || out.PowerWatts["psu1"] != 220.5 { + t.Fatalf("PowerWatts not preserved: %+v", out.PowerWatts) + } + if len(out.Additional) != 1 || out.Additional["voltage"] != 12.0 { + t.Fatalf("Additional not preserved: %+v", out.Additional) + } + if len(out.GPU) != 1 || out.GPU[0].Name != "nvidia" { + t.Fatalf("GPU not preserved: %+v", out.GPU) + } + if len(out.SMART) != 1 || out.SMART[0].Device != "sda" || out.SMART[0].Temperature != 40 { + t.Fatalf("SMART not preserved: %+v", out.SMART) + } + }) + + // ---------------- StorageFrontend ---------------- + t.Run("StorageFrontend_nil_and_nil_zfs", func(t *testing.T) { + s := StorageFrontend{ID: "s-1"} + out := s.NormalizeCollections() + if out.Nodes == nil || out.NodeIDs == nil { + t.Fatalf("Nodes/NodeIDs should be non-nil after normalize") + } + if out.ZFSPool != nil { + t.Fatalf("ZFSPool must remain nil when nil on input") + } + }) + t.Run("StorageFrontend_populated_with_zfs_recurses", func(t *testing.T) { + s := StorageFrontend{ + ID: "s-1", + Nodes: []string{"node-a", "node-a"}, // duplicate on purpose + NodeIDs: []string{"nid-1"}, + ZFSPool: &ZFSPool{Name: "tank"}, // Devices nil → recursion observable + } + out := s.NormalizeCollections() + if len(out.Nodes) != 2 || out.Nodes[0] != "node-a" || out.Nodes[1] != "node-a" { + t.Fatalf("Nodes not preserved (no dedup expected): %+v", out.Nodes) + } + if len(out.NodeIDs) != 1 || out.NodeIDs[0] != "nid-1" { + t.Fatalf("NodeIDs not preserved: %+v", out.NodeIDs) + } + // The `if s.ZFSPool != nil` branch must have run and recursed. + if out.ZFSPool == nil { + t.Fatalf("ZFSPool must be preserved when non-nil on input") + } + if out.ZFSPool.Name != "tank" { + t.Fatalf("ZFSPool.Name not preserved: %q", out.ZFSPool.Name) + } + if out.ZFSPool.Devices == nil { + t.Fatalf("nested ZFSPool.Devices not normalized (recursion did not run)") + } + }) + + // ---------------- CephClusterFrontend ---------------- + t.Run("CephClusterFrontend_nil", func(t *testing.T) { + c := CephClusterFrontend{ID: "ceph-1"} + out := c.NormalizeCollections() + if out.Pools == nil || out.Services == nil { + t.Fatalf("Pools/Services should be non-nil after normalize") + } + }) + t.Run("CephClusterFrontend_populated_preserved", func(t *testing.T) { + c := CephClusterFrontend{ + ID: "ceph-1", + Pools: []CephPool{{Name: "replicapool"}}, + Services: []CephServiceStatus{{Type: "mon", Running: 1}}, + } + out := c.NormalizeCollections() + if len(out.Pools) != 1 || out.Pools[0].Name != "replicapool" { + t.Fatalf("Pools not preserved: %+v", out.Pools) + } + if len(out.Services) != 1 || out.Services[0].Type != "mon" || out.Services[0].Running != 1 { + t.Fatalf("Services not preserved: %+v", out.Services) + } + }) + + // ---------------- StateFrontend ---------------- + t.Run("StateFrontend_nil_all_collections", func(t *testing.T) { + s := StateFrontend{} + out := s.NormalizeCollections() + if out.ActiveAlerts == nil || out.RecentlyResolved == nil || out.Metrics == nil || + out.ConnectionHealth == nil || out.PVETagColors == nil || out.PVETagStyles == nil || + out.Resources == nil || out.ConnectedInfrastructure == nil { + t.Fatalf("all top-level collection fields must be non-nil after normalize") + } + // Performance.APICallDuration is a nested nil-map branch. + if out.Performance.APICallDuration == nil { + t.Fatalf("Performance.APICallDuration should be non-nil after normalize") + } + }) + t.Run("StateFrontend_populated_recurses_into_nested", func(t *testing.T) { + // Provide a non-nil Performance.APICallDuration to exercise the + // "already populated" arm of that specific branch. + s := StateFrontend{ + ActiveAlerts: []Alert{{ID: "a-1"}}, + RecentlyResolved: []ResolvedAlert{{Alert: Alert{ID: "r-1"}}}, + Metrics: []Metric{{Type: "cpu"}}, + ConnectionHealth: map[string]bool{"api": true}, + PVETagColors: map[string]string{"prod": "#ff0000"}, + PVETagStyles: map[string]PVETagStyle{"pve1": {}}, + Performance: Performance{APICallDuration: map[string]float64{"nodes": 12.3}}, + // Each nested element has nil sub-collections so recursion is observable. + ConnectedInfrastructure: []ConnectedInfrastructureItemFrontend{{ID: "ci-1"}}, // Surfaces nil + Resources: []ResourceFrontend{{ID: "res-1"}}, // Tags/Labels/Alerts nil + } + out := s.NormalizeCollections() + + if len(out.ActiveAlerts) != 1 || out.ActiveAlerts[0].ID != "a-1" { + t.Fatalf("ActiveAlerts not preserved") + } + if len(out.RecentlyResolved) != 1 || out.RecentlyResolved[0].ID != "r-1" { + t.Fatalf("RecentlyResolved not preserved") + } + if len(out.Metrics) != 1 || out.Metrics[0].Type != "cpu" { + t.Fatalf("Metrics not preserved") + } + if !out.ConnectionHealth["api"] { + t.Fatalf("ConnectionHealth not preserved: %+v", out.ConnectionHealth) + } + if out.PVETagColors["prod"] != "#ff0000" { + t.Fatalf("PVETagColors not preserved: %+v", out.PVETagColors) + } + // Performance.APICallDuration must be preserved as-is (non-nil arm). + if got := out.Performance.APICallDuration["nodes"]; got != 12.3 { + t.Fatalf("Performance.APICallDuration[\"nodes\"] = %v, want 12.3", got) + } + + // Recursion over ConnectedInfrastructure. + if len(out.ConnectedInfrastructure) != 1 || out.ConnectedInfrastructure[0].ID != "ci-1" { + t.Fatalf("ConnectedInfrastructure not preserved") + } + if out.ConnectedInfrastructure[0].Surfaces == nil { + t.Fatalf("nested ConnectedInfrastructureItemFrontend.Surfaces not normalized") + } + // Recursion over Resources. + if len(out.Resources) != 1 || out.Resources[0].ID != "res-1" { + t.Fatalf("Resources not preserved") + } + r := out.Resources[0] + if r.Tags == nil || r.Labels == nil || r.Alerts == nil { + t.Fatalf("nested ResourceFrontend collections not normalized: %+v", r) + } + }) + + // ---------------- ResourceFrontend ---------------- + t.Run("ResourceFrontend_nil_and_nil_identity", func(t *testing.T) { + r := ResourceFrontend{ID: "res-1"} + out := r.NormalizeCollections() + if out.Tags == nil || out.Labels == nil || out.Alerts == nil { + t.Fatalf("Tags/Labels/Alerts should be non-nil after normalize") + } + if out.Identity != nil { + t.Fatalf("Identity must remain nil when nil on input") + } + }) + t.Run("ResourceFrontend_populated_with_identity_recurses", func(t *testing.T) { + r := ResourceFrontend{ + ID: "res-1", + Tags: []string{"env:prod", "env:prod"}, // duplicate on purpose + Labels: map[string]string{"team": "infra"}, + Alerts: []ResourceAlertFrontend{{ID: "al-1", Level: "warn"}}, + Identity: &ResourceIdentityFrontend{Hostname: "host-1"}, // IPs nil → recursion observable + } + out := r.NormalizeCollections() + if len(out.Tags) != 2 || out.Tags[0] != "env:prod" || out.Tags[1] != "env:prod" { + t.Fatalf("Tags not preserved (no dedup expected): %+v", out.Tags) + } + if len(out.Labels) != 1 || out.Labels["team"] != "infra" { + t.Fatalf("Labels not preserved: %+v", out.Labels) + } + if len(out.Alerts) != 1 || out.Alerts[0].ID != "al-1" || out.Alerts[0].Level != "warn" { + t.Fatalf("Alerts not preserved: %+v", out.Alerts) + } + // The `if r.Identity != nil` branch must have run and recursed. + if out.Identity == nil { + t.Fatalf("Identity must be preserved when non-nil on input") + } + if out.Identity.Hostname != "host-1" { + t.Fatalf("Identity.Hostname not preserved: %q", out.Identity.Hostname) + } + if out.Identity.IPs == nil { + t.Fatalf("nested Identity.IPs not normalized (recursion did not run)") + } + }) +} diff --git a/internal/servicediscovery/formatters_branchcov0718_test.go b/internal/servicediscovery/formatters_branchcov0718_test.go new file mode 100644 index 000000000..1b5bee47a --- /dev/null +++ b/internal/servicediscovery/formatters_branchcov0718_test.go @@ -0,0 +1,465 @@ +package servicediscovery + +import ( + "sort" + "strings" + "testing" +) + +// tokenSetFrom runs addResourceIDTokens against a single resource ID and +// returns the resulting set as a sorted slice, so table cases can assert the +// exact token population produced by each branch. +func tokenSetFrom(resourceID string) []string { + tokens := make(map[string]struct{}) + addResourceIDTokens(tokens, resourceID) + out := make([]string, 0, len(tokens)) + for k := range tokens { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func TestAddResourceIDTokens(t *testing.T) { + tests := []struct { + name string + resourceID string + expected []string + }{ + { + name: "empty-input-skipped", + resourceID: "", + expected: []string{}, + }, + { + name: "whitespace-only-skipped", + resourceID: " ", + expected: []string{}, + }, + { + name: "plain-id", + resourceID: "abc", + expected: []string{"abc"}, + }, + { + name: "slash-last-segment", + resourceID: "abc/def", + expected: []string{"abc/def", "def"}, + }, + { + name: "colon-last-segment", + resourceID: "abc:def", + expected: []string{"abc:def", "def"}, + }, + { + name: "vm-prefix-and-trailing-digits", + resourceID: "VM-101", + expected: []string{"101", "vm-101"}, + }, + { + name: "ct-prefix-and-trailing-digits", + resourceID: "ct-202", + expected: []string{"202", "ct-202"}, + }, + { + name: "lxc-prefix-no-trailing-digits-branch", + resourceID: "LXC-303", + expected: []string{"303", "lxc-303"}, + }, + { + name: "qemu-slash-with-trailing-digits", + resourceID: "qemu/404", + expected: []string{"404", "qemu/404"}, + }, + { + name: "lxc-slash-with-trailing-digits", + resourceID: "lxc/505", + expected: []string{"505", "lxc/505"}, + }, + { + name: "docker-host-container-split", + resourceID: "docker:host1/container1", + expected: []string{"container1", "docker:host1/container1", "host1", "host1/container1"}, + }, + { + name: "colon-without-slash-no-host-container-split", + resourceID: "abc:def", + expected: []string{"abc:def", "def"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tokenSetFrom(tt.resourceID) + if len(got) != len(tt.expected) { + t.Fatalf("token count mismatch: got %v, want %v", got, tt.expected) + } + for i, v := range tt.expected { + if got[i] != v { + t.Fatalf("token[%d]: got %q, want %q (full got=%v)", i, got[i], v, got) + } + } + }) + } +} + +func TestBuildResourceIDTokenSet(t *testing.T) { + t.Run("empty-input-yields-empty-set", func(t *testing.T) { + if got := buildResourceIDTokenSet(nil); len(got) != 0 { + t.Fatalf("expected empty token set, got %v", got) + } + }) + + t.Run("all-whitespace-yields-empty-set", func(t *testing.T) { + got := buildResourceIDTokenSet([]string{" ", ""}) + if len(got) != 0 { + t.Fatalf("expected empty token set for whitespace-only IDs, got %v", got) + } + }) + + t.Run("multiple-ids-aggregated", func(t *testing.T) { + got := buildResourceIDTokenSet([]string{"vm-101", "ct-202"}) + for _, want := range []string{"vm-101", "101", "ct-202", "202"} { + if _, ok := got[want]; !ok { + t.Fatalf("expected token %q in set, got %v", want, got) + } + } + }) +} + +func TestDiscoveryMatchesTokens(t *testing.T) { + tokens := map[string]struct{}{"abc": {}, "101": {}} + + t.Run("nil-discovery-never-matches", func(t *testing.T) { + if discoveryMatchesTokens(nil, tokens) { + t.Fatalf("nil discovery must not match any token set") + } + }) + + t.Run("matching-token-returns-true", func(t *testing.T) { + d := &ResourceDiscovery{ResourceID: "ABC"} // lowercased to "abc" by discoveryTokens + if !discoveryMatchesTokens(d, tokens) { + t.Fatalf("expected match for ResourceID ABC against token abc") + } + }) + + t.Run("no-matching-token-returns-false", func(t *testing.T) { + d := &ResourceDiscovery{ResourceID: "xyz"} + if discoveryMatchesTokens(d, tokens) { + t.Fatalf("expected no match for ResourceID xyz") + } + }) + + t.Run("empty-token-set-returns-false", func(t *testing.T) { + d := &ResourceDiscovery{ResourceID: "abc"} + if discoveryMatchesTokens(d, map[string]struct{}{}) { + t.Fatalf("empty token set must not match anything") + } + }) +} + +func TestDiscoveryTokens(t *testing.T) { + tests := []struct { + name string + disc *ResourceDiscovery + mustHave []string // tokens that MUST be present (lowercased) + }{ + { + name: "vm-type", + disc: &ResourceDiscovery{ResourceID: "101", TargetID: "node1", ID: "vm:node1:101", ResourceType: ResourceTypeVM}, + mustHave: []string{"101", "vm:node1:101", "node1", "qemu/101", "vm/101", "vm-101", "agent:node1"}, + }, + { + name: "system-container-type", + disc: &ResourceDiscovery{ResourceID: "202", TargetID: "node1", ID: "lxc:node1:202", ResourceType: ResourceTypeSystemContainer}, + mustHave: []string{"202", "lxc/202", "ct/202", "ct-202", "system-container/202"}, + }, + { + name: "docker-type-with-target", + disc: &ResourceDiscovery{ResourceID: "app", TargetID: "host1", ID: "docker:host1:app", ResourceType: ResourceTypeDocker}, + mustHave: []string{"app", "host1", "docker:host1", "docker:host1/app"}, + }, + { + name: "docker-type-without-target", + disc: &ResourceDiscovery{ResourceID: "app", ID: "docker::app", ResourceType: ResourceTypeDocker}, + mustHave: []string{"app"}, + }, + { + name: "agent-type", + disc: &ResourceDiscovery{ResourceID: "ag1", TargetID: "host1", ID: "agent:host1:ag1", ResourceType: ResourceTypeAgent}, + mustHave: []string{"agent:ag1", "agent:host1", "ag1"}, + }, + { + name: "k8s-type", + disc: &ResourceDiscovery{ResourceID: "pod1", TargetID: "cluster1", ID: "k8s:cluster1:pod1", ResourceType: ResourceTypeK8s}, + mustHave: []string{"pod1", "k8s/pod1", "kubernetes/pod1"}, + }, + { + name: "unknown-type-falls-through-switch", + disc: &ResourceDiscovery{ResourceID: "rid", TargetID: "tid", ID: "weird:tid:rid", ResourceType: ResourceType("unknown")}, + mustHave: []string{"rid", "tid", "agent:tid"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := discoveryTokens(tt.disc) + set := make(map[string]struct{}, len(got)) + for _, g := range got { + set[g] = struct{}{} + } + for _, want := range tt.mustHave { + if _, ok := set[want]; !ok { + t.Fatalf("expected token %q in discoveryTokens output, got %v", want, got) + } + } + // Every returned token must be lowercase — matching relies on it. + for _, g := range got { + if g != strings.ToLower(g) { + t.Fatalf("discoveryTokens returned non-lowercased token %q", g) + } + } + }) + } +} + +func TestFilterDiscoveriesByResourceIDs(t *testing.T) { + discoveries := []*ResourceDiscovery{ + {ID: "vm:node1:101", ResourceType: ResourceTypeVM, ResourceID: "101", TargetID: "node1", ServiceName: "VM One"}, + {ID: "docker:host1:app", ResourceType: ResourceTypeDocker, ResourceID: "app", TargetID: "host1", ServiceName: "App"}, + {ID: "lxc:node2:202", ResourceType: ResourceTypeSystemContainer, ResourceID: "202", TargetID: "node2", ServiceName: "LXC"}, + } + + t.Run("empty-discoveries-returns-nil", func(t *testing.T) { + if got := FilterDiscoveriesByResourceIDs(nil, []string{"101"}); got != nil { + t.Fatalf("expected nil for empty discoveries, got %v", got) + } + }) + + t.Run("empty-resource-ids-returns-all", func(t *testing.T) { + got := FilterDiscoveriesByResourceIDs(discoveries, nil) + if len(got) != len(discoveries) { + t.Fatalf("expected all %d discoveries, got %d (%v)", len(discoveries), len(got), got) + } + }) + + t.Run("whitespace-only-ids-returns-nil", func(t *testing.T) { + // tokens set ends up empty -> filter returns nil (distinct from the + // empty-resourceIDs arm which returns all). + if got := FilterDiscoveriesByResourceIDs(discoveries, []string{" ", ""}); got != nil { + t.Fatalf("expected nil when token set is empty, got %v", got) + } + }) + + t.Run("keeps-matching-drops-rest", func(t *testing.T) { + got := FilterDiscoveriesByResourceIDs(discoveries, []string{"101"}) + if len(got) != 1 || got[0].ResourceID != "101" { + t.Fatalf("expected only the VM (101), got %v", got) + } + }) + + t.Run("no-match-returns-empty", func(t *testing.T) { + got := FilterDiscoveriesByResourceIDs(discoveries, []string{"does-not-exist"}) + if len(got) != 0 { + t.Fatalf("expected empty result for non-matching ID, got %v", got) + } + }) + + t.Run("multiple-resource-ids", func(t *testing.T) { + got := FilterDiscoveriesByResourceIDs(discoveries, []string{"101", "app"}) + if len(got) != 2 { + t.Fatalf("expected 2 matches, got %d (%v)", len(got), got) + } + seen := map[string]bool{} + for _, d := range got { + seen[d.ResourceID] = true + } + if !seen["101"] || !seen["app"] { + t.Fatalf("expected to keep 101 and app, got %v", seen) + } + }) +} + +func TestContainerFingerprint_HasChanged(t *testing.T) { + fp := &ContainerFingerprint{Hash: "abc123", SchemaVersion: FingerprintSchemaVersion} + + t.Run("nil-other-is-change", func(t *testing.T) { + if !fp.HasChanged(nil) { + t.Fatalf("HasChanged(nil) must be true") + } + }) + + t.Run("same-hash-no-change", func(t *testing.T) { + other := &ContainerFingerprint{Hash: "abc123", SchemaVersion: FingerprintSchemaVersion} + if fp.HasChanged(other) { + t.Fatalf("HasChanged with identical hash must be false") + } + }) + + t.Run("different-hash-is-change", func(t *testing.T) { + other := &ContainerFingerprint{Hash: "different", SchemaVersion: FingerprintSchemaVersion} + if !fp.HasChanged(other) { + t.Fatalf("HasChanged with different hash must be true") + } + }) +} + +func TestContainerFingerprint_String(t *testing.T) { + fp := &ContainerFingerprint{ + ResourceID: "rid-1", + TargetID: "tid-1", + Hash: "deadbeef", + ImageName: "nginx:1.2.3", + Ports: []string{"80/tcp", "443/tcp"}, + } + + got := fp.String() + want := "Fingerprint{id=rid-1, target=tid-1, hash=deadbeef, image=nginx:1.2.3, ports=[80/tcp 443/tcp]}" + if got != want { + t.Fatalf("String() mismatch:\n got: %s\nwant: %s", got, want) + } +} + +func TestGenerateK8sPodFingerprint(t *testing.T) { + basePod := &KubernetesPod{ + UID: "uid-1", + Name: "web", + Namespace: "prod", + NodeName: "node-a", + OwnerKind: "Deployment", + OwnerName: "web-deploy", + Containers: []KubernetesPodContainer{ + {Name: "c1", Image: "img1:v1"}, + {Name: "c2", Image: "img2:v2"}, + }, + Labels: map[string]string{"app": "web", "team": "platform"}, + } + + t.Run("deterministic-for-same-input", func(t *testing.T) { + fp1 := GenerateK8sPodFingerprint("cluster-1", basePod) + fp2 := GenerateK8sPodFingerprint("cluster-1", basePod) + if fp1.Hash != fp2.Hash { + t.Fatalf("expected deterministic hash; got %q then %q", fp1.Hash, fp2.Hash) + } + if fp1.Hash == "" { + t.Fatalf("expected non-empty hash") + } + }) + + t.Run("identity-fields-populated", func(t *testing.T) { + fp := GenerateK8sPodFingerprint("cluster-1", basePod) + if fp.ResourceID != "uid-1" { + t.Fatalf("ResourceID: got %q, want uid-1", fp.ResourceID) + } + if fp.TargetID != "cluster-1" { + t.Fatalf("TargetID: got %q, want cluster-1", fp.TargetID) + } + if fp.SchemaVersion != FingerprintSchemaVersion { + t.Fatalf("SchemaVersion: got %d, want %d", fp.SchemaVersion, FingerprintSchemaVersion) + } + // First sorted image is "c1:img1:v1" (c1 < c2). + if fp.ImageName != "c1:img1:v1" { + t.Fatalf("ImageName: got %q, want c1:img1:v1", fp.ImageName) + } + }) + + t.Run("empty-containers-leaves-image-empty", func(t *testing.T) { + empty := &KubernetesPod{UID: "u", Name: "n", Namespace: "ns"} + fp := GenerateK8sPodFingerprint("c", empty) + if fp.ImageName != "" { + t.Fatalf("ImageName: got %q, want empty", fp.ImageName) + } + if fp.Hash == "" { + t.Fatalf("expected non-empty hash even with no containers") + } + }) + + // Each mutation below must produce a different hash from the base. + changeCases := []struct { + name string + mutate func(p KubernetesPod) *KubernetesPod + }{ + { + name: "changed-uid", + mutate: func(p KubernetesPod) *KubernetesPod { + p.UID = "uid-2" + return &p + }, + }, + { + name: "changed-name", + mutate: func(p KubernetesPod) *KubernetesPod { + p.Name = "worker" + return &p + }, + }, + { + name: "changed-namespace", + mutate: func(p KubernetesPod) *KubernetesPod { + p.Namespace = "staging" + return &p + }, + }, + { + name: "changed-node", + mutate: func(p KubernetesPod) *KubernetesPod { + p.NodeName = "node-b" + return &p + }, + }, + { + name: "changed-owner-kind", + mutate: func(p KubernetesPod) *KubernetesPod { + p.OwnerKind = "StatefulSet" + return &p + }, + }, + { + name: "cleared-owner", + mutate: func(p KubernetesPod) *KubernetesPod { + p.OwnerKind = "" + p.OwnerName = "" + return &p + }, + }, + { + name: "changed-container-image", + mutate: func(p KubernetesPod) *KubernetesPod { + p.Containers = []KubernetesPodContainer{{Name: "c1", Image: "img1:v9"}} + return &p + }, + }, + { + name: "cleared-containers", + mutate: func(p KubernetesPod) *KubernetesPod { + p.Containers = nil + return &p + }, + }, + { + name: "changed-label-value", + mutate: func(p KubernetesPod) *KubernetesPod { + p.Labels = map[string]string{"app": "web", "team": "sre"} + return &p + }, + }, + { + name: "cleared-labels", + mutate: func(p KubernetesPod) *KubernetesPod { + p.Labels = nil + return &p + }, + }, + } + + base := GenerateK8sPodFingerprint("cluster-1", basePod).Hash + for _, tc := range changeCases { + t.Run(tc.name, func(t *testing.T) { + mutated := tc.mutate(*basePod) + fp := GenerateK8sPodFingerprint("cluster-1", mutated) + if fp.Hash == base { + t.Fatalf("expected different hash after %s; both = %q", tc.name, base) + } + }) + } +} diff --git a/internal/servicediscovery/readiness_branchcov0718_test.go b/internal/servicediscovery/readiness_branchcov0718_test.go new file mode 100644 index 000000000..142d2a8b6 --- /dev/null +++ b/internal/servicediscovery/readiness_branchcov0718_test.go @@ -0,0 +1,501 @@ +package servicediscovery + +import ( + "sort" + "strings" + "testing" + "time" + + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// TestDiscoveryReadinessForResource_Branches exercises the pure early-exit +// branches of (*Service).DiscoveryReadinessForResource: nil target, nil +// receiver, nil store, and unsupported resource type. The supported-type +// branch falls through into Store file I/O via GetDiscoveryByResource and is +// covered instead through the dedicated DiscoveryReadinessForTarget sibling +// tests. +func TestDiscoveryReadinessForResource_Branches(t *testing.T) { + now := time.Date(2026, 7, 18, 9, 0, 0, 0, time.UTC) + supportedTarget := &unified.DiscoveryTarget{ + ResourceType: "system-container", + AgentID: "node-a", + ResourceID: "101", + } + unsupportedTarget := &unified.DiscoveryTarget{ + ResourceType: "ceph", + AgentID: "cluster", + ResourceID: "fsid", + } + + t.Run("nil-target-returns-unsupported", func(t *testing.T) { + var s *Service // nil receiver is fine because target short-circuits first + got := s.DiscoveryReadinessForResource( + unified.Resource{DiscoveryTarget: nil}, now, + ) + if got.State != unified.ResourceDiscoveryReadinessUnsupported { + t.Fatalf("state = %q, want %q", got.State, unified.ResourceDiscoveryReadinessUnsupported) + } + if got.Source != discoveryReadinessSource { + t.Fatalf("Source = %q, want %q", got.Source, discoveryReadinessSource) + } + if !got.GeneratedAt.Equal(now) { + t.Fatalf("GeneratedAt = %v, want %v", got.GeneratedAt, now) + } + if got.ResourceType != "" || got.TargetID != "" || got.ResourceID != "" { + t.Fatalf("base fields should be empty for nil target, got %+v", got) + } + // DiscoveryReadinessForTarget returns the Unsupported state before + // populating StaleAfterSeconds; confirm the staleness slot is left at + // its zero value (which is the actual observable behaviour here). + if got.StaleAfterSeconds != 0 { + t.Fatalf("StaleAfterSeconds = %d, want 0 (nil-target path returns before staleness is set)", + got.StaleAfterSeconds) + } + // defaultDiscoveryMaxAge is the value passed into the inner call; it + // cannot be observed because the unsupported branch short-circuits, so + // assert the call still completes without populating a discovery id. + if got.DiscoveryID != "" { + t.Fatalf("DiscoveryID should be empty for nil target, got %q", got.DiscoveryID) + } + }) + + t.Run("nil-receiver-returns-unavailable", func(t *testing.T) { + var s *Service + got := s.DiscoveryReadinessForResource( + unified.Resource{DiscoveryTarget: supportedTarget}, now, + ) + if got.State != unified.ResourceDiscoveryReadinessUnavailable { + t.Fatalf("state = %q, want %q", got.State, unified.ResourceDiscoveryReadinessUnavailable) + } + if got.Reason != "Discovery service is not configured." { + t.Fatalf("Reason = %q, want not-configured reason", got.Reason) + } + if got.ResourceType != "system-container" || got.TargetID != "node-a" || got.ResourceID != "101" { + t.Fatalf("target fields not projected: %+v", got) + } + }) + + t.Run("nil-store-returns-unavailable", func(t *testing.T) { + s := &Service{} // non-nil receiver but nil store + got := s.DiscoveryReadinessForResource( + unified.Resource{DiscoveryTarget: supportedTarget}, now, + ) + if got.State != unified.ResourceDiscoveryReadinessUnavailable { + t.Fatalf("state = %q, want %q", got.State, unified.ResourceDiscoveryReadinessUnavailable) + } + if got.Reason != "Discovery service is not configured." { + t.Fatalf("Reason = %q, want not-configured reason", got.Reason) + } + }) + + t.Run("zero-now-is-normalized-to-utc", func(t *testing.T) { + var s *Service + got := s.DiscoveryReadinessForResource( + unified.Resource{DiscoveryTarget: nil}, time.Time{}, + ) + if got.GeneratedAt.IsZero() { + t.Fatal("zero now should be replaced with time.Now().UTC()") + } + if got.GeneratedAt.Location() != time.UTC { + t.Fatalf("GeneratedAt location = %v, want UTC", got.GeneratedAt.Location()) + } + }) + + t.Run("unsupported-resource-type-returns-unsupported", func(t *testing.T) { + // Non-nil store with configured maxDiscoveryAge exercises the + // DiscoveryResourceTypeForTarget !ok branch without descending into + // Store file I/O. maxDiscoveryAge is consumed by the inner call but + // not observable on the projection (the unsupported branch returns + // before StaleAfterSeconds is set). + s := &Service{ + store: &Store{}, + maxDiscoveryAge: 7 * 24 * time.Hour, + } + got := s.DiscoveryReadinessForResource( + unified.Resource{DiscoveryTarget: unsupportedTarget}, now, + ) + if got.State != unified.ResourceDiscoveryReadinessUnsupported { + t.Fatalf("state = %q, want %q", got.State, unified.ResourceDiscoveryReadinessUnsupported) + } + if got.Reason != "Service discovery does not support this resource type." { + t.Fatalf("Reason = %q, want unsupported-type reason", got.Reason) + } + // The unsupported-type branch returns from DiscoveryReadinessForTarget + // before StaleAfterSeconds is populated, so the configured maxDiscoveryAge + // is consumed by the call but not observable on the projection. + if got.StaleAfterSeconds != 0 { + t.Fatalf("StaleAfterSeconds = %d, want 0 (unsupported-type path returns before staleness is set)", + got.StaleAfterSeconds) + } + if got.DiscoveryID != "" { + t.Fatalf("DiscoveryID should be empty for unsupported target, got %q", got.DiscoveryID) + } + }) +} + +// TestDiscoveryReadinessUnavailableForTarget_Branches covers the unavailable +// projection: nil vs non-nil target, custom vs empty reason, and the default +// reason substitution when an empty reason is supplied. +func TestDiscoveryReadinessUnavailableForTarget_Branches(t *testing.T) { + now := time.Date(2026, 7, 18, 9, 0, 0, 0, time.UTC) + + t.Run("nil-target-minimal-base", func(t *testing.T) { + got := DiscoveryReadinessUnavailableForTarget(nil, now, "custom reason") + if got.State != unified.ResourceDiscoveryReadinessUnavailable { + t.Fatalf("state = %q, want unavailable", got.State) + } + if got.Reason != "custom reason" { + t.Fatalf("Reason = %q, want custom reason", got.Reason) + } + if got.ResourceType != "" || got.TargetID != "" || got.ResourceID != "" { + t.Fatalf("base fields should be empty for nil target, got %+v", got) + } + if !got.GeneratedAt.Equal(now) { + t.Fatalf("GeneratedAt = %v, want %v", got.GeneratedAt, now) + } + }) + + t.Run("non-nil-target-with-custom-reason", func(t *testing.T) { + target := &unified.DiscoveryTarget{ + ResourceType: "vm", + AgentID: "node-b", + ResourceID: "200", + } + got := DiscoveryReadinessUnavailableForTarget(target, now, " scanner offline ") + if got.State != unified.ResourceDiscoveryReadinessUnavailable { + t.Fatalf("state = %q, want unavailable", got.State) + } + // Reason is trimmed before assignment. + if got.Reason != "scanner offline" { + t.Fatalf("Reason = %q, want trimmed 'scanner offline'", got.Reason) + } + if got.ResourceType != "vm" || got.TargetID != "node-b" || got.ResourceID != "200" { + t.Fatalf("target fields not projected: %+v", got) + } + // Canonical vm target → discoveryID is derived. + if got.DiscoveryID != MakeResourceID(ResourceTypeVM, "node-b", "200") { + t.Fatalf("DiscoveryID = %q, want derived id", got.DiscoveryID) + } + }) + + t.Run("empty-reason-falls-back-to-default", func(t *testing.T) { + target := &unified.DiscoveryTarget{ + ResourceType: "system-container", + AgentID: "node-c", + ResourceID: "303", + } + got := DiscoveryReadinessUnavailableForTarget(target, now, " ") + if got.Reason != "Discovery status is not available." { + t.Fatalf("Reason = %q, want default fallback", got.Reason) + } + }) + + t.Run("zero-now-normalized", func(t *testing.T) { + got := DiscoveryReadinessUnavailableForTarget(nil, time.Time{}, "") + if got.GeneratedAt.IsZero() || got.GeneratedAt.Location() != time.UTC { + t.Fatalf("GeneratedAt = %v, want non-zero UTC", got.GeneratedAt) + } + }) +} + +// TestDiscoveryReadinessReadFailureForTarget_Branches covers the read-failure +// projection for both nil and non-nil targets. The state must always be +// "failed" with the fixed read-failure reason. +func TestDiscoveryReadinessReadFailureForTarget_Branches(t *testing.T) { + now := time.Date(2026, 7, 18, 9, 0, 0, 0, time.UTC) + + t.Run("nil-target", func(t *testing.T) { + got := DiscoveryReadinessReadFailureForTarget(nil, now) + if got.State != unified.ResourceDiscoveryReadinessFailed { + t.Fatalf("state = %q, want failed", got.State) + } + if got.Reason != "Discovery status could not be read." { + t.Fatalf("Reason = %q, want fixed read-failure reason", got.Reason) + } + if got.Source != discoveryReadinessSource { + t.Fatalf("Source = %q, want %q", got.Source, discoveryReadinessSource) + } + if got.ResourceType != "" || got.TargetID != "" || got.ResourceID != "" { + t.Fatalf("base fields should be empty for nil target, got %+v", got) + } + }) + + t.Run("non-nil-target-projects-base-fields", func(t *testing.T) { + target := &unified.DiscoveryTarget{ + ResourceType: "pod", + AgentID: "cluster-x", + ResourceID: "default/web", + } + got := DiscoveryReadinessReadFailureForTarget(target, now) + if got.State != unified.ResourceDiscoveryReadinessFailed { + t.Fatalf("state = %q, want failed", got.State) + } + if got.Reason != "Discovery status could not be read." { + t.Fatalf("Reason = %q, want fixed read-failure reason", got.Reason) + } + // pod maps to ResourceTypeK8s, so DiscoveryIDForTarget derives an id. + if got.DiscoveryID != MakeResourceID(ResourceTypeK8s, "cluster-x", "default/web") { + t.Fatalf("DiscoveryID = %q, want derived k8s id", got.DiscoveryID) + } + if got.ResourceType != "pod" || got.TargetID != "cluster-x" || got.ResourceID != "default/web" { + t.Fatalf("target fields not projected: %+v", got) + } + }) +} + +// TestGetCommandCategories_Branches verifies GetCommandCategories returns a +// unique, sorted category set per resource type, including the empty-set case +// for an unknown type. +func TestGetCommandCategories_Branches(t *testing.T) { + // Pre-compute the expected category set per resource type by walking the + // same command list with a de-duping set + sort, independent of the + // production sort order. + expectedFor := func(rt ResourceType) []string { + cmds := GetCommandsForResource(rt) + set := make(map[string]struct{}) + for _, c := range cmds { + for _, cat := range c.Categories { + set[cat] = struct{}{} + } + } + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out + } + + cases := []struct { + name string + rt ResourceType + }{ + {"system-container", ResourceTypeSystemContainer}, + {"vm", ResourceTypeVM}, + {"docker", ResourceTypeDocker}, + {"docker-vm", ResourceTypeDockerVM}, + {"docker-system-container", ResourceTypeDockerSystemContainer}, + {"k8s", ResourceTypeK8s}, + {"agent", ResourceTypeAgent}, + {"unknown", ResourceType("does-not-exist")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := GetCommandCategories(tc.rt) + want := expectedFor(tc.rt) + + if len(got) != len(want) { + t.Fatalf("category count = %d, want %d (got=%v)", len(got), len(want), got) + } + // Assert each element matches (sorted order) — catches both + // membership and ordering bugs. + for i := range got { + if got[i] != want[i] { + t.Fatalf("category[%d] = %q, want %q (got=%v want=%v)", + i, got[i], want[i], got, want) + } + } + // Result must be sorted ascending. + if !sort.StringsAreSorted(got) { + t.Fatalf("categories not sorted: %v", got) + } + // Result must be unique. + seen := make(map[string]bool, len(got)) + for _, c := range got { + if seen[c] { + t.Fatalf("duplicate category %q in %v", c, got) + } + seen[c] = true + } + }) + } + + // Explicit invariant: unknown type yields a non-nil empty slice so callers + // can safely iterate. + if cats := GetCommandCategories(ResourceType("nope")); len(cats) != 0 { + t.Fatalf("unknown resource type should yield no categories, got %v", cats) + } + + // Cross-check a concrete known category against the agent (host) set so + // the test does not purely mirror production code — "version" must appear + // because getHostCommands includes os_release/proxmox_version commands. + agentCats := GetCommandCategories(ResourceTypeAgent) + if !containsString(agentCats, "version") { + t.Fatalf("agent categories missing expected 'version': %v", agentCats) + } + if !containsString(agentCats, "hardware") { + t.Fatalf("agent categories missing expected 'hardware': %v", agentCats) + } +} + +func containsString(haystack []string, needle string) bool { + for _, h := range haystack { + if h == needle { + return true + } + } + return false +} + +// TestIsSchemaOutdated_Branches covers the three meaningful arms of the +// schema-version comparison: older (true), current (false), and future +// (false — forward-compatible). +func TestIsSchemaOutdated_Branches(t *testing.T) { + t.Run("zero-schema-outdated", func(t *testing.T) { + fp := &ContainerFingerprint{SchemaVersion: 0} + if !fp.IsSchemaOutdated() { + t.Fatal("SchemaVersion=0 should be outdated") + } + }) + + t.Run("older-schema-outdated", func(t *testing.T) { + fp := &ContainerFingerprint{SchemaVersion: FingerprintSchemaVersion - 1} + if !fp.IsSchemaOutdated() { + t.Fatalf("SchemaVersion=%d (current-1) should be outdated", FingerprintSchemaVersion-1) + } + }) + + t.Run("current-schema-not-outdated", func(t *testing.T) { + fp := &ContainerFingerprint{SchemaVersion: FingerprintSchemaVersion} + if fp.IsSchemaOutdated() { + t.Fatalf("SchemaVersion=%d == current should not be outdated", FingerprintSchemaVersion) + } + }) + + t.Run("future-schema-not-outdated", func(t *testing.T) { + fp := &ContainerFingerprint{SchemaVersion: FingerprintSchemaVersion + 1} + if fp.IsSchemaOutdated() { + t.Fatalf("SchemaVersion=%d (future) should not be outdated", FingerprintSchemaVersion+1) + } + }) +} + +// TestNormalizeDeepScanTimeout_Branches verifies the clamp/return behavior for +// positive, zero, and negative inputs. +func TestNormalizeDeepScanTimeout_Branches(t *testing.T) { + t.Run("positive-returned-as-is", func(t *testing.T) { + in := 90 * time.Second + if got := normalizeDeepScanTimeout(in); got != in { + t.Fatalf("normalizeDeepScanTimeout(%v) = %v, want %v", in, got, in) + } + }) + + t.Run("positive-small-value-returned-as-is", func(t *testing.T) { + // Positive but below the default is still honored — only non-positive + // triggers the default. + in := 1 * time.Millisecond + if got := normalizeDeepScanTimeout(in); got != in { + t.Fatalf("normalizeDeepScanTimeout(%v) = %v, want %v", in, got, in) + } + }) + + t.Run("zero-falls-back-to-default", func(t *testing.T) { + if got := normalizeDeepScanTimeout(0); got != defaultDiscoveryScanTimeout { + t.Fatalf("normalizeDeepScanTimeout(0) = %v, want default %v", + got, defaultDiscoveryScanTimeout) + } + }) + + t.Run("negative-falls-back-to-default", func(t *testing.T) { + in := -5 * time.Second + if got := normalizeDeepScanTimeout(in); got != defaultDiscoveryScanTimeout { + t.Fatalf("normalizeDeepScanTimeout(%v) = %v, want default %v", + in, got, defaultDiscoveryScanTimeout) + } + }) +} + +// TestFormatURLSuggestionDiagnostic_Branches covers each combination of +// primary/fallback code/detail, including the all-empty sentinel return. +func TestFormatURLSuggestionDiagnostic_Branches(t *testing.T) { + cases := []struct { + name string + primaryCode string + primaryDetail string + fallbackCode string + fallbackDetail string + want string + wantSubstrings []string + }{ + { + name: "all-empty-sentinel", + want: "no suggestion diagnostics available", + }, + { + name: "primary-only-with-detail", + primaryCode: "port_match", + primaryDetail: "matched 8123/tcp", + want: "primary=port_match (matched 8123/tcp)", + }, + { + name: "primary-only-no-detail", + primaryCode: "port_match", + want: "primary=port_match", + }, + { + name: "fallback-only-with-detail", + fallbackCode: "service_type_default", + fallbackDetail: "default for homeassistant", + want: "fallback=service_type_default (default for homeassistant)", + }, + { + name: "fallback-only-no-detail", + fallbackCode: "service_type_default", + want: "fallback=service_type_default", + }, + { + name: "both-with-details-joined-by-semicolon", + primaryCode: "port_match", + primaryDetail: "8123/tcp", + fallbackCode: "service_type_default", + fallbackDetail: "homeassistant", + want: "primary=port_match (8123/tcp); fallback=service_type_default (homeassistant)", + }, + { + name: "both-without-details", + primaryCode: "port_match", + fallbackCode: "service_type_default", + want: "primary=port_match; fallback=service_type_default", + }, + { + name: "primary-empty-fallback-present", + fallbackCode: "service_type_default", + want: "fallback=service_type_default", + }, + { + name: "primary-empty-fallback-with-detail", + fallbackCode: "service_type_default", + fallbackDetail: "x", + want: "fallback=service_type_default (x)", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := formatURLSuggestionDiagnostic( + tc.primaryCode, tc.primaryDetail, + tc.fallbackCode, tc.fallbackDetail, + ) + if tc.want != "" && got != tc.want { + t.Fatalf("formatURLSuggestionDiagnostic(...) = %q, want %q", + got, tc.want) + } + for _, sub := range tc.wantSubstrings { + if !strings.Contains(got, sub) { + t.Fatalf("expected substring %q in %q", sub, got) + } + } + }) + } + + // Explicit invariant: primary must always precede fallback when both set. + both := formatURLSuggestionDiagnostic("p_code", "p_detail", "f_code", "f_detail") + if !strings.HasPrefix(both, "primary=") { + t.Fatalf("expected primary= prefix, got %q", both) + } + if !strings.Contains(both, "; fallback=") { + t.Fatalf("expected '; fallback=' separator, got %q", both) + } +} diff --git a/internal/storagehealth/risk_branchcov0718_test.go b/internal/storagehealth/risk_branchcov0718_test.go new file mode 100644 index 000000000..52b5eb416 --- /dev/null +++ b/internal/storagehealth/risk_branchcov0718_test.go @@ -0,0 +1,570 @@ +package storagehealth + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +// Pointer helpers keep malformed-input branches explicit at the call site. +func int64Ptr(v int64) *int64 { return &v } +func intPtr(v int) *int { return &v } + +// reasonSeverity returns the severity for the first reason matching code, or +// the zero RiskLevel when no such reason exists. Used to assert both the +// presence of a reason and its severity in a single check. +func reasonSeverity(assessment Assessment, code string) (RiskLevel, bool) { + for _, r := range assessment.Reasons { + if r.Code == code { + return r.Severity, true + } + } + return "", false +} + +// --- AssessPhysicalDisk: branch coverage over risk.go:48-88 --- + +func TestAssessPhysicalDisk_Branches(t *testing.T) { + tests := []struct { + name string + disk models.PhysicalDisk + wantLevel RiskLevel + wantReasons []string // codes that MUST be present (independent of order) + }{ + { + // nil SmartAttributes arm: every *int64 pointer is skipped; only + // Model/Health/Temperature/Wearout are sourced from the disk. + name: "healthy disk with nil smart attributes stays healthy", + disk: models.PhysicalDisk{Model: "Crucial MX500", Health: "PASSED", Temperature: 35, Wearout: 80}, + wantLevel: RiskHealthy, + }, + { + // non-nil SmartAttributes arm with all pointers set but all benign: + // proves every `attrs.X != nil` branch executes without escalating. + name: "healthy disk with benign smart attributes stays healthy", + disk: models.PhysicalDisk{ + Health: "PASSED", Temperature: 35, Wearout: 80, + SmartAttributes: &models.SMARTAttributes{ + PowerOnHours: int64Ptr(1000), + PowerCycles: int64Ptr(50), + ReallocatedSectors: int64Ptr(0), + PendingSectors: int64Ptr(0), + OfflineUncorrectable: int64Ptr(0), + UDMACRCErrors: int64Ptr(0), + PercentageUsed: intPtr(10), + AvailableSpare: intPtr(90), + MediaErrors: int64Ptr(0), + UnsafeShutdowns: int64Ptr(0), + }, + }, + wantLevel: RiskHealthy, + }, + { + // FAILED health (no firmware-bug model) -> critical health_status. + name: "failed health escalates to critical", + disk: models.PhysicalDisk{Model: "Crucial MX500", Health: "FAILED"}, + wantLevel: RiskCritical, + wantReasons: []string{"health_status"}, + }, + { + // Pending sectors via SMART attributes -> critical. + name: "pending sectors via smart attributes critical", + disk: models.PhysicalDisk{ + Health: "PASSED", + SmartAttributes: &models.SMARTAttributes{ + PendingSectors: int64Ptr(5), + }, + }, + wantLevel: RiskCritical, + wantReasons: []string{"pending_sectors"}, + }, + { + // Offline uncorrectable via SMART attributes -> critical. + name: "offline uncorrectable via smart attributes critical", + disk: models.PhysicalDisk{ + Health: "PASSED", + SmartAttributes: &models.SMARTAttributes{ + OfflineUncorrectable: int64Ptr(2), + }, + }, + wantLevel: RiskCritical, + wantReasons: []string{"offline_uncorrectable"}, + }, + { + // Media errors via SMART attributes -> critical. + name: "media errors via smart attributes critical", + disk: models.PhysicalDisk{ + Health: "PASSED", + SmartAttributes: &models.SMARTAttributes{ + MediaErrors: int64Ptr(1), + }, + }, + wantLevel: RiskCritical, + wantReasons: []string{"media_errors"}, + }, + { + // Wearout <= 5 (sourced from disk.Wearout directly) -> critical. + name: "wearout at critical threshold", + disk: models.PhysicalDisk{Health: "PASSED", Wearout: 3}, + wantLevel: RiskCritical, + wantReasons: []string{"wearout_low"}, + }, + { + // Wearout 6..9 (warning band) -> warning. + name: "wearout at warning threshold", + disk: models.PhysicalDisk{Health: "PASSED", Wearout: 8}, + wantLevel: RiskWarning, + wantReasons: []string{"wearout_low"}, + }, + { + // Available spare <= 10 via SMART -> critical. + name: "nvme available spare critical", + disk: models.PhysicalDisk{ + Health: "PASSED", + SmartAttributes: &models.SMARTAttributes{ + AvailableSpare: intPtr(8), + }, + }, + wantLevel: RiskCritical, + wantReasons: []string{"nvme_available_spare_low"}, + }, + { + // Available spare 11..19 via SMART -> warning. + name: "nvme available spare warning", + disk: models.PhysicalDisk{ + Health: "PASSED", + SmartAttributes: &models.SMARTAttributes{ + AvailableSpare: intPtr(15), + }, + }, + wantLevel: RiskWarning, + wantReasons: []string{"nvme_available_spare_low"}, + }, + { + // PercentageUsed >= 95 via SMART -> critical. + name: "nvme percentage used critical", + disk: models.PhysicalDisk{ + Health: "PASSED", + SmartAttributes: &models.SMARTAttributes{ + PercentageUsed: intPtr(97), + }, + }, + wantLevel: RiskCritical, + wantReasons: []string{"nvme_percentage_used_high"}, + }, + { + // PercentageUsed 90..94 via SMART -> warning. + name: "nvme percentage used warning", + disk: models.PhysicalDisk{ + Health: "PASSED", + SmartAttributes: &models.SMARTAttributes{ + PercentageUsed: intPtr(92), + }, + }, + wantLevel: RiskWarning, + wantReasons: []string{"nvme_percentage_used_high"}, + }, + { + // Temperature >= 70 -> critical. + name: "temperature critical", + disk: models.PhysicalDisk{Health: "PASSED", Temperature: 72}, + wantLevel: RiskCritical, + wantReasons: []string{"temperature_high"}, + }, + { + // Temperature 60..69 -> warning. + name: "temperature warning", + disk: models.PhysicalDisk{Health: "PASSED", Temperature: 63}, + wantLevel: RiskWarning, + wantReasons: []string{"temperature_high"}, + }, + { + // Reallocated sectors > 0 via SMART -> warning. + name: "reallocated sectors warning", + disk: models.PhysicalDisk{ + Health: "PASSED", + SmartAttributes: &models.SMARTAttributes{ + ReallocatedSectors: int64Ptr(4), + }, + }, + wantLevel: RiskWarning, + wantReasons: []string{"reallocated_sectors"}, + }, + { + // UDMA CRC errors > 0 via SMART -> monitor. + name: "udma crc errors monitor", + disk: models.PhysicalDisk{ + Health: "PASSED", + SmartAttributes: &models.SMARTAttributes{ + UDMACRCErrors: int64Ptr(10), + }, + }, + wantLevel: RiskMonitor, + wantReasons: []string{"crc_errors"}, + }, + { + // Samsung 980 firmware-bug model suppresses FAILED health_status. + name: "samsung 980 firmware bug suppresses failed health", + disk: models.PhysicalDisk{Model: "Samsung SSD 980 Pro", Health: "FAILED"}, + wantLevel: RiskHealthy, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := AssessPhysicalDisk(tt.disk) + if got.Level != tt.wantLevel { + t.Fatalf("AssessPhysicalDisk level = %q, want %q; reasons=%+v", got.Level, tt.wantLevel, got.Reasons) + } + for _, code := range tt.wantReasons { + if _, ok := reasonSeverity(got, code); !ok { + t.Errorf("expected reason %q present, got reasons=%+v", code, got.Reasons) + } + } + }) + } +} + +// TestAssessPhysicalDisk_NilSmartAttributesDoesNotPanic pins the nil-SmartAttributes +// branch in isolation: a disk with zero-valued fields must not deref nil pointers. +func TestAssessPhysicalDisk_NilSmartAttributesDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("AssessPhysicalDisk panicked on nil SmartAttributes: %v", r) + } + }() + got := AssessPhysicalDisk(models.PhysicalDisk{}) + if got.Level != RiskHealthy { + t.Fatalf("empty disk level = %q, want %q", got.Level, RiskHealthy) + } + if len(got.Reasons) != 0 { + t.Fatalf("empty disk should produce no reasons, got %+v", got.Reasons) + } +} + +// TestAssessPhysicalDisk_MultipleReasonsTakeHighest drives several conditions +// at once and asserts both the merged level (critical) and that every reason +// is preserved through the SMART-attribute mapping. +func TestAssessPhysicalDisk_MultipleReasonsTakeHighest(t *testing.T) { + got := AssessPhysicalDisk(models.PhysicalDisk{ + Health: "PASSED", + Temperature: 63, // warning + SmartAttributes: &models.SMARTAttributes{ + PendingSectors: int64Ptr(1), // critical + UDMACRCErrors: int64Ptr(2), // monitor + }, + }) + if got.Level != RiskCritical { + t.Fatalf("expected critical (highest), got %q; reasons=%+v", got.Level, got.Reasons) + } + if len(got.Reasons) != 3 { + t.Fatalf("expected 3 reasons (temperature_high, pending_sectors, crc_errors), got %d: %+v", len(got.Reasons), got.Reasons) + } + for _, code := range []string{"pending_sectors", "temperature_high", "crc_errors"} { + if _, ok := reasonSeverity(got, code); !ok { + t.Errorf("expected reason %q present, got %+v", code, got.Reasons) + } + } +} + +// --- AssessHostSMARTDisk: branch coverage over risk.go:90-131 --- +// +// AssessHostSMARTDisk differs from AssessPhysicalDisk in two ways: +// - Wearout is initialised to -1 (so the wearout branches only fire when +// PercentageUsed is supplied and derives Wearout into a positive band). +// - When PercentageUsed is set, Wearout is computed as 100 - PercentageUsed. + +func TestAssessHostSMARTDisk_Branches(t *testing.T) { + tests := []struct { + name string + disk models.HostDiskSMART + wantLevel RiskLevel + wantReasons []string + }{ + { + // nil Attributes arm: only Model/Health/Temperature are sourced; + // Wearout stays -1 so the wearout branch is skipped. + name: "nil attributes stays healthy", + disk: models.HostDiskSMART{Model: "WD Blue", Health: "PASSED", Temperature: 35}, + wantLevel: RiskHealthy, + }, + { + // non-nil Attributes arm with benign values: every pointer is read + // but none escalate. Proves nil-deref safety for the SMART path. + name: "benign attributes stays healthy", + disk: models.HostDiskSMART{ + Health: "PASSED", Temperature: 35, + Attributes: &models.SMARTAttributes{ + PowerOnHours: int64Ptr(120), + PowerCycles: int64Ptr(8), + ReallocatedSectors: int64Ptr(0), + PendingSectors: int64Ptr(0), + OfflineUncorrectable: int64Ptr(0), + UDMACRCErrors: int64Ptr(0), + PercentageUsed: intPtr(20), // Wearout = 80, healthy + AvailableSpare: intPtr(99), + MediaErrors: int64Ptr(0), + UnsafeShutdowns: int64Ptr(0), + }, + }, + wantLevel: RiskHealthy, + }, + { + // FAILED health on a host SMART disk -> critical health_status. + name: "failed health critical", + disk: models.HostDiskSMART{Model: "WD Blue", Health: "FAILED"}, + wantLevel: RiskCritical, + wantReasons: []string{"health_status"}, + }, + { + // Pending sectors via host SMART attributes -> critical. + name: "pending sectors critical", + disk: models.HostDiskSMART{ + Health: "PASSED", + Attributes: &models.SMARTAttributes{ + PendingSectors: int64Ptr(3), + }, + }, + wantLevel: RiskCritical, + wantReasons: []string{"pending_sectors"}, + }, + { + // Offline uncorrectable via host SMART attributes -> critical. + name: "offline uncorrectable critical", + disk: models.HostDiskSMART{ + Health: "PASSED", + Attributes: &models.SMARTAttributes{ + OfflineUncorrectable: int64Ptr(1), + }, + }, + wantLevel: RiskCritical, + wantReasons: []string{"offline_uncorrectable"}, + }, + { + // Media errors via host SMART attributes -> critical. + name: "media errors critical", + disk: models.HostDiskSMART{ + Health: "PASSED", + Attributes: &models.SMARTAttributes{ + MediaErrors: int64Ptr(2), + }, + }, + wantLevel: RiskCritical, + wantReasons: []string{"media_errors"}, + }, + { + // PercentageUsed >= 95 derives Wearout <= 5: BOTH the percentage-used + // critical reason AND the wearout_low critical reason should fire. + name: "percentage used critical derives wearout critical", + disk: models.HostDiskSMART{ + Health: "PASSED", + Attributes: &models.SMARTAttributes{ + PercentageUsed: intPtr(97), // Wearout = 3 -> critical + }, + }, + wantLevel: RiskCritical, + wantReasons: []string{"nvme_percentage_used_high", "wearout_low"}, + }, + { + // PercentageUsed 90..94 derives Wearout 6..9: both reasons at warning. + name: "percentage used warning derives wearout warning", + disk: models.HostDiskSMART{ + Health: "PASSED", + Attributes: &models.SMARTAttributes{ + PercentageUsed: intPtr(92), // Wearout = 8 -> warning + }, + }, + wantLevel: RiskWarning, + wantReasons: []string{"nvme_percentage_used_high", "wearout_low"}, + }, + { + // Available spare <= 10 via host SMART -> critical. + name: "nvme available spare critical", + disk: models.HostDiskSMART{ + Health: "PASSED", + Attributes: &models.SMARTAttributes{ + AvailableSpare: intPtr(7), + }, + }, + wantLevel: RiskCritical, + wantReasons: []string{"nvme_available_spare_low"}, + }, + { + // Available spare 11..19 via host SMART -> warning. + name: "nvme available spare warning", + disk: models.HostDiskSMART{ + Health: "PASSED", + Attributes: &models.SMARTAttributes{ + AvailableSpare: intPtr(15), + }, + }, + wantLevel: RiskWarning, + wantReasons: []string{"nvme_available_spare_low"}, + }, + { + // Temperature >= 70 -> critical. + name: "temperature critical", + disk: models.HostDiskSMART{Health: "PASSED", Temperature: 75}, + wantLevel: RiskCritical, + wantReasons: []string{"temperature_high"}, + }, + { + // Temperature 60..69 -> warning. + name: "temperature warning", + disk: models.HostDiskSMART{Health: "PASSED", Temperature: 60}, + wantLevel: RiskWarning, + wantReasons: []string{"temperature_high"}, + }, + { + // Reallocated sectors > 0 via host SMART -> warning. + name: "reallocated sectors warning", + disk: models.HostDiskSMART{ + Health: "PASSED", + Attributes: &models.SMARTAttributes{ + ReallocatedSectors: int64Ptr(6), + }, + }, + wantLevel: RiskWarning, + wantReasons: []string{"reallocated_sectors"}, + }, + { + // UDMA CRC errors > 0 via host SMART -> monitor. + name: "udma crc errors monitor", + disk: models.HostDiskSMART{ + Health: "PASSED", + Attributes: &models.SMARTAttributes{ + UDMACRCErrors: int64Ptr(11), + }, + }, + wantLevel: RiskMonitor, + wantReasons: []string{"crc_errors"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := AssessHostSMARTDisk(tt.disk) + if got.Level != tt.wantLevel { + t.Fatalf("AssessHostSMARTDisk level = %q, want %q; reasons=%+v", got.Level, tt.wantLevel, got.Reasons) + } + for _, code := range tt.wantReasons { + if _, ok := reasonSeverity(got, code); !ok { + t.Errorf("expected reason %q present, got %+v", code, got.Reasons) + } + } + }) + } +} + +// TestAssessHostSMARTDisk_NilAttributesDoesNotPanic pins the nil-Attributes +// branch in isolation; with default Wearout=-1, no wearout reason fires. +func TestAssessHostSMARTDisk_NilAttributesDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("AssessHostSMARTDisk panicked on nil Attributes: %v", r) + } + }() + got := AssessHostSMARTDisk(models.HostDiskSMART{}) + if got.Level != RiskHealthy { + t.Fatalf("empty disk level = %q, want %q", got.Level, RiskHealthy) + } + if len(got.Reasons) != 0 { + t.Fatalf("empty disk should produce no reasons, got %+v", got.Reasons) + } + // Sanity: when Wearout is computed from a default -1 (no PercentageUsed), + // the wearout_low branch must not fire even with attributes present. + got2 := AssessHostSMARTDisk(models.HostDiskSMART{ + Health: "PASSED", + Attributes: &models.SMARTAttributes{PowerOnHours: int64Ptr(100)}, + }) + if _, ok := reasonSeverity(got2, "wearout_low"); ok { + t.Fatalf("wearout_low must not fire when Wearout is negative") + } +} + +// --- SummarizeAssessments: branch coverage over topology.go:335-345 --- + +func TestSummarizeAssessments(t *testing.T) { + t.Run("empty returns healthy with no reasons", func(t *testing.T) { + got := SummarizeAssessments() + if got.Level != RiskHealthy { + t.Fatalf("level = %q, want %q", got.Level, RiskHealthy) + } + if len(got.Reasons) != 0 { + t.Fatalf("expected no reasons, got %+v", got.Reasons) + } + }) + + t.Run("single assessment passes through level", func(t *testing.T) { + in := Assessment{ + Level: RiskWarning, + Reasons: []Reason{ + {Code: "a", Severity: RiskWarning, Summary: "a summary"}, + }, + } + got := SummarizeAssessments(in) + if got.Level != RiskWarning { + t.Fatalf("level = %q, want %q", got.Level, RiskWarning) + } + if len(got.Reasons) != 1 || got.Reasons[0].Code != "a" { + t.Fatalf("reasons not preserved, got %+v", got.Reasons) + } + }) + + t.Run("multiple differing severities picks highest", func(t *testing.T) { + healthy := Assessment{Level: RiskHealthy, Reasons: []Reason{{Code: "h", Severity: RiskHealthy, Summary: "h"}}} + monitor := Assessment{Level: RiskMonitor, Reasons: []Reason{{Code: "m", Severity: RiskMonitor, Summary: "m"}}} + warning := Assessment{Level: RiskWarning, Reasons: []Reason{{Code: "w", Severity: RiskWarning, Summary: "w"}}} + critical := Assessment{Level: RiskCritical, Reasons: []Reason{{Code: "c", Severity: RiskCritical, Summary: "c"}}} + + got := SummarizeAssessments(healthy, monitor, warning, critical) + if got.Level != RiskCritical { + t.Fatalf("level = %q, want %q (highest of inputs)", got.Level, RiskCritical) + } + // All reasons must be merged through. + if len(got.Reasons) != 4 { + t.Fatalf("expected 4 merged reasons, got %d: %+v", len(got.Reasons), got.Reasons) + } + gotCodes := map[string]bool{} + for _, r := range got.Reasons { + gotCodes[r.Code] = true + } + for _, want := range []string{"h", "m", "w", "c"} { + if !gotCodes[want] { + t.Errorf("expected merged reason %q present, got %+v", want, got.Reasons) + } + } + }) + + t.Run("reasons sorted by severity descending then code", func(t *testing.T) { + // Two critical-severity reasons with codes "b" and "a" (so secondary + // sort by code is exercised) plus a warning reason. + in := Assessment{ + Level: RiskCritical, + Reasons: []Reason{ + {Code: "b", Severity: RiskCritical, Summary: "b"}, + {Code: "a", Severity: RiskCritical, Summary: "a"}, + {Code: "z", Severity: RiskWarning, Summary: "z"}, + }, + } + got := SummarizeAssessments(in) + if len(got.Reasons) != 3 { + t.Fatalf("expected 3 reasons, got %d: %+v", len(got.Reasons), got.Reasons) + } + // Criticals first (severity desc), and within equal severity codes ascending. + wantOrder := []string{"a", "b", "z"} + for i, want := range wantOrder { + if got.Reasons[i].Code != want { + t.Errorf("reasons[%d].Code = %q, want %q (full: %+v)", i, got.Reasons[i].Code, want, got.Reasons) + } + } + }) + + t.Run("healthy inputs dominate only when no higher severity present", func(t *testing.T) { + // All inputs healthy -> summary healthy. + got := SummarizeAssessments(Assessment{Level: RiskHealthy}, Assessment{Level: RiskHealthy}) + if got.Level != RiskHealthy { + t.Fatalf("level = %q, want %q", got.Level, RiskHealthy) + } + }) +} diff --git a/internal/telemetry/pulse_intelligence_evidence_branchcov0718_test.go b/internal/telemetry/pulse_intelligence_evidence_branchcov0718_test.go new file mode 100644 index 000000000..e89762d03 --- /dev/null +++ b/internal/telemetry/pulse_intelligence_evidence_branchcov0718_test.go @@ -0,0 +1,397 @@ +package telemetry + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +// fixedCutoff is the `since` boundary used across these tests; events strictly +// before it must be filtered out, events at-or-after it must be projected. +var fixedCutoff = time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + +func TestPulseIntelligenceAIUsageEvidenceFromHistory(t *testing.T) { + // Anchor timestamps relative to the cutoff. + before := fixedCutoff.Add(-24 * time.Hour) // excluded by cutoff + atCutoff := fixedCutoff // included (not Before(since) and not zero) + after := fixedCutoff.Add(24 * time.Hour) // included + + tests := []struct { + name string + history *config.AIUsageHistoryData + want PulseIntelligenceAIUsageEvidence + }{ + { + name: "nil history returns zero evidence", + history: nil, + want: PulseIntelligenceAIUsageEvidence{}, + }, + { + name: "empty events returns zero evidence", + history: &config.AIUsageHistoryData{}, + want: PulseIntelligenceAIUsageEvidence{}, + }, + { + name: "event before cutoff is excluded", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: before, UseCase: "chat"}, + }, + }, + want: PulseIntelligenceAIUsageEvidence{}, + }, + { + name: "zero timestamp event is excluded", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: time.Time{}, UseCase: "chat"}, + }, + }, + want: PulseIntelligenceAIUsageEvidence{}, + }, + { + name: "event at cutoff is included (Before is strict)", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: atCutoff, UseCase: "patrol"}, + }, + }, + want: PulseIntelligenceAIUsageEvidence{PatrolAICalls: 1}, + }, + { + name: "plain chat after cutoff increments AssistantAICalls only", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: after, UseCase: "chat"}, + }, + }, + want: PulseIntelligenceAIUsageEvidence{AssistantAICalls: 1}, + }, + { + name: "chat with governed context scopes increments context counter", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: after, UseCase: "chat", ContextScope: "fleet"}, + }, + }, + want: PulseIntelligenceAIUsageEvidence{AssistantAICalls: 1, AssistantContextAICalls: 1}, + }, + { + name: "chat with TargetType/TargetID/FindingID all trigger context detection", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: after, UseCase: "chat", TargetType: "vm"}, + {Timestamp: after, UseCase: "chat", TargetID: "node-1"}, + {Timestamp: after, UseCase: "chat", FindingID: "F-42"}, + }, + }, + want: PulseIntelligenceAIUsageEvidence{AssistantAICalls: 3, AssistantContextAICalls: 3}, + }, + { + name: "whitespace-only context fields do not count as context", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: after, UseCase: "chat", ContextScope: " "}, + }, + }, + want: PulseIntelligenceAIUsageEvidence{AssistantAICalls: 1}, + }, + { + name: "chat ToolCallCount is summed across calls", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: after, UseCase: "chat", ToolCallCount: 3}, + {Timestamp: after, UseCase: "chat", ToolCallCount: 2}, + {Timestamp: after, UseCase: "chat"}, // zero ToolCallCount does not add + }, + }, + want: PulseIntelligenceAIUsageEvidence{AssistantAICalls: 3, AssistantToolCalls: 5}, + }, + { + name: "patrol use case increments PatrolAICalls only", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: after, UseCase: "patrol", ToolCallCount: 9, ContextScope: "fleet"}, + }, + }, + want: PulseIntelligenceAIUsageEvidence{PatrolAICalls: 1}, + }, + { + name: "use case is case-insensitive and trimmed", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: after, UseCase: " CHAT "}, + {Timestamp: after, UseCase: "PaTrOl"}, + }, + }, + want: PulseIntelligenceAIUsageEvidence{AssistantAICalls: 1, PatrolAICalls: 1}, + }, + { + name: "unknown use case is ignored", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: after, UseCase: "summarize"}, + }, + }, + want: PulseIntelligenceAIUsageEvidence{}, + }, + { + name: "mixed inclusion: before-cutoff filtered, after-cutoff projected", + history: &config.AIUsageHistoryData{ + Events: []config.AIUsageEventRecord{ + {Timestamp: before, UseCase: "chat", ContextScope: "fleet", ToolCallCount: 100}, + {Timestamp: after, UseCase: "chat", ContextScope: "host", ToolCallCount: 2}, + {Timestamp: after, UseCase: "patrol"}, + }, + }, + want: PulseIntelligenceAIUsageEvidence{ + AssistantAICalls: 1, + AssistantContextAICalls: 1, + AssistantToolCalls: 2, + PatrolAICalls: 1, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := PulseIntelligenceAIUsageEvidenceFromHistory(tt.history, fixedCutoff) + if got != tt.want { + t.Fatalf("PulseIntelligenceAIUsageEvidenceFromHistory() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestPulseIntelligenceExternalAgentActivitySurface(t *testing.T) { + tests := []struct { + name string + surface string + want bool + }{ + {"agent_api", config.ExternalAgentActivitySurfaceAgentAPI, true}, + {"pulse_mcp", config.ExternalAgentActivitySurfacePulseMCP, true}, + {"whitespace wrapped agent_api", " " + config.ExternalAgentActivitySurfaceAgentAPI + " ", true}, + {"unknown surface", "webhook", false}, + {"empty surface", "", false}, + {"whitespace only", " ", false}, + {"workflow prompt agent_api constant shares the same surface value", config.WorkflowPromptActivitySurfaceAgentAPI, true}, // value is "agent_api" + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := PulseIntelligenceExternalAgentActivitySurface(tt.surface); got != tt.want { + t.Fatalf("PulseIntelligenceExternalAgentActivitySurface(%q) = %v, want %v", tt.surface, got, tt.want) + } + }) + } +} + +func TestPulseIntelligenceExternalAgentEvidence_CollaborationActive(t *testing.T) { + tests := []struct { + name string + e PulseIntelligenceExternalAgentEvidence + want bool + }{ + {name: "zero evidence inactive", want: false}, + {name: "Used flag active", e: PulseIntelligenceExternalAgentEvidence{Used: true}, want: true}, + {name: "MCPAdapterUsed active", e: PulseIntelligenceExternalAgentEvidence{MCPAdapterUsed: true}, want: true}, + {name: "ContextRequests active", e: PulseIntelligenceExternalAgentEvidence{ContextRequests: 1}, want: true}, + {name: "EventStreamRequests active", e: PulseIntelligenceExternalAgentEvidence{EventStreamRequests: 1}, want: true}, + {name: "ProvisioningRequests active", e: PulseIntelligenceExternalAgentEvidence{ProvisioningRequests: 1}, want: true}, + {name: "OperatorStateRequests active", e: PulseIntelligenceExternalAgentEvidence{OperatorStateRequests: 1}, want: true}, + {name: "FindingRequests active", e: PulseIntelligenceExternalAgentEvidence{FindingRequests: 1}, want: true}, + {name: "ActionRequests active", e: PulseIntelligenceExternalAgentEvidence{ActionRequests: 1}, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.e.CollaborationActive(); got != tt.want { + t.Fatalf("CollaborationActive() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPulseIntelligenceExternalAgentEvidence_CollaborationCount(t *testing.T) { + tests := []struct { + name string + e PulseIntelligenceExternalAgentEvidence + want int + }{ + {name: "zero evidence count is zero", want: 0}, + {name: "request counts sum to total", e: PulseIntelligenceExternalAgentEvidence{ + ContextRequests: 2, EventStreamRequests: 3, ProvisioningRequests: 1, + OperatorStateRequests: 1, FindingRequests: 1, ActionRequests: 2, + }, want: 10}, + {name: "Used only collapses to coarse count 1", e: PulseIntelligenceExternalAgentEvidence{Used: true}, want: 1}, + {name: "MCPAdapterUsed only collapses to coarse count 1", e: PulseIntelligenceExternalAgentEvidence{MCPAdapterUsed: true}, want: 1}, + {name: "request counts take precedence over coarse Used flag", e: PulseIntelligenceExternalAgentEvidence{ + Used: true, ContextRequests: 4, + }, want: 4}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.e.CollaborationCount(); got != tt.want { + t.Fatalf("CollaborationCount() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestPulseIntelligenceExternalAgentEvidence_ApplyActivity(t *testing.T) { + tests := []struct { + name string + activity string + want PulseIntelligenceExternalAgentEvidence + }{ + {"resource_context bumps ContextRequests", config.ExternalAgentActivityResourceContext, PulseIntelligenceExternalAgentEvidence{ContextRequests: 1}}, + {"fleet_context bumps ContextRequests", config.ExternalAgentActivityFleetContext, PulseIntelligenceExternalAgentEvidence{ContextRequests: 1}}, + {"event_stream bumps EventStreamRequests", config.ExternalAgentActivityEventStream, PulseIntelligenceExternalAgentEvidence{EventStreamRequests: 1}}, + {"provisioning bumps ProvisioningRequests", config.ExternalAgentActivityProvisioning, PulseIntelligenceExternalAgentEvidence{ProvisioningRequests: 1}}, + {"operator_state bumps OperatorStateRequests", config.ExternalAgentActivityOperatorState, PulseIntelligenceExternalAgentEvidence{OperatorStateRequests: 1}}, + {"finding_list bumps FindingRequests", config.ExternalAgentActivityFindingList, PulseIntelligenceExternalAgentEvidence{FindingRequests: 1}}, + {"finding_decision bumps FindingRequests", config.ExternalAgentActivityFindingDecision, PulseIntelligenceExternalAgentEvidence{FindingRequests: 1}}, + {"action_plan bumps ActionRequests", config.ExternalAgentActivityActionPlan, PulseIntelligenceExternalAgentEvidence{ActionRequests: 1}}, + {"action_decision bumps ActionRequests", config.ExternalAgentActivityActionDecision, PulseIntelligenceExternalAgentEvidence{ActionRequests: 1}}, + {"action_execute bumps ActionRequests", config.ExternalAgentActivityActionExecute, PulseIntelligenceExternalAgentEvidence{ActionRequests: 1}}, + {"whitespace wrapped activity still matches", " " + config.ExternalAgentActivityEventStream + " ", PulseIntelligenceExternalAgentEvidence{EventStreamRequests: 1}}, + {"unknown activity is a no-op", "nope", PulseIntelligenceExternalAgentEvidence{}}, + {"empty activity is a no-op", "", PulseIntelligenceExternalAgentEvidence{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var e PulseIntelligenceExternalAgentEvidence + e.ApplyActivity(tt.activity) + if e != tt.want { + t.Fatalf("ApplyActivity(%q) -> %+v, want %+v", tt.activity, e, tt.want) + } + }) + } + + t.Run("nil receiver does not panic", func(t *testing.T) { + var e *PulseIntelligenceExternalAgentEvidence + // Must not panic; the guard is the only behaviour observable here. + e.ApplyActivity(config.ExternalAgentActivityEventStream) + }) + + t.Run("repeated apply accumulates across buckets", func(t *testing.T) { + var e PulseIntelligenceExternalAgentEvidence + e.ApplyActivity(config.ExternalAgentActivityResourceContext) + e.ApplyActivity(config.ExternalAgentActivityFleetContext) + e.ApplyActivity(config.ExternalAgentActivityActionPlan) + e.ApplyActivity(config.ExternalAgentActivityActionExecute) + want := PulseIntelligenceExternalAgentEvidence{ContextRequests: 2, ActionRequests: 2} + if e != want { + t.Fatalf("accumulated evidence = %+v, want %+v", e, want) + } + }) +} + +func TestPulseIntelligenceExternalAgentEvidenceFromHistory(t *testing.T) { + before := fixedCutoff.Add(-24 * time.Hour) + atCutoff := fixedCutoff + after := fixedCutoff.Add(24 * time.Hour) + + tests := []struct { + name string + history *config.ExternalAgentActivityHistoryData + want PulseIntelligenceExternalAgentEvidence + }{ + { + name: "nil history returns zero evidence", + history: nil, + want: PulseIntelligenceExternalAgentEvidence{}, + }, + { + name: "empty events returns zero evidence", + history: &config.ExternalAgentActivityHistoryData{}, + want: PulseIntelligenceExternalAgentEvidence{}, + }, + { + name: "event before cutoff is excluded", + history: &config.ExternalAgentActivityHistoryData{ + Events: []config.ExternalAgentActivityRecord{ + {Timestamp: before, Surface: config.ExternalAgentActivitySurfaceAgentAPI, Activity: config.ExternalAgentActivityEventStream}, + }, + }, + want: PulseIntelligenceExternalAgentEvidence{}, + }, + { + name: "zero timestamp event is excluded", + history: &config.ExternalAgentActivityHistoryData{ + Events: []config.ExternalAgentActivityRecord{ + {Timestamp: time.Time{}, Surface: config.ExternalAgentActivitySurfaceAgentAPI, Activity: config.ExternalAgentActivityEventStream}, + }, + }, + want: PulseIntelligenceExternalAgentEvidence{}, + }, + { + name: "unknown surface is filtered (Used stays false)", + history: &config.ExternalAgentActivityHistoryData{ + Events: []config.ExternalAgentActivityRecord{ + {Timestamp: after, Surface: "webhook", Activity: config.ExternalAgentActivityEventStream}, + }, + }, + want: PulseIntelligenceExternalAgentEvidence{}, + }, + { + name: "agent_api surface marks Used but not MCPAdapterUsed", + history: &config.ExternalAgentActivityHistoryData{ + Events: []config.ExternalAgentActivityRecord{ + {Timestamp: after, Surface: config.ExternalAgentActivitySurfaceAgentAPI, Activity: config.ExternalAgentActivityEventStream}, + }, + }, + want: PulseIntelligenceExternalAgentEvidence{Used: true, EventStreamRequests: 1}, + }, + { + name: "pulse_mcp surface marks Used and MCPAdapterUsed", + history: &config.ExternalAgentActivityHistoryData{ + Events: []config.ExternalAgentActivityRecord{ + {Timestamp: after, Surface: config.ExternalAgentActivitySurfacePulseMCP, Activity: config.ExternalAgentActivityResourceContext}, + }, + }, + want: PulseIntelligenceExternalAgentEvidence{Used: true, MCPAdapterUsed: true, ContextRequests: 1}, + }, + { + name: "at-cutoff event is included (Before is strict)", + history: &config.ExternalAgentActivityHistoryData{ + Events: []config.ExternalAgentActivityRecord{ + {Timestamp: atCutoff, Surface: config.ExternalAgentActivitySurfaceAgentAPI, Activity: config.ExternalAgentActivityActionPlan}, + }, + }, + want: PulseIntelligenceExternalAgentEvidence{Used: true, ActionRequests: 1}, + }, + { + name: "mixed events: before excluded, unknown surface filtered, multiple buckets accumulate", + history: &config.ExternalAgentActivityHistoryData{ + Events: []config.ExternalAgentActivityRecord{ + {Timestamp: before, Surface: config.ExternalAgentActivitySurfacePulseMCP, Activity: config.ExternalAgentActivityResourceContext}, + {Timestamp: after, Surface: "intranet", Activity: config.ExternalAgentActivityEventStream}, + {Timestamp: after, Surface: config.ExternalAgentActivitySurfaceAgentAPI, Activity: config.ExternalAgentActivityFindingDecision}, + {Timestamp: after, Surface: config.ExternalAgentActivitySurfacePulseMCP, Activity: config.ExternalAgentActivityActionExecute}, + {Timestamp: after, Surface: config.ExternalAgentActivitySurfacePulseMCP, Activity: config.ExternalAgentActivityOperatorState}, + }, + }, + want: PulseIntelligenceExternalAgentEvidence{ + Used: true, + MCPAdapterUsed: true, + FindingRequests: 1, + ActionRequests: 1, + OperatorStateRequests: 1, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := PulseIntelligenceExternalAgentEvidenceFromHistory(tt.history, fixedCutoff) + if got != tt.want { + t.Fatalf("PulseIntelligenceExternalAgentEvidenceFromHistory() = %+v, want %+v", got, tt.want) + } + }) + } +} diff --git a/internal/truenas/client_branchcov0718_test.go b/internal/truenas/client_branchcov0718_test.go new file mode 100644 index 000000000..657ac8da9 --- /dev/null +++ b/internal/truenas/client_branchcov0718_test.go @@ -0,0 +1,432 @@ +package truenas + +import ( + "fmt" + "net" + "testing" + "time" +) + +// timeoutError is a minimal net.Error implementation used to drive the +// Timeout()/Temporary() branches of isTimeoutError deterministically. +type timeoutError struct { + timeout bool + temporary bool + msg string +} + +func (e *timeoutError) Error() string { return e.msg } +func (e *timeoutError) Timeout() bool { return e.timeout } +func (e *timeoutError) Temporary() bool { + return e.temporary +} + +// wrappedError chains a timeout error so we can verify errors.As walks the +// chain inside isTimeoutError. +type wrappedError struct { + inner error +} + +func (e *wrappedError) Error() string { return "wrapped: " + e.inner.Error() } +func (e *wrappedError) Unwrap() error { return e.inner } + +func TestIsTimeoutErrorCoversBranches(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "nil error returns false", err: nil, want: false}, + {name: "plain non-network error returns false", err: fmt.Errorf("disk full"), want: false}, + {name: "network error with timeout flag returns true", err: &timeoutError{timeout: true, temporary: true, msg: "i/o timeout"}, want: true}, + {name: "network error without timeout flag returns false", err: &timeoutError{timeout: false, temporary: true, msg: "connection reset"}, want: false}, + {name: "wrapped network timeout still detected via errors.As", err: &wrappedError{inner: &timeoutError{timeout: true, msg: "deadline exceeded"}}, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isTimeoutError(tt.err) + if got != tt.want { + t.Fatalf("isTimeoutError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +func TestSplitSnapshotNameCoversShapes(t *testing.T) { + tests := []struct { + name string + full string + wantDataset string + wantSnap string + }{ + {name: "empty string", full: "", wantDataset: "", wantSnap: ""}, + {name: "whitespace only collapses to empty", full: " ", wantDataset: "", wantSnap: ""}, + {name: "no at sign", full: "tank/media", wantDataset: "", wantSnap: ""}, + {name: "simple dataset at snapshot", full: "tank@auto-2024-01-01", wantDataset: "tank", wantSnap: "auto-2024-01-01"}, + {name: "nested dataset at snapshot", full: "tank/media/movies@manual-1", wantDataset: "tank/media/movies", wantSnap: "manual-1"}, + {name: "only at sign returns empty parts", full: "@", wantDataset: "", wantSnap: ""}, + {name: "first at sign wins when multiple present", full: "tank@snap@extra", wantDataset: "tank", wantSnap: "snap@extra"}, + {name: "leading and trailing whitespace is trimmed", full: " tank/media @ snap ", wantDataset: "tank/media", wantSnap: "snap"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dataset, snap := splitSnapshotName(tt.full) + if dataset != tt.wantDataset || snap != tt.wantSnap { + t.Fatalf("splitSnapshotName(%q) = (%q, %q), want (%q, %q)", tt.full, dataset, snap, tt.wantDataset, tt.wantSnap) + } + }) + } +} + +func TestDatasetFromSharePathCoversShapes(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + {name: "empty path", path: "", want: ""}, + {name: "whitespace only", path: " ", want: ""}, + {name: "EXTERNAL sentinel rejected case-insensitively", path: "external", want: ""}, + {name: "EXTERNAL uppercase rejected", path: "EXTERNAL", want: ""}, + {name: "bare mnt prefix only", path: "/mnt/", want: ""}, + {name: "pool only under mnt", path: "/mnt/tank", want: "tank"}, + {name: "pool and dataset under mnt", path: "/mnt/tank/media", want: "tank/media"}, + {name: "deep path truncated to pool and dataset", path: "/mnt/tank/media/movies/action", want: "tank/media"}, + {name: "trailing slashes trimmed", path: "/mnt/tank/media/", want: "tank/media"}, + {name: "no mnt prefix keeps raw value", path: "tank/apps", want: "tank/apps"}, + {name: "surrounding whitespace trimmed", path: " /mnt/tank/apps ", want: "tank/apps"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := datasetFromSharePath(tt.path) + if got != tt.want { + t.Fatalf("datasetFromSharePath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestPoolFromSharePathCoversShapes(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + {name: "empty path returns empty pool", path: "", want: ""}, + {name: "mnt only returns empty pool", path: "/mnt/", want: ""}, + {name: "EXTERNAL sentinel returns empty pool", path: "EXTERNAL", want: ""}, + {name: "pool only yields pool", path: "/mnt/tank", want: "tank"}, + {name: "pool and dataset yields pool only", path: "/mnt/tank/media", want: "tank"}, + {name: "deep path still yields root pool", path: "/mnt/tank/media/movies", want: "tank"}, + {name: "dataset path without slash echoes back as pool", path: "/mnt/tank", want: "tank"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := poolFromSharePath(tt.path) + if got != tt.want { + t.Fatalf("poolFromSharePath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestExtractFirstReportingFloatValueCoversShapes(t *testing.T) { + tests := []struct { + name string + raw any + wantValue float64 + wantOK bool + }{ + {name: "nil returns false", raw: nil, wantValue: 0, wantOK: false}, + {name: "empty slice returns false", raw: []any{}, wantValue: 0, wantOK: false}, + {name: "slice of non-numeric strings returns false", raw: []any{"abc", "def"}, wantValue: 0, wantOK: false}, + {name: "slice with first float64 wins", raw: []any{float64(1.5), float64(2.5)}, wantValue: 1.5, wantOK: true}, + {name: "slice skips non-numeric leading entries", raw: []any{"junk", float64(7.25)}, wantValue: 7.25, wantOK: true}, + {name: "nested slice recurses to first float", raw: []any{[]any{"x", float64(9.5)}}, wantValue: 9.5, wantOK: true}, + {name: "deeply nested slice recurses to first float", raw: []any{[]any{[]any{float64(42.0)}}}, wantValue: 42.0, wantOK: true}, + {name: "empty map returns false", raw: map[string]any{}, wantValue: 0, wantOK: false}, + {name: "single-entry map returns that value", raw: map[string]any{"cpu": float64(3.14)}, wantValue: 3.14, wantOK: true}, + {name: "map recurses into nested map for value", raw: map[string]any{"agg": map[string]any{"mean": float64(11.0)}}, wantValue: 11.0, wantOK: true}, + {name: "bare float64 default branch returns value", raw: float64(2.5), wantValue: 2.5, wantOK: true}, + {name: "bare int default branch returns value as float", raw: int(8), wantValue: 8.0, wantOK: true}, + {name: "numeric string default branch parses", raw: "12.5", wantValue: 12.5, wantOK: true}, + {name: "non-numeric string default branch returns false", raw: "not-a-number", wantValue: 0, wantOK: false}, + {name: "non-numeric unhandled type returns false", raw: struct{ X int }{X: 1}, wantValue: 0, wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotValue, gotOK := extractFirstReportingFloatValue(tt.raw) + if gotOK != tt.wantOK { + t.Fatalf("extractFirstReportingFloatValue(%v) ok = %v, want %v", tt.raw, gotOK, tt.wantOK) + } + if gotOK && gotValue != tt.wantValue { + t.Fatalf("extractFirstReportingFloatValue(%v) = %v, want %v", tt.raw, gotValue, tt.wantValue) + } + }) + } +} + +func TestMergeSystemTelemetryCoversAllBranches(t *testing.T) { + t.Run("nil system is a no-op", func(t *testing.T) { + telemetry := &SystemInfo{CPUCount: 8, CPUPercent: 12.5} + mergeSystemTelemetry(nil, telemetry) + }) + + t.Run("nil telemetry is a no-op preserving system values", func(t *testing.T) { + system := &SystemInfo{Hostname: "nas", CPUCount: 4, MemoryTotalBytes: 1024, CPUPercent: 50} + mergeSystemTelemetry(system, nil) + if system.CPUCount != 4 || system.MemoryTotalBytes != 1024 || system.CPUPercent != 50 { + t.Fatalf("mergeSystemTelemetry with nil telemetry mutated system: %+v", system) + } + }) + + t.Run("both nil pointers is a no-op", func(t *testing.T) { + mergeSystemTelemetry(nil, nil) + }) + + t.Run("positive telemetry fields override system fields", func(t *testing.T) { + system := &SystemInfo{CPUCount: 4, MemoryTotalBytes: 1024, MemoryAvailableBytes: 512} + telemetry := &SystemInfo{ + CPUCount: 16, + MemoryTotalBytes: 8192, + MemoryAvailableBytes: 4096, + } + mergeSystemTelemetry(system, telemetry) + if system.CPUCount != 16 { + t.Fatalf("CPUCount = %d, want 16", system.CPUCount) + } + if system.MemoryTotalBytes != 8192 { + t.Fatalf("MemoryTotalBytes = %d, want 8192", system.MemoryTotalBytes) + } + if system.MemoryAvailableBytes != 4096 { + t.Fatalf("MemoryAvailableBytes = %d, want 4096", system.MemoryAvailableBytes) + } + }) + + t.Run("zero or empty telemetry fields do not override system fields", func(t *testing.T) { + system := &SystemInfo{CPUCount: 4, MemoryTotalBytes: 1024, MemoryAvailableBytes: 512, IntervalSeconds: 5} + telemetry := &SystemInfo{ + CPUCount: 0, + MemoryTotalBytes: 0, + MemoryAvailableBytes: 0, + IntervalSeconds: 0, + } + mergeSystemTelemetry(system, telemetry) + if system.CPUCount != 4 { + t.Fatalf("CPUCount = %d, want preserved 4", system.CPUCount) + } + if system.MemoryTotalBytes != 1024 { + t.Fatalf("MemoryTotalBytes = %d, want preserved 1024", system.MemoryTotalBytes) + } + if system.MemoryAvailableBytes != 512 { + t.Fatalf("MemoryAvailableBytes = %d, want preserved 512", system.MemoryAvailableBytes) + } + if system.IntervalSeconds != 5 { + t.Fatalf("IntervalSeconds = %d, want preserved 5", system.IntervalSeconds) + } + }) + + t.Run("rate fields are always copied regardless of zero", func(t *testing.T) { + system := &SystemInfo{CPUPercent: 90, NetInRate: 1, NetOutRate: 2, DiskReadRate: 3, DiskWriteRate: 4} + telemetry := &SystemInfo{CPUPercent: 0, NetInRate: 0, NetOutRate: 0, DiskReadRate: 0, DiskWriteRate: 0} + mergeSystemTelemetry(system, telemetry) + if system.CPUPercent != 0 || system.NetInRate != 0 || system.NetOutRate != 0 || system.DiskReadRate != 0 || system.DiskWriteRate != 0 { + t.Fatalf("rate fields not overwritten with zero: %+v", system) + } + }) + + t.Run("positive telemetry rate fields are copied through", func(t *testing.T) { + system := &SystemInfo{} + telemetry := &SystemInfo{CPUPercent: 42.5, NetInRate: 100, NetOutRate: 200, DiskReadRate: 300, DiskWriteRate: 400} + mergeSystemTelemetry(system, telemetry) + if system.CPUPercent != 42.5 { + t.Fatalf("CPUPercent = %v, want 42.5", system.CPUPercent) + } + if system.NetInRate != 100 || system.NetOutRate != 200 { + t.Fatalf("Net rates = (%v, %v), want (100, 200)", system.NetInRate, system.NetOutRate) + } + if system.DiskReadRate != 300 || system.DiskWriteRate != 400 { + t.Fatalf("Disk rates = (%v, %v), want (300, 400)", system.DiskReadRate, system.DiskWriteRate) + } + }) + + t.Run("non-empty temperature map is cloned into system", func(t *testing.T) { + system := &SystemInfo{} + temps := map[string]float64{"cpu_package": 55.0, "core_0": 52.0} + telemetry := &SystemInfo{TemperatureCelsius: temps} + mergeSystemTelemetry(system, telemetry) + if system.TemperatureCelsius == nil { + t.Fatalf("expected cloned temperature map, got nil") + } + if len(system.TemperatureCelsius) != 2 { + t.Fatalf("expected 2 temperature entries, got %d", len(system.TemperatureCelsius)) + } + if v, ok := system.TemperatureCelsius["cpu_package"]; !ok || v != 55.0 { + t.Fatalf("cpu_package entry = (%v, %v), want present 55.0", v, ok) + } + system.TemperatureCelsius["cpu_package"] = 999 + if temps["cpu_package"] == 999 { + t.Fatal("expected system temperature map to be a clone, not the same reference") + } + }) + + t.Run("empty temperature map is not copied", func(t *testing.T) { + system := &SystemInfo{TemperatureCelsius: map[string]float64{"existing": 1.0}} + telemetry := &SystemInfo{TemperatureCelsius: map[string]float64{}} + mergeSystemTelemetry(system, telemetry) + if _, ok := system.TemperatureCelsius["existing"]; !ok { + t.Fatalf("expected existing temperature map to be preserved, got %+v", system.TemperatureCelsius) + } + }) + + t.Run("non-zero collected_at is copied", func(t *testing.T) { + system := &SystemInfo{} + now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + telemetry := &SystemInfo{CollectedAt: now} + mergeSystemTelemetry(system, telemetry) + if !system.CollectedAt.Equal(now) { + t.Fatalf("CollectedAt = %v, want %v", system.CollectedAt, now) + } + }) + + t.Run("zero collected_at is not copied", func(t *testing.T) { + original := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + system := &SystemInfo{CollectedAt: original} + telemetry := &SystemInfo{CollectedAt: time.Time{}} + mergeSystemTelemetry(system, telemetry) + if !system.CollectedAt.Equal(original) { + t.Fatalf("CollectedAt = %v, want preserved %v", system.CollectedAt, original) + } + }) + + t.Run("positive interval seconds is copied", func(t *testing.T) { + system := &SystemInfo{IntervalSeconds: 0} + telemetry := &SystemInfo{IntervalSeconds: 2} + mergeSystemTelemetry(system, telemetry) + if system.IntervalSeconds != 2 { + t.Fatalf("IntervalSeconds = %d, want 2", system.IntervalSeconds) + } + }) +} + +func TestAppImagesFromContainersCoversShapes(t *testing.T) { + t.Run("nil containers returns nil", func(t *testing.T) { + if got := appImagesFromContainers(nil); got != nil { + t.Fatalf("appImagesFromContainers(nil) = %#v, want nil", got) + } + }) + + t.Run("empty containers returns nil", func(t *testing.T) { + if got := appImagesFromContainers([]AppContainer{}); got != nil { + t.Fatalf("appImagesFromContainers([]) = %#v, want nil", got) + } + }) + + t.Run("containers with blank images are skipped", func(t *testing.T) { + containers := []AppContainer{ + {ID: "a", Image: ""}, + {ID: "b", Image: " "}, + } + got := appImagesFromContainers(containers) + if len(got) != 0 { + t.Fatalf("appImagesFromContainers() = %#v, want empty slice", got) + } + }) + + t.Run("images are gathered preserving order and trimming whitespace", func(t *testing.T) { + containers := []AppContainer{ + {ID: "a", Image: " alpine:3.20 "}, + {ID: "b", Image: ""}, + {ID: "c", Image: "nginx:latest"}, + } + got := appImagesFromContainers(containers) + want := []string{"alpine:3.20", "nginx:latest"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("appImagesFromContainers() = %#v, want %#v", got, want) + } + }) + + t.Run("duplicate images are preserved at this layer", func(t *testing.T) { + containers := []AppContainer{ + {ID: "a", Image: "redis:7"}, + {ID: "b", Image: "redis:7"}, + } + got := appImagesFromContainers(containers) + if len(got) != 2 || got[0] != "redis:7" || got[1] != "redis:7" { + t.Fatalf("appImagesFromContainers() = %#v, want two redis:7 entries", got) + } + }) +} + +func TestAppVolumesFromContainersCoversShapes(t *testing.T) { + t.Run("nil containers returns nil", func(t *testing.T) { + if got := appVolumesFromContainers(nil); got != nil { + t.Fatalf("appVolumesFromContainers(nil) = %#v, want nil", got) + } + }) + + t.Run("empty containers returns nil", func(t *testing.T) { + if got := appVolumesFromContainers([]AppContainer{}); got != nil { + t.Fatalf("appVolumesFromContainers([]) = %#v, want nil", got) + } + }) + + t.Run("containers with no volume mounts produce empty result", func(t *testing.T) { + containers := []AppContainer{ + {ID: "a"}, + {ID: "b"}, + } + got := appVolumesFromContainers(containers) + if len(got) != 0 { + t.Fatalf("appVolumesFromContainers() = %#v, want empty", got) + } + }) + + t.Run("volumes from multiple containers are concatenated in order", func(t *testing.T) { + containers := []AppContainer{ + {ID: "a", VolumeMounts: []AppVolume{{Source: "/host/a", Destination: "/data/a", Mode: "rw", Type: "bind"}}}, + {ID: "b", VolumeMounts: []AppVolume{ + {Source: "/host/b1", Destination: "/data/b1"}, + {Source: "/host/b2", Destination: "/data/b2"}, + }}, + } + got := appVolumesFromContainers(containers) + if len(got) != 3 { + t.Fatalf("appVolumesFromContainers() len = %d, want 3 (%#v)", len(got), got) + } + if got[0].Source != "/host/a" || got[1].Source != "/host/b1" || got[2].Source != "/host/b2" { + t.Fatalf("appVolumesFromContainers() = %#v, want sources in container order", got) + } + }) + + t.Run("duplicate volumes across containers are NOT deduped at this layer", func(t *testing.T) { + // This deliberately documents the function's actual behaviour: dedup is + // the caller's responsibility (see dedupeAppVolumes, invoked from + // parseAppsWithStats). Asserting it here keeps the test honest and + // guards against an accidental behaviour change. + dupe := AppVolume{Source: "/host/shared", Destination: "/data/shared", Mode: "rw", Type: "bind"} + containers := []AppContainer{ + {ID: "a", VolumeMounts: []AppVolume{dupe}}, + {ID: "b", VolumeMounts: []AppVolume{dupe}}, + } + got := appVolumesFromContainers(containers) + if len(got) != 2 { + t.Fatalf("appVolumesFromContainers() len = %d, want 2 (no dedup at this layer)", len(got)) + } + if got[0] != dupe || got[1] != dupe { + t.Fatalf("appVolumesFromContainers() = %#v, want both volumes preserved", got) + } + }) +} + +// Compile-time guards: ensure the test helpers we built keep satisfying the +// interfaces the production code targets via errors.As / type switches. +var ( + _ net.Error = (*timeoutError)(nil) + _ error = (*wrappedError)(nil) +) diff --git a/internal/vmware/client_signals_branchcov0718_test.go b/internal/vmware/client_signals_branchcov0718_test.go new file mode 100644 index 000000000..7e8881449 --- /dev/null +++ b/internal/vmware/client_signals_branchcov0718_test.go @@ -0,0 +1,403 @@ +package vmware + +import ( + "crypto/x509" + "errors" + "strings" + "testing" + "time" +) + +// silentX509Wrap wraps an *x509.UnknownAuthorityError but reports a +// keyword-free Error() string so the substring check in +// classifyTransportError is bypassed and the errors.As branch is the +// only path that can classify the wrapped error as TLS. +type silentX509Wrap struct { + wrapped error +} + +func (w *silentX509Wrap) Error() string { return "innocuous transport failure" } +func (w *silentX509Wrap) Unwrap() error { return w.wrapped } + +// fakeNetError implements the net.Error interface (Timeout/Temporary) without +// embedding x509/tls/certificate keywords in its message. +type fakeNetError struct{ msg string } + +func (e *fakeNetError) Error() string { return e.msg } +func (e *fakeNetError) Timeout() bool { return false } +func (e *fakeNetError) Temporary() bool { return false } + +func TestInventoryAlarmSortTime(t *testing.T) { + pst := time.FixedZone("PST", -8*3600) + // 10:30 PST == 18:30 UTC. Using a non-UTC input proves the function + // normalises to UTC rather than returning the raw stored value. + nonUTC := time.Date(2026, time.January, 15, 10, 30, 0, 0, pst) + wantUTC := time.Date(2026, time.January, 15, 18, 30, 0, 0, time.UTC) + + cases := []struct { + name string + alarm InventoryAlarm + want time.Time + }{ + { + name: "zero triggered_at returns zero time", + alarm: InventoryAlarm{}, + want: time.Time{}, + }, + { + name: "non-zero triggered_at returns same instant in UTC", + alarm: InventoryAlarm{TriggeredAt: nonUTC}, + want: wantUTC, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := inventoryAlarmSortTime(tc.alarm) + if tc.want.IsZero() { + if !got.IsZero() { + t.Fatalf("inventoryAlarmSortTime = %v, want zero time", got) + } + return + } + if !got.Equal(tc.want) { + t.Fatalf("inventoryAlarmSortTime = %v, want instant equal to %v", got, tc.want) + } + if got.Location() != time.UTC { + t.Fatalf("inventoryAlarmSortTime location = %v, want UTC", got.Location()) + } + }) + } +} + +func TestInventoryTaskSortTime(t *testing.T) { + pst := time.FixedZone("PST", -8*3600) + started := time.Date(2026, time.February, 1, 9, 0, 0, 0, pst) // 17:00 UTC + completed := time.Date(2026, time.February, 1, 11, 0, 0, 0, pst) // 19:00 UTC + wantStartedUTC := time.Date(2026, time.February, 1, 17, 0, 0, 0, time.UTC) + wantCompletedUTC := time.Date(2026, time.February, 1, 19, 0, 0, 0, time.UTC) + + cases := []struct { + name string + task InventoryTask + want time.Time + }{ + { + name: "no times returns zero", + task: InventoryTask{}, + want: time.Time{}, + }, + { + name: "only completed returns completed utc", + task: InventoryTask{CompletedAt: completed}, + want: wantCompletedUTC, + }, + { + name: "only started returns started utc", + task: InventoryTask{StartedAt: started}, + want: wantStartedUTC, + }, + { + name: "started takes precedence over completed", + task: InventoryTask{StartedAt: started, CompletedAt: completed}, + want: wantStartedUTC, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := inventoryTaskSortTime(tc.task) + if tc.want.IsZero() { + if !got.IsZero() { + t.Fatalf("inventoryTaskSortTime = %v, want zero time", got) + } + return + } + if !got.Equal(tc.want) { + t.Fatalf("inventoryTaskSortTime = %v, want instant equal to %v", got, tc.want) + } + if got.Location() != time.UTC { + t.Fatalf("inventoryTaskSortTime location = %v, want UTC", got.Location()) + } + }) + } +} + +func TestInventoryEventSortTime(t *testing.T) { + pst := time.FixedZone("PST", -8*3600) + nonUTC := time.Date(2026, time.March, 12, 5, 15, 0, 0, pst) // 13:15 UTC + wantUTC := time.Date(2026, time.March, 12, 13, 15, 0, 0, time.UTC) + + cases := []struct { + name string + event InventoryEvent + want time.Time + }{ + { + name: "zero created_at returns zero time", + event: InventoryEvent{}, + want: time.Time{}, + }, + { + name: "non-zero created_at returns same instant in UTC", + event: InventoryEvent{CreatedAt: nonUTC}, + want: wantUTC, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := inventoryEventSortTime(tc.event) + if tc.want.IsZero() { + if !got.IsZero() { + t.Fatalf("inventoryEventSortTime = %v, want zero time", got) + } + return + } + if !got.Equal(tc.want) { + t.Fatalf("inventoryEventSortTime = %v, want instant equal to %v", got, tc.want) + } + if got.Location() != time.UTC { + t.Fatalf("inventoryEventSortTime location = %v, want UTC", got.Location()) + } + }) + } +} + +func TestIsAutomationNotFound(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {name: "nil error returns false", err: nil, want: false}, + {name: "non-connection error returns false", err: errors.New("boom"), want: false}, + {name: "not_found category returns true", err: &ConnectionError{Category: "not_found", Message: "missing"}, want: true}, + {name: "unavailable category returns false", err: &ConnectionError{Category: "unavailable", Message: "busy"}, want: false}, + {name: "endpoint category returns false", err: &ConnectionError{Category: "endpoint", Message: "boom"}, want: false}, + {name: "empty category returns false", err: &ConnectionError{}, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isAutomationNotFound(tc.err); got != tc.want { + t.Fatalf("isAutomationNotFound(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +func TestIsAutomationUnavailable(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {name: "nil error returns false", err: nil, want: false}, + {name: "non-connection error returns false", err: errors.New("boom"), want: false}, + {name: "unavailable category returns true", err: &ConnectionError{Category: "unavailable", Message: "busy"}, want: true}, + {name: "not_found category returns false", err: &ConnectionError{Category: "not_found", Message: "missing"}, want: false}, + {name: "endpoint category returns false", err: &ConnectionError{Category: "endpoint", Message: "boom"}, want: false}, + {name: "empty category returns false", err: &ConnectionError{}, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isAutomationUnavailable(tc.err); got != tc.want { + t.Fatalf("isAutomationUnavailable(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +func TestClassifyTransportError(t *testing.T) { + cases := []struct { + name string + stage string + err error + wantCategory string + wantMsgContains string + }{ + { + name: "nil error returns nil", + stage: "automation session", + err: nil, + wantCategory: "", + wantMsgContains: "", + }, + { + name: "x509 substring classifies as tls", + stage: "automation session", + err: errors.New("Get https://vc/sdk: x509: certificate signed by unknown authority"), + wantCategory: "tls", + wantMsgContains: "VMware TLS validation failed during automation session", + }, + { + name: "certificate substring classifies as tls", + stage: "vi-json login", + err: errors.New("certificate verify failed"), + wantCategory: "tls", + wantMsgContains: "VMware TLS validation failed during vi-json login", + }, + { + name: "tls substring classifies as tls", + stage: "automation session", + err: errors.New("tls: handshake failure"), + wantCategory: "tls", + wantMsgContains: "VMware TLS validation failed during automation session", + }, + { + name: "wrapped unknown authority without keyword hits errors.As tls branch", + stage: "vi-json service content", + err: &silentX509Wrap{wrapped: &x509.UnknownAuthorityError{}}, + wantCategory: "tls", + wantMsgContains: "VMware TLS validation failed during vi-json service content", + }, + { + name: "net.Error classifies as network error", + stage: "automation session", + err: &fakeNetError{msg: "dial tcp: connection refused"}, + wantCategory: "network", + wantMsgContains: "VMware network error during automation session", + }, + { + name: "unclassified error falls through to connection failed", + stage: "label", + err: errors.New("unexpected EOF"), + wantCategory: "network", + wantMsgContains: "VMware connection failed during label", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := classifyTransportError(tc.stage, tc.err) + if tc.err == nil { + if got != nil { + t.Fatalf("classifyTransportError(%q, nil) = %v, want nil", tc.stage, got) + } + return + } + connErr, ok := got.(*ConnectionError) + if !ok { + t.Fatalf("classifyTransportError(%q, %v) = %T, want *ConnectionError", tc.stage, tc.err, got) + } + if connErr.Category != tc.wantCategory { + t.Fatalf("classifyTransportError category = %q, want %q (msg=%q)", connErr.Category, tc.wantCategory, connErr.Message) + } + if !strings.Contains(connErr.Message, tc.wantMsgContains) { + t.Fatalf("classifyTransportError message = %q, want substring %q", connErr.Message, tc.wantMsgContains) + } + }) + } +} + +func TestVMwareSortKey(t *testing.T) { + cases := []struct { + name string + id string + entityName string + want string + }{ + {name: "both populated returns lowercased trimmed id", id: "VM-1", entityName: "vm one", want: "vm-1"}, + {name: "id wins over name regardless of name case", id: "Host-X", entityName: "lower-priority", want: "host-x"}, + {name: "empty id falls back to lowercased trimmed name", id: "", entityName: "VM Two", want: "vm two"}, + {name: "whitespace-only id falls back to name", id: " ", entityName: "Spaces", want: "spaces"}, + {name: "empty name returns lowercased trimmed id", id: "vm-1", entityName: "", want: "vm-1"}, + {name: "both empty returns empty", id: "", entityName: "", want: ""}, + {name: "id is trimmed and lowercased", id: " Host-X ", entityName: "ignored", want: "host-x"}, + {name: "name is trimmed and lowercased when id empty", id: "", entityName: " Host-Y ", want: "host-y"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := vmwareSortKey(tc.id, tc.entityName); got != tc.want { + t.Fatalf("vmwareSortKey(%q, %q) = %q, want %q", tc.id, tc.entityName, got, tc.want) + } + }) + } +} + +func TestSortInventorySnapshot(t *testing.T) { + t.Run("nil snapshot is a no-op", func(t *testing.T) { + // Should not panic on a nil receiver-by-argument. + sortInventorySnapshot(nil) + }) + + t.Run("empty snapshot is a no-op", func(t *testing.T) { + sortInventorySnapshot(&InventorySnapshot{}) + }) + + t.Run("each slice sorted ascending by vmwareSortKey", func(t *testing.T) { + // Each slice is presented in reverse-sorted (z-first, a-second) + // order so that an in-place sort produces a distinct, checkable + // permutation. IDs are populated so the sort key is lowercased id. + snapshot := &InventorySnapshot{ + Hosts: []InventoryHost{ + {Host: "host-z", Name: "Z Host"}, + {Host: "host-a", Name: "A Host"}, + }, + VMs: []InventoryVM{ + {VM: "vm-z", Name: "Z VM"}, + {VM: "vm-a", Name: "A VM"}, + }, + Datastores: []InventoryDatastore{ + {Datastore: "ds-z", Name: "Z DS"}, + {Datastore: "ds-a", Name: "A DS"}, + }, + Clusters: []InventoryCluster{ + {Cluster: "domain-z", Name: "Z Cluster"}, + {Cluster: "domain-a", Name: "A Cluster"}, + }, + Networks: []InventoryNetwork{ + {Network: "net-z", Name: "Z Net"}, + {Network: "net-a", Name: "A Net"}, + }, + EnrichmentIssues: []InventoryEnrichmentIssue{ + {Stage: "topology", EntityType: "cluster", EntityID: "domain-z", Category: "unavailable", Message: "z msg"}, + {Stage: "signals", EntityType: "vm", EntityID: "vm-a", Category: "not_found", Message: "a msg"}, + }, + } + + sortInventorySnapshot(snapshot) + + if got := snapshot.Hosts[0].Host; got != "host-a" { + t.Fatalf("hosts not sorted ascending; first Host = %q, want %q", got, "host-a") + } + if got := snapshot.Hosts[1].Host; got != "host-z" { + t.Fatalf("hosts not sorted ascending; second Host = %q, want %q", got, "host-z") + } + if got := snapshot.VMs[0].VM; got != "vm-a" { + t.Fatalf("vms not sorted ascending; first VM = %q, want %q", got, "vm-a") + } + if got := snapshot.VMs[1].VM; got != "vm-z" { + t.Fatalf("vms not sorted ascending; second VM = %q, want %q", got, "vm-z") + } + if got := snapshot.Datastores[0].Datastore; got != "ds-a" { + t.Fatalf("datastores not sorted ascending; first Datastore = %q, want %q", got, "ds-a") + } + if got := snapshot.Datastores[1].Datastore; got != "ds-z" { + t.Fatalf("datastores not sorted ascending; second Datastore = %q, want %q", got, "ds-z") + } + if got := snapshot.Clusters[0].Cluster; got != "domain-a" { + t.Fatalf("clusters not sorted ascending; first Cluster = %q, want %q", got, "domain-a") + } + if got := snapshot.Clusters[1].Cluster; got != "domain-z" { + t.Fatalf("clusters not sorted ascending; second Cluster = %q, want %q", got, "domain-z") + } + if got := snapshot.Networks[0].Network; got != "net-a" { + t.Fatalf("networks not sorted ascending; first Network = %q, want %q", got, "net-a") + } + if got := snapshot.Networks[1].Network; got != "net-z" { + t.Fatalf("networks not sorted ascending; second Network = %q, want %q", got, "net-z") + } + // Enrichment issue key is "stage\x00entityType\x00..."; "signals" + // sorts before "topology". + if got := snapshot.EnrichmentIssues[0].Stage; got != "signals" { + t.Fatalf("enrichment issues not sorted ascending; first Stage = %q, want %q", got, "signals") + } + if got := snapshot.EnrichmentIssues[1].Stage; got != "topology" { + t.Fatalf("enrichment issues not sorted ascending; second Stage = %q, want %q", got, "topology") + } + + // Re-sorting an already-sorted snapshot must leave it stable. + before := snapshot.Hosts[0].Host + sortInventorySnapshot(snapshot) + if snapshot.Hosts[0].Host != before { + t.Fatalf("re-sort changed hosts order; first Host = %q, want %q", snapshot.Hosts[0].Host, before) + } + }) +} diff --git a/pkg/licensing/service_state_branchcov0718_test.go b/pkg/licensing/service_state_branchcov0718_test.go new file mode 100644 index 000000000..b02b24c50 --- /dev/null +++ b/pkg/licensing/service_state_branchcov0718_test.go @@ -0,0 +1,360 @@ +package licensing + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// overflowMockSource is an EntitlementSource whose OverflowGrantedAt can be +// configured. The sibling mockSource (evaluator_test.go) hard-wires +// OverflowGrantedAt to nil, so a dedicated mock is required to exercise the +// "value present" branch of (*Evaluator).OverflowGrantedAt. +type overflowMockSource struct { + overflow *int64 + subState SubscriptionState +} + +func (m overflowMockSource) Capabilities() []string { return nil } +func (m overflowMockSource) Limits() map[string]int64 { return nil } +func (m overflowMockSource) MetersEnabled() []string { return nil } +func (m overflowMockSource) PlanVersion() string { return "" } +func (m overflowMockSource) SubscriptionState() SubscriptionState { return m.subState } +func (m overflowMockSource) TrialStartedAt() *int64 { return nil } +func (m overflowMockSource) TrialEndsAt() *int64 { return nil } +func (m overflowMockSource) OverflowGrantedAt() *int64 { + if m.overflow == nil { + return nil + } + c := *m.overflow + return &c +} + +// TestEvaluatorOverflowGrantedAt0718 covers every branch of +// (*Evaluator).OverflowGrantedAt: nil receiver, non-nil receiver with a nil +// source, source returning nil, and source returning a value (which must be +// handed back as a defensive copy). +func TestEvaluatorOverflowGrantedAt0718(t *testing.T) { + ts := int64(1700000000) + + t.Run("nil_receiver_returns_nil", func(t *testing.T) { + var e *Evaluator + assert.Nil(t, e.OverflowGrantedAt()) + }) + + t.Run("nil_source_returns_nil", func(t *testing.T) { + // White-box construction: receiver non-nil, source nil -> second + // short-circuit arm. + e := &Evaluator{source: nil} + assert.Nil(t, e.OverflowGrantedAt()) + }) + + t.Run("source_returns_nil_propagates_nil", func(t *testing.T) { + e := NewEvaluator(overflowMockSource{overflow: nil}) + assert.Nil(t, e.OverflowGrantedAt()) + }) + + t.Run("source_returns_value_returns_distinct_copy", func(t *testing.T) { + e := NewEvaluator(overflowMockSource{overflow: &ts}) + got := e.OverflowGrantedAt() + require.NotNil(t, got) + assert.Equal(t, ts, *got) + // cloneInt64Ptr must produce a fresh pointer, not alias the input. + assert.NotSame(t, &ts, got) + }) +} + +// TestDatabaseSourceOverflowGrantedAt0718 covers both arms of +// (*DatabaseSource).OverflowGrantedAt. The active-subscription store path is +// used so no lease-token validation runs and the stored timestamp survives +// normalization; the nil and populated cases exercise the two cloneInt64Ptr +// outcomes. +func TestDatabaseSourceOverflowGrantedAt0718(t *testing.T) { + ts := int64(1700000000) + + t.Run("nil_when_state_has_no_timestamp", func(t *testing.T) { + store := &mockBillingStore{ + state: &BillingState{ + PlanVersion: "pro", + SubscriptionState: SubStateActive, + }, + } + source := NewDatabaseSource(store, "org-1", time.Hour) + assert.Nil(t, source.OverflowGrantedAt()) + }) + + t.Run("returns_value_when_state_has_timestamp", func(t *testing.T) { + store := &mockBillingStore{ + state: &BillingState{ + PlanVersion: "pro", + SubscriptionState: SubStateActive, + OverflowGrantedAt: &ts, + }, + } + source := NewDatabaseSource(store, "org-1", time.Hour) + got := source.OverflowGrantedAt() + require.NotNil(t, got) + assert.Equal(t, ts, *got) + // Must be a defensive copy, not the internal pointer. + assert.NotSame(t, &ts, got) + }) + + t.Run("returns_distinct_pointers_across_calls", func(t *testing.T) { + store := &mockBillingStore{ + state: &BillingState{ + PlanVersion: "pro", + SubscriptionState: SubStateActive, + OverflowGrantedAt: &ts, + }, + } + source := NewDatabaseSource(store, "org-1", time.Hour) + first := source.OverflowGrantedAt() + second := source.OverflowGrantedAt() + require.NotNil(t, first) + require.NotNil(t, second) + assert.Equal(t, ts, *first) + assert.Equal(t, ts, *second) + // Each call must yield its own copy (no shared aliasing). + assert.NotSame(t, first, second) + }) +} + +// TestServiceGetLicenseState0718 covers every return path of +// (*Service).GetLicenseState: the no-license/no-evaluator None path, every +// evaluator-driven arm (active/trial/grace/expired via suspended), and every +// JWT-driven arm (active/trial/grace/expired-past-grace/suspended-claim). +func TestServiceGetLicenseState0718(t *testing.T) { + // Defensive: ensure no env-var bypass influences the derivation. + t.Setenv("PULSE_DEV", "") + t.Setenv("PULSE_MOCK_MODE", "") + + t.Run("no_license_no_evaluator_returns_none", func(t *testing.T) { + s := NewService() + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateNone, state) + assert.Nil(t, lic) + }) + + // Hosted / evaluator-driven arm (s.license == nil && s.evaluator != nil). + t.Run("evaluator_active_returns_active_nil_license", func(t *testing.T) { + s := NewService() + s.SetEvaluator(NewEvaluator(mockSource{subState: SubStateActive})) + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateActive, state) + assert.Nil(t, lic) + }) + + t.Run("evaluator_trial_returns_active_nil_license", func(t *testing.T) { + s := NewService() + s.SetEvaluator(NewEvaluator(mockSource{subState: SubStateTrial})) + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateActive, state) + assert.Nil(t, lic) + }) + + t.Run("evaluator_grace_returns_grace_nil_license", func(t *testing.T) { + s := NewService() + s.SetEvaluator(NewEvaluator(mockSource{subState: SubStateGrace})) + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateGracePeriod, state) + assert.Nil(t, lic) + }) + + t.Run("evaluator_expired_returns_expired_nil_license", func(t *testing.T) { + s := NewService() + s.SetEvaluator(NewEvaluator(mockSource{subState: SubStateExpired})) + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateExpired, state) + assert.Nil(t, lic) + }) + + t.Run("evaluator_suspended_falls_through_default_to_expired", func(t *testing.T) { + s := NewService() + s.SetEvaluator(NewEvaluator(mockSource{subState: SubStateSuspended})) + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateExpired, state) + assert.Nil(t, lic) + }) + + // JWT-driven arm (s.license != nil) via currentJWTSubscriptionStateLocked. + t.Run("jwt_active_no_substate_returns_active_with_clone", func(t *testing.T) { + s := NewService() + original := &License{Claims: Claims{ + Tier: TierPro, + ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix(), + }} + s.SetCurrentForTesting(original) + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateActive, state) + require.NotNil(t, lic) + assert.Equal(t, TierPro, lic.Claims.Tier) + // Returned license must be a clone, not the internal pointer. + assert.NotSame(t, original, lic) + assert.Same(t, original, s.CurrentUnsafeForTesting(), "internal pointer must be unchanged") + }) + + t.Run("jwt_trial_claim_returns_active", func(t *testing.T) { + s := NewService() + s.SetCurrentForTesting(&License{Claims: Claims{ + Tier: TierPro, + ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix(), + SubState: SubStateTrial, + }}) + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateActive, state) + require.NotNil(t, lic) + }) + + t.Run("jwt_expired_within_grace_returns_grace", func(t *testing.T) { + s := NewService() + s.SetCurrentForTesting(&License{Claims: Claims{ + Tier: TierPro, + ExpiresAt: time.Now().Add(-24 * time.Hour).Unix(), // expired yesterday, within 7-day grace + }}) + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateGracePeriod, state) + require.NotNil(t, lic) + }) + + t.Run("jwt_expired_past_grace_returns_expired", func(t *testing.T) { + s := NewService() + s.SetCurrentForTesting(&License{Claims: Claims{ + Tier: TierPro, + ExpiresAt: time.Now().Add(-365 * 24 * time.Hour).Unix(), // long past grace + }}) + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateExpired, state) + require.NotNil(t, lic) + }) + + t.Run("jwt_suspended_claim_returns_expired", func(t *testing.T) { + s := NewService() + s.SetCurrentForTesting(&License{Claims: Claims{ + Tier: TierPro, + ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix(), + SubState: SubStateSuspended, + }}) + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateExpired, state) + require.NotNil(t, lic) + }) + + t.Run("jwt_canceled_claim_returns_expired", func(t *testing.T) { + s := NewService() + s.SetCurrentForTesting(&License{Claims: Claims{ + Tier: TierPro, + ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix(), + SubState: SubStateCanceled, + }}) + state, lic := s.GetLicenseState() + assert.Equal(t, LicenseStateExpired, state) + require.NotNil(t, lic) + }) +} + +// TestServiceGetLicenseStateString0718 covers both arms of the hasFeatures +// computation in (*Service).GetLicenseStateString: true for active/grace, +// false for none/expired. +func TestServiceGetLicenseStateString0718(t *testing.T) { + t.Setenv("PULSE_DEV", "") + t.Setenv("PULSE_MOCK_MODE", "") + + tests := []struct { + name string + setup func(*Service) + wantStr string + wantAvail bool + }{ + { + name: "none_returns_false", + setup: func(s *Service) {}, + wantStr: string(LicenseStateNone), + wantAvail: false, + }, + { + name: "active_returns_true", + setup: func(s *Service) { + s.SetEvaluator(NewEvaluator(mockSource{subState: SubStateActive})) + }, + wantStr: string(LicenseStateActive), + wantAvail: true, + }, + { + name: "grace_returns_true", + setup: func(s *Service) { + s.SetEvaluator(NewEvaluator(mockSource{subState: SubStateGrace})) + }, + wantStr: string(LicenseStateGracePeriod), + wantAvail: true, + }, + { + name: "expired_returns_false", + setup: func(s *Service) { + s.SetEvaluator(NewEvaluator(mockSource{subState: SubStateExpired})) + }, + wantStr: string(LicenseStateExpired), + wantAvail: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + s := NewService() + tt.setup(s) + gotStr, gotAvail := s.GetLicenseStateString() + assert.Equal(t, tt.wantStr, gotStr) + assert.Equal(t, tt.wantAvail, gotAvail) + }) + } +} + +// TestServiceRequireFeature0718 covers both arms of (*Service).RequireFeature: +// nil when the feature is genuinely entitled via an active license, and a +// wrapped ErrFeatureNotIncluded when it is not. RBAC is a Pro-only feature +// (not free), so it deterministically distinguishes the two arms. +func TestServiceRequireFeature0718(t *testing.T) { + // Force real entitlement evaluation: no dev/demo env bypass. + t.Setenv("PULSE_DEV", "") + t.Setenv("PULSE_MOCK_MODE", "") + t.Setenv("PULSE_LICENSE_DEV_MODE", "") + + // Precondition: RBAC is a Pro capability, absent from the free tier, so + // the only path to a nil error is an active license claim. + require.False(t, TierHasFeature(TierFree, FeatureRBAC), + "test fixture requires RBAC to be a non-free feature") + + t.Run("licensed_feature_returns_nil", func(t *testing.T) { + s := NewService() + s.SetCurrentForTesting(&License{Claims: Claims{ + Tier: TierPro, + ExpiresAt: 0, // lifetime -> IsExpired=false -> SubStateActive + Capabilities: []string{FeatureRBAC}, + }}) + assert.NoError(t, s.RequireFeature(FeatureRBAC)) + }) + + t.Run("unlicensed_feature_returns_ErrFeatureNotIncluded", func(t *testing.T) { + s := NewService() // no license, no evaluator -> free-tier fallback + + err := s.RequireFeature(FeatureRBAC) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrFeatureNotIncluded), + "want errors.Is ErrFeatureNotIncluded, got %v", err) + // Message format is " requires Pulse or above"; + // RBAC's min tier is Pro, so the suffix is a stable behavioral signal. + assert.Contains(t, err.Error(), "Pro or above", + "error message should name the required tier") + }) + + t.Run("free_tier_feature_returns_nil_without_license", func(t *testing.T) { + s := NewService() // no license, no evaluator + // FeatureAIPatrol is in the free tier, so it is granted even without + // any license, exercising the no-error arm via the free fallback. + require.True(t, TierHasFeature(TierFree, FeatureAIPatrol)) + assert.NoError(t, s.RequireFeature(FeatureAIPatrol)) + }) +} diff --git a/pkg/securityutil/outbound_http_branchcov0718_test.go b/pkg/securityutil/outbound_http_branchcov0718_test.go new file mode 100644 index 000000000..ec51fc2b0 --- /dev/null +++ b/pkg/securityutil/outbound_http_branchcov0718_test.go @@ -0,0 +1,313 @@ +package securityutil + +import ( + "context" + "net" + "net/url" + "strings" + "testing" +) + +// This file raises branch/function coverage for the pure and pre-DNS error +// paths in outbound_http.go and httpurl.go. It does not exercise any network, +// filesystem, or global-resolver state: every hostname target is either an +// IP literal (which resolvePermittedOutboundIP short-circuits without DNS) or +// a value rejected before DNS resolution runs. + +func TestBranchCovAllowedOutboundSchemes(t *testing.T) { + tests := []struct { + name string + opts RestrictedOutboundHTTPOptions + want []string + }{ + {name: "empty allowed schemes defaults to http https", opts: RestrictedOutboundHTTPOptions{}, want: []string{"http", "https"}}, + {name: "nil slice defaults to http https", opts: RestrictedOutboundHTTPOptions{AllowedSchemes: nil}, want: []string{"http", "https"}}, + {name: "explicit schemes returned verbatim", opts: RestrictedOutboundHTTPOptions{AllowedSchemes: []string{"https", "ftp"}}, want: []string{"https", "ftp"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := allowedOutboundSchemes(tt.opts) + if len(got) != len(tt.want) { + t.Fatalf("allowedOutboundSchemes() = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("allowedOutboundSchemes()[%d] = %q, want %q (full: %v)", i, got[i], tt.want[i], got) + } + } + }) + } +} + +func TestBranchCovIsAllowedOutboundScheme(t *testing.T) { + tests := []struct { + name string + scheme string + allow []string + want bool + }{ + {name: "exact match http", scheme: "http", allow: []string{"http", "https"}, want: true}, + {name: "match ignores surrounding whitespace in candidate", scheme: "https", allow: []string{" https ", "http"}, want: true}, + {name: "match is case insensitive on candidate", scheme: "HTTPS", allow: []string{"https", "http"}, want: true}, + {name: "no match when scheme absent", scheme: "ftp", allow: []string{"http", "https"}, want: false}, + {name: "no match against empty allow list", scheme: "http", allow: []string{}, want: false}, + {name: "exact candidate differs after trim", scheme: "http", allow: []string{"httpx"}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isAllowedOutboundScheme(tt.scheme, tt.allow); got != tt.want { + t.Fatalf("isAllowedOutboundScheme(%q, %v) = %v, want %v", tt.scheme, tt.allow, got, tt.want) + } + }) + } +} + +func TestBranchCovValidateOutboundIP(t *testing.T) { + allOpts := RestrictedOutboundHTTPOptions{AllowPrivateIPs: true, AllowLoopback: true} + strictOpts := RestrictedOutboundHTTPOptions{} + + tests := []struct { + name string + ip net.IP + opts RestrictedOutboundHTTPOptions + wantErr string // empty means expect nil + }{ + {name: "nil ip rejected", ip: net.IP(nil), opts: allOpts, wantErr: "invalid IP address"}, + {name: "loopback blocked when not allowed", ip: net.ParseIP("127.0.0.1"), opts: strictOpts, wantErr: "loopback addresses are not allowed"}, + {name: "loopback permitted when allowed", ip: net.ParseIP("127.0.0.1"), opts: allOpts, wantErr: ""}, + {name: "metadata service ip always rejected", ip: net.ParseIP("169.254.169.254"), opts: allOpts, wantErr: "metadata service address is not allowed"}, + {name: "link local unicast rejected", ip: net.ParseIP("169.254.10.20"), opts: allOpts, wantErr: "link-local addresses are not allowed"}, + {name: "link local multicast rejected as link local", ip: net.ParseIP("224.0.0.1"), opts: allOpts, wantErr: "link-local addresses are not allowed"}, + {name: "non-link-local multicast rejected", ip: net.ParseIP("239.0.0.1"), opts: allOpts, wantErr: "multicast addresses are not allowed"}, + {name: "unspecified ipv4 rejected", ip: net.ParseIP("0.0.0.0"), opts: allOpts, wantErr: "unspecified addresses are not allowed"}, + {name: "unspecified ipv6 rejected", ip: net.ParseIP("::"), opts: allOpts, wantErr: "unspecified addresses are not allowed"}, + {name: "private blocked when not allowed", ip: net.ParseIP("192.168.1.1"), opts: strictOpts, wantErr: "private addresses are not allowed"}, + {name: "private permitted when allowed", ip: net.ParseIP("10.0.0.1"), opts: allOpts, wantErr: ""}, + {name: "public documentation ip accepted", ip: net.ParseIP("203.0.113.1"), opts: strictOpts, wantErr: ""}, + {name: "public ipv6 accepted", ip: net.ParseIP("2001:db8::1"), opts: strictOpts, wantErr: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateOutboundIP(tt.ip, tt.opts) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("validateOutboundIP(%v, %+v) unexpected error: %v", tt.ip, tt.opts, err) + } + return + } + if err == nil { + t.Fatalf("validateOutboundIP(%v, %+v) = nil, want error containing %q", tt.ip, tt.opts, tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("validateOutboundIP(%v, %+v) err = %q, want substring %q", tt.ip, tt.opts, err.Error(), tt.wantErr) + } + }) + } +} + +func TestBranchCovCanonicalOriginHost(t *testing.T) { + tests := []struct { + name string + u func(t *testing.T) *url.URL // nil returns nil *url.URL + want string + }{ + {name: "nil url returns empty", u: func(t *testing.T) *url.URL { return nil }, want: ""}, + {name: "http default port 80", u: func(t *testing.T) *url.URL { return mustParseURL(t, "http://example.com/p") }, want: "example.com:80"}, + {name: "https default port 443", u: func(t *testing.T) *url.URL { return mustParseURL(t, "https://example.com/p") }, want: "example.com:443"}, + {name: "host uppercased normalized", u: func(t *testing.T) *url.URL { return mustParseURL(t, "http://EXAMPLE.com/p") }, want: "example.com:80"}, + {name: "explicit port preserved", u: func(t *testing.T) *url.URL { return mustParseURL(t, "https://example.com:8443/p") }, want: "example.com:8443"}, + {name: "ipv6 default http port", u: func(t *testing.T) *url.URL { return mustParseURL(t, "http://[::1]/p") }, want: "[::1]:80"}, + {name: "ipv6 explicit port", u: func(t *testing.T) *url.URL { return mustParseURL(t, "http://[::1]:9000/p") }, want: "[::1]:9000"}, + {name: "unknown scheme no port returns raw host", u: func(t *testing.T) *url.URL { return mustParseURL(t, "ftp://example.com/p") }, want: "example.com"}, + {name: "empty host falls back to raw host", u: func(t *testing.T) *url.URL { return mustParseURL(t, "http://") }, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := canonicalOriginHost(tt.u(t)) + if got != tt.want { + t.Fatalf("canonicalOriginHost() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBranchCovValidateOutboundFetchURL(t *testing.T) { + httpsOnly := RestrictedOutboundHTTPOptions{AllowedSchemes: []string{"https"}} + permissive := RestrictedOutboundHTTPOptions{AllowedSchemes: []string{"http", "https"}, AllowPrivateIPs: true, AllowLoopback: true} + + tests := []struct { + name string + raw string + opts RestrictedOutboundHTTPOptions + wantErr string + wantStr string + }{ + {name: "empty raw rejected", raw: " ", opts: permissive, wantErr: "URL is required"}, + {name: "malformed raw rejected", raw: ":not-a-url", opts: permissive, wantErr: "invalid URL"}, + {name: "default host required", raw: "https://", opts: permissive, wantErr: "URL host is required"}, + {name: "disallowed scheme rejected with configured list", raw: "http://203.0.113.1/x", opts: httpsOnly, wantErr: "URL scheme must be one of: https"}, + {name: "fragment rejected before dns", raw: "https://203.0.113.1/x#frag", opts: permissive, wantErr: "URL fragments are not allowed"}, + {name: "ip literal metadata blocked without dns", raw: "http://169.254.169.254/x", opts: permissive, wantErr: "metadata service address is not allowed"}, + {name: "ip literal private blocked without dns", raw: "http://192.168.1.1/x", opts: RestrictedOutboundHTTPOptions{AllowedSchemes: []string{"http", "https"}}, wantErr: "private addresses are not allowed"}, + {name: "ip literal public success without dns", raw: "http://203.0.113.1/path?q=1", opts: permissive, wantStr: "http://203.0.113.1/path?q=1"}, + {name: "https ip literal success", raw: "https://203.0.113.1/p", opts: httpsOnly, wantStr: "https://203.0.113.1/p"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ValidateOutboundFetchURL(context.Background(), tt.raw, tt.opts) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("ValidateOutboundFetchURL(%q) = %v, want error containing %q", tt.raw, got, tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("ValidateOutboundFetchURL(%q) err = %q, want substring %q", tt.raw, err.Error(), tt.wantErr) + } + if got != nil { + t.Fatalf("ValidateOutboundFetchURL(%q) returned non-nil URL with error: %v", tt.raw, got) + } + return + } + if err != nil { + t.Fatalf("ValidateOutboundFetchURL(%q) unexpected error: %v", tt.raw, err) + } + if got == nil { + t.Fatalf("ValidateOutboundFetchURL(%q) returned nil URL", tt.raw) + } + if got.String() != tt.wantStr { + t.Fatalf("ValidateOutboundFetchURL(%q) = %q, want %q", tt.raw, got.String(), tt.wantStr) + } + }) + } +} + +func TestBranchCovNewValidatedRequestWithContext(t *testing.T) { + t.Run("nil target errors", func(t *testing.T) { + req, err := NewValidatedRequestWithContext(context.Background(), "GET", nil, nil) + if err == nil || !strings.Contains(err.Error(), "target URL is required") { + t.Fatalf("err = %v, want target URL is required", err) + } + if req != nil { + t.Fatalf("req = %v, want nil", req) + } + }) + + t.Run("invalid method surfaces new request error", func(t *testing.T) { + target := mustParseURL(t, "https://example.com/p") + req, err := NewValidatedRequestWithContext(context.Background(), "GET X", target, nil) + if err == nil || !strings.Contains(err.Error(), "invalid method") { + t.Fatalf("err = %v, want invalid method", err) + } + if req != nil { + t.Fatalf("req = %v, want nil", req) + } + }) + + t.Run("valid target clones url and sets host", func(t *testing.T) { + target := mustParseURL(t, "https://api.example.com:8443/v1?k=v") + req, err := NewValidatedRequestWithContext(context.Background(), "GET", target, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req == nil { + t.Fatal("req is nil") + } + if req.Method != "GET" { + t.Fatalf("Method = %q, want GET", req.Method) + } + if req.URL == nil || req.URL.String() != "https://api.example.com:8443/v1?k=v" { + t.Fatalf("URL = %v, want the target URL", req.URL) + } + if req.URL == target { + t.Fatal("URL is the same pointer as target; expected a clone") + } + if req.Host != "api.example.com:8443" { + t.Fatalf("Host = %q, want api.example.com:8443", req.Host) + } + if req.RequestURI != "" { + t.Fatalf("RequestURI = %q, want empty", req.RequestURI) + } + }) + + t.Run("post method preserved with body", func(t *testing.T) { + target := mustParseURL(t, "https://example.com/ingest") + req, err := NewValidatedRequestWithContext(context.Background(), "POST", target, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Method != "POST" { + t.Fatalf("Method = %q, want POST", req.Method) + } + }) +} + +func TestBranchCovNewRelativeRequestWithContext(t *testing.T) { + t.Run("nil base errors before method validation", func(t *testing.T) { + req, err := NewRelativeRequestWithContext(context.Background(), "GET", nil, "/x", nil) + if err == nil || !strings.Contains(err.Error(), "base URL is required") { + t.Fatalf("err = %v, want base URL is required", err) + } + if req != nil { + t.Fatalf("req = %v, want nil", req) + } + }) + + t.Run("empty relative path errors", func(t *testing.T) { + base := mustParseURL(t, "https://api.example.com/v1") + _, err := NewRelativeRequestWithContext(context.Background(), "GET", base, " ", nil) + if err == nil || !strings.Contains(err.Error(), "relative path is required") { + t.Fatalf("err = %v, want relative path is required", err) + } + }) + + t.Run("backslash in relative path errors", func(t *testing.T) { + base := mustParseURL(t, "https://api.example.com/v1") + _, err := NewRelativeRequestWithContext(context.Background(), "GET", base, `/a\b`, nil) + if err == nil || !strings.Contains(err.Error(), "must not contain backslashes") { + t.Fatalf("err = %v, want backslash rejection", err) + } + }) + + t.Run("absolute url in relative path errors", func(t *testing.T) { + base := mustParseURL(t, "https://api.example.com/v1") + _, err := NewRelativeRequestWithContext(context.Background(), "GET", base, "https://evil.example/x", nil) + if err == nil || !strings.Contains(err.Error(), "must not include scheme or host") { + t.Fatalf("err = %v, want scheme/host rejection", err) + } + }) + + t.Run("relative path without leading slash errors", func(t *testing.T) { + base := mustParseURL(t, "https://api.example.com/v1") + _, err := NewRelativeRequestWithContext(context.Background(), "GET", base, "users", nil) + if err == nil || !strings.Contains(err.Error(), "must start with '/'") { + t.Fatalf("err = %v, want leading slash rejection", err) + } + }) + + t.Run("success builds request with joined url", func(t *testing.T) { + base := mustParseURL(t, "https://api.example.com/v1") + req, err := NewRelativeRequestWithContext(context.Background(), "POST", base, "/users?active=1", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req == nil { + t.Fatal("req is nil") + } + if req.Method != "POST" { + t.Fatalf("Method = %q, want POST", req.Method) + } + if req.URL == nil || req.URL.String() != "https://api.example.com/v1/users?active=1" { + t.Fatalf("URL = %v, want joined relative URL", req.URL) + } + if req.Host != "api.example.com" { + t.Fatalf("Host = %q, want api.example.com", req.Host) + } + if req.RequestURI != "" { + t.Fatalf("RequestURI = %q, want empty", req.RequestURI) + } + }) +}