mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 "<display> requires Pulse <minTier> 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))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user