diff --git a/internal/agentcontext/resource_context_sections_branchcov0719pm_test.go b/internal/agentcontext/resource_context_sections_branchcov0719pm_test.go new file mode 100644 index 000000000..00f44d35a --- /dev/null +++ b/internal/agentcontext/resource_context_sections_branchcov0719pm_test.go @@ -0,0 +1,284 @@ +package agentcontext + +import ( + "testing" + "time" + + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// TestFormatKubernetesServicePortsBranchCov exercises every branch of +// formatKubernetesServicePorts: the empty-input early return, the regular +// formatting arm in the bounded loop, and the truncation ("N more") arm +// triggered once the loop index reaches the internal cap of 5. +func TestFormatKubernetesServicePortsBranchCov(t *testing.T) { + five := []unified.K8sServicePort{ + {Port: 1, TargetPort: "a", Protocol: "TCP"}, + {Port: 2, TargetPort: "b", Protocol: "TCP"}, + {Port: 3, TargetPort: "c", Protocol: "TCP"}, + {Port: 4, TargetPort: "d", Protocol: "TCP"}, + {Port: 5, TargetPort: "e", Protocol: "TCP"}, + } + + tests := []struct { + name string + ports []unified.K8sServicePort + want string + }{ + { + name: "nil slice returns empty string", + ports: nil, + want: "", + }, + { + name: "empty slice returns empty string", + ports: []unified.K8sServicePort{}, + want: "", + }, + { + name: "single port formats as port:targetPort/protocol", + ports: []unified.K8sServicePort{ + {Name: "http", Port: 80, TargetPort: "http", Protocol: "TCP", NodePort: 30080}, + }, + want: "80:http/TCP", + }, + { + name: "two ports joined with comma-space", + ports: []unified.K8sServicePort{ + {Port: 80, TargetPort: "http", Protocol: "TCP"}, + {Port: 443, TargetPort: "https", Protocol: "TCP"}, + }, + want: "80:http/TCP, 443:https/TCP", + }, + { + name: "exactly five ports renders every entry without truncation", + ports: five, + want: "1:a/TCP, 2:b/TCP, 3:c/TCP, 4:d/TCP, 5:e/TCP", + }, + { + name: "six ports truncates to five plus one more suffix", + ports: append(append([]unified.K8sServicePort{}, five...), + unified.K8sServicePort{Port: 6, TargetPort: "f", Protocol: "TCP"}, + ), + want: "1:a/TCP, 2:b/TCP, 3:c/TCP, 4:d/TCP, 5:e/TCP, 1 more", + }, + { + name: "eight ports truncates to five plus three more suffix", + ports: append(append([]unified.K8sServicePort{}, five...), + unified.K8sServicePort{Port: 6, TargetPort: "f", Protocol: "TCP"}, + unified.K8sServicePort{Port: 7, TargetPort: "g", Protocol: "TCP"}, + unified.K8sServicePort{Port: 8, TargetPort: "h", Protocol: "TCP"}, + ), + want: "1:a/TCP, 2:b/TCP, 3:c/TCP, 4:d/TCP, 5:e/TCP, 3 more", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := formatKubernetesServicePorts(tc.ports) + if got != tc.want { + t.Fatalf("formatKubernetesServicePorts(%+v) = %q, want %q", tc.ports, got, tc.want) + } + }) + } +} + +// TestAddMetricFactBranchCov exercises every branch of addMetricFact and the +// downstream empty-value skip in addAgentContextFact: the nil-metric early +// return, each arm of the metric switch (Percent, Value, Used/Total), the +// empty-value skip when no arm matches, and propagation of observedAt +// through both its nil and non-nil forms. +func TestAddMetricFactBranchCov(t *testing.T) { + observedAt := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + + t.Run("nil_metric_does_not_append", func(t *testing.T) { + facts := []Fact{{Label: "preexisting", Value: "v"}} + before := make([]Fact, len(facts)) + copy(before, facts) + + addMetricFact(&facts, "CPU", nil, &observedAt) + + if len(facts) != len(before) { + t.Fatalf("nil metric appended: before=%d after=%d (%+v)", len(before), len(facts), facts) + } + if facts[0].Label != "preexisting" || facts[0].Value != "v" { + t.Fatalf("existing slice mutated by nil metric: %+v", facts) + } + }) + + t.Run("nil_slice_unchanged_when_metric_nil", func(t *testing.T) { + var facts []Fact + addMetricFact(&facts, "CPU", nil, &observedAt) + if facts != nil { + t.Fatalf("nil slice promoted to non-nil by nil metric: %+v", facts) + } + }) + + t.Run("percent_arm_appends_percentage_with_non_nil_observedAt", func(t *testing.T) { + facts := []Fact{} + metric := &unified.MetricValue{Percent: 42.5} + addMetricFact(&facts, "CPU", metric, &observedAt) + + if len(facts) != 1 { + t.Fatalf("expected exactly 1 fact, got %d: %+v", len(facts), facts) + } + fact := facts[0] + if fact.Label != "CPU" { + t.Fatalf("Label = %q, want CPU", fact.Label) + } + if fact.Value != "42.5%" { + t.Fatalf("Value = %q, want 42.5%%", fact.Value) + } + if fact.Source != agentContextSourceUnifiedResource { + t.Fatalf("Source = %q, want %q", fact.Source, agentContextSourceUnifiedResource) + } + if fact.TrustTier != agentContextTrustRuntimeObserved { + t.Fatalf("TrustTier = %q, want %q", fact.TrustTier, agentContextTrustRuntimeObserved) + } + if fact.ObservedAt == nil || !fact.ObservedAt.Equal(observedAt) { + t.Fatalf("ObservedAt = %v, want %v", fact.ObservedAt, observedAt) + } + if fact.Redacted { + t.Fatalf("Redacted = true, want false") + } + }) + + t.Run("value_arm_appends_value_unit_with_nil_observedAt", func(t *testing.T) { + facts := []Fact{} + metric := &unified.MetricValue{Value: 1.5, Unit: "GB"} + addMetricFact(&facts, "Memory", metric, nil) + + if len(facts) != 1 { + t.Fatalf("expected exactly 1 fact, got %d: %+v", len(facts), facts) + } + fact := facts[0] + if fact.Label != "Memory" { + t.Fatalf("Label = %q, want Memory", fact.Label) + } + if fact.Value != "1.5 GB" { + t.Fatalf("Value = %q, want \"1.5 GB\"", fact.Value) + } + if fact.ObservedAt != nil { + t.Fatalf("ObservedAt = %v, want nil when caller passes nil", fact.ObservedAt) + } + }) + + t.Run("value_arm_trims_surrounding_whitespace_in_unit", func(t *testing.T) { + facts := []Fact{} + metric := &unified.MetricValue{Value: 4.0, Unit: " MiB "} + addMetricFact(&facts, "Memory", metric, &observedAt) + + if len(facts) != 1 { + t.Fatalf("expected exactly 1 fact, got %d: %+v", len(facts), facts) + } + if got := facts[0].Value; got != "4.0 MiB" { + t.Fatalf("Value = %q, want \"4.0 MiB\" (unit trimmed)", got) + } + }) + + t.Run("used_total_arm_appends_ratio_when_total_positive", func(t *testing.T) { + facts := []Fact{} + used := int64(100) + total := int64(200) + metric := &unified.MetricValue{Used: &used, Total: &total} + addMetricFact(&facts, "Disk", metric, &observedAt) + + if len(facts) != 1 { + t.Fatalf("expected exactly 1 fact, got %d: %+v", len(facts), facts) + } + if got := facts[0].Value; got != "100/200" { + t.Fatalf("Value = %q, want \"100/200\"", got) + } + if got := facts[0].Label; got != "Disk" { + t.Fatalf("Label = %q, want Disk", got) + } + }) + + t.Run("used_total_arm_skipped_when_total_zero", func(t *testing.T) { + facts := []Fact{} + used := int64(50) + total := int64(0) + metric := &unified.MetricValue{Used: &used, Total: &total} + addMetricFact(&facts, "Disk", metric, &observedAt) + + if len(facts) != 0 { + t.Fatalf("expected 0 facts when Total<=0, got %d: %+v", len(facts), facts) + } + }) + + t.Run("used_total_arm_skipped_when_total_negative", func(t *testing.T) { + facts := []Fact{} + used := int64(50) + total := int64(-1) + metric := &unified.MetricValue{Used: &used, Total: &total} + addMetricFact(&facts, "Disk", metric, &observedAt) + + if len(facts) != 0 { + t.Fatalf("expected 0 facts when Total<0, got %d: %+v", len(facts), facts) + } + }) + + t.Run("used_total_arm_skipped_when_used_nil", func(t *testing.T) { + facts := []Fact{} + total := int64(100) + metric := &unified.MetricValue{Total: &total} + addMetricFact(&facts, "Disk", metric, &observedAt) + + if len(facts) != 0 { + t.Fatalf("expected 0 facts when Used is nil, got %d: %+v", len(facts), facts) + } + }) + + t.Run("zero_metric_produces_empty_value_and_skips_append", func(t *testing.T) { + facts := []Fact{} + metric := &unified.MetricValue{} + addMetricFact(&facts, "Network in", metric, &observedAt) + + if len(facts) != 0 { + t.Fatalf("expected 0 facts for zero metric, got %d: %+v", len(facts), facts) + } + }) + + t.Run("percent_arm_takes_precedence_over_value_and_used_total", func(t *testing.T) { + facts := []Fact{} + used := int64(100) + total := int64(200) + metric := &unified.MetricValue{Percent: 75.0, Value: 9.9, Unit: "GB", Used: &used, Total: &total} + addMetricFact(&facts, "CPU", metric, &observedAt) + + if len(facts) != 1 { + t.Fatalf("expected exactly 1 fact, got %d: %+v", len(facts), facts) + } + if got := facts[0].Value; got != "75.0%" { + t.Fatalf("Value = %q, want \"75.0%%\" (Percent wins)", got) + } + }) + + t.Run("value_arm_takes_precedence_over_used_total", func(t *testing.T) { + facts := []Fact{} + used := int64(100) + total := int64(200) + metric := &unified.MetricValue{Value: 5.0, Unit: "GiB", Used: &used, Total: &total} + addMetricFact(&facts, "Memory", metric, &observedAt) + + if len(facts) != 1 { + t.Fatalf("expected exactly 1 fact, got %d: %+v", len(facts), facts) + } + if got := facts[0].Value; got != "5.0 GiB" { + t.Fatalf("Value = %q, want \"5.0 GiB\" (Value wins over Used/Total)", got) + } + }) + + t.Run("negative_value_takes_value_arm", func(t *testing.T) { + facts := []Fact{} + metric := &unified.MetricValue{Value: -3.5, Unit: "MB/s"} + addMetricFact(&facts, "Network out", metric, &observedAt) + + if len(facts) != 1 { + t.Fatalf("expected exactly 1 fact for negative Value, got %d: %+v", len(facts), facts) + } + if got := facts[0].Value; got != "-3.5 MB/s" { + t.Fatalf("Value = %q, want \"-3.5 MB/s\"", got) + } + }) +} diff --git a/internal/ai/providers/provider_normalize_branchcov0719pm_test.go b/internal/ai/providers/provider_normalize_branchcov0719pm_test.go new file mode 100644 index 000000000..af4ce3da4 --- /dev/null +++ b/internal/ai/providers/provider_normalize_branchcov0719pm_test.go @@ -0,0 +1,117 @@ +package providers + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestToolProgressEventNormalizeCollections_BranchCov0719pm exercises every +// branch of (ToolProgressEvent).NormalizeCollections (provider.go:211): +// - the `if e.Input == nil` true arm, which substitutes a non-nil empty map +// - the implicit else, where a populated Input map is preserved unchanged +// +// Both cases also assert that the non-Input scalar/slice fields are passed +// through verbatim so the normalizer is confirmed to be a no-op outside of the +// nil-collection fix-up. +func TestToolProgressEventNormalizeCollections_BranchCov0719pm(t *testing.T) { + tests := []struct { + name string + event ToolProgressEvent + // wantInputIsNil reports the expected nil-ness of the returned Input + // map. It is always false after normalization; documented per-case for + // clarity. + wantInputIsNil bool + // wantInput reports the expected contents of the returned Input map + // (compared with reflect.DeepEqual so an empty non-nil map is + // distinguishable from a nil one). + wantInput map[string]interface{} + }{ + { + name: "nil input map is replaced with non-nil empty map", + event: ToolProgressEvent{ + ID: "tool_42", + Name: "get_time", + Input: nil, + RawInput: `{"tz":"UTC"}`, + Phase: "streaming", + Message: "Receiving tool input.", + }, + wantInputIsNil: false, + wantInput: map[string]interface{}{}, + }, + { + name: "populated input map is preserved unchanged", + event: ToolProgressEvent{ + ID: "tool_43", + Name: "search", + Input: map[string]interface{}{"q": "pulse", "limit": float64(10)}, + Phase: "ready", + }, + wantInputIsNil: false, + wantInput: map[string]interface{}{"q": "pulse", "limit": float64(10)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Snapshot the input so we can prove the scalar/slice fields are + // preserved on the populated case. Input is asserted separately. + inID, inName := tt.event.ID, tt.event.Name + inRaw, inPhase, inMsg := tt.event.RawInput, tt.event.Phase, tt.event.Message + + got := tt.event.NormalizeCollections() + + // Input map: must never be nil after normalization, and contents + // must equal the expected map (empty for the nil case, the + // original entries for the populated case). + if got.Input == nil { + t.Fatalf("NormalizeCollections returned nil Input map; want non-nil") + } + if tt.wantInputIsNil { + t.Fatalf("test data error: wantInputIsNil must be false after normalization") + } + if !reflect.DeepEqual(got.Input, tt.wantInput) { + t.Fatalf("NormalizeCollections Input = %#v, want %#v", got.Input, tt.wantInput) + } + + // On the populated case, the original map object identity may be + // preserved or a new map allocated with the same contents — the + // contract is contents-equality, asserted above via DeepEqual. + // Here we additionally assert the non-nil invariant explicitly so + // a future refactor that drops the fix-up fails loudly. + assert.NotNil(t, got.Input, "normalized Input map must be non-nil") + + // All other fields must round-trip unchanged. + assert.Equal(t, inID, got.ID, "ID must be preserved") + assert.Equal(t, inName, got.Name, "Name must be preserved") + assert.Equal(t, inRaw, got.RawInput, "RawInput must be preserved") + assert.Equal(t, inPhase, got.Phase, "Phase must be preserved") + assert.Equal(t, inMsg, got.Message, "Message must be preserved") + }) + } +} + +// TestToolProgressEventNormalizeCollections_ZeroValue0719pm covers the literal +// zero value of ToolProgressEvent, which is the most common shape that reaches +// NormalizeCollections when a provider constructs an event before populating +// any field. Every field — including Input — starts nil and Input must end up a +// non-nil empty map. +func TestToolProgressEventNormalizeCollections_ZeroValue0719pm(t *testing.T) { + var zero ToolProgressEvent + got := zero.NormalizeCollections() + + if got.Input == nil { + t.Fatalf("NormalizeCollections on zero-value event left Input nil; want non-nil empty map") + } + if len(got.Input) != 0 { + t.Fatalf("NormalizeCollections on zero-value event Input len = %d, want 0", len(got.Input)) + } + // Zero scalars are unchanged. + assert.Equal(t, "", got.ID, "ID must remain zero value") + assert.Equal(t, "", got.Name, "Name must remain zero value") + assert.Equal(t, "", got.RawInput, "RawInput must remain zero value") + assert.Equal(t, "", got.Phase, "Phase must remain zero value") + assert.Equal(t, "", got.Message, "Message must remain zero value") +} diff --git a/internal/ai/tools/tools_query_toolresponse_branchcov0719pm_test.go b/internal/ai/tools/tools_query_toolresponse_branchcov0719pm_test.go new file mode 100644 index 000000000..9ea0e44a7 --- /dev/null +++ b/internal/ai/tools/tools_query_toolresponse_branchcov0719pm_test.go @@ -0,0 +1,218 @@ +package tools + +import ( + "testing" +) + +// This test file raises branch/function coverage on the two pure +// ToToolResponse() methods declared in tools_query.go: +// - (*ErrStrictResolution).ToToolResponse() (tools_query.go:48) +// - (*ErrRoutingMismatch).ToToolResponse() (tools_query.go:93) +// +// It deliberately exercises both arms of the conditional inside +// ErrRoutingMismatch.ToToolResponse() (MoreSpecificIDs populated vs empty), +// since the existing strict_resolution_test.go only hits the empty branch. + +// TestErrStrictResolutionToToolResponse_BranchCov0719pm drives the single +// return path of (*ErrStrictResolution).ToToolResponse() and asserts the +// returned ToolResponse carries the STRICT_RESOLUTION code, the original +// human-readable message, the blocked flag, and the exact metadata map the +// policy contract publishes (resource_id / action / policy_boundary). +func TestErrStrictResolutionToToolResponse_BranchCov0719pm(t *testing.T) { + const ( + wantResourceID = "vm:101" + wantAction = "restart" + wantMessage = "resource 'vm:101' has not been discovered; discovery is required before restart" + wantPolicy = "Resource discovery is required before resource-specific actions." + ) + + err := &ErrStrictResolution{ + ResourceID: wantResourceID, + Action: wantAction, + Message: wantMessage, + } + + resp := err.ToToolResponse() + + if resp.OK { + t.Fatalf("ToToolResponse().OK = true; blocked operations must report OK=false") + } + if resp.Error == nil { + t.Fatalf("ToToolResponse().Error = nil; STRICT_RESOLUTION must populate Error envelope") + } + if resp.Error.Code != ErrCodeStrictResolution { + t.Fatalf("Error.Code = %q, want %q", resp.Error.Code, ErrCodeStrictResolution) + } + if !resp.Error.Blocked { + t.Errorf("Error.Blocked = false, want true (STRICT_RESOLUTION is a policy block)") + } + if resp.Error.Failed { + t.Errorf("Error.Failed = true, want false (block, not execution failure)") + } + if resp.Error.Message != wantMessage { + t.Errorf("Error.Message = %q, want %q", resp.Error.Message, wantMessage) + } + + details := resp.Error.Details + if details == nil { + t.Fatalf("Error.Details = nil; expected populated policy metadata") + } + + if got, ok := details["resource_id"].(string); !ok || got != wantResourceID { + t.Errorf("Details[resource_id] = %v, want %q", details["resource_id"], wantResourceID) + } + if got, ok := details["action"].(string); !ok || got != wantAction { + t.Errorf("Details[action] = %v, want %q", details["action"], wantAction) + } + if got, ok := details["policy_boundary"].(string); !ok || got != wantPolicy { + t.Errorf("Details[policy_boundary] = %v, want %q", details["policy_boundary"], wantPolicy) + } + + if _, present := details["more_specific_resources"]; present { + t.Errorf("Details unexpectedly carries more_specific_resources (strict-resolution payload)") + } +} + +// TestErrRoutingMismatchToToolResponse_BranchCov0719pm is table-driven over +// both branches of the `len(e.MoreSpecificIDs) > 0` conditional in +// (*ErrRoutingMismatch).ToToolResponse(): the canonical-ID arm (which adds +// the more_specific_resource_ids fact) and the empty arm (which omits it). +// Each case asserts the ROUTING_MISMATCH code, the blocked flag, the echoed +// message, and the precise set of metadata keys/values the function emits. +func TestErrRoutingMismatchToToolResponse_BranchCov0719pm(t *testing.T) { + const wantPolicy = "A more specific child resource exists on the requested host; choose the intended resource before retrying a scoped action." + + type expect struct { + name string + targetHost string + moreSpecificResources []string + moreSpecificIDs []string + childKinds []string + message string + wantIDsKey bool // true → expects more_specific_resource_ids + wantIDsValue []string + wantResourcesAssertionType bool // true → assert Details[more_specific_resources] type-asserts as []string + } + + cases := []expect{ + { + name: "with_canonical_ids_populates_more_specific_resource_ids", + targetHost: "pve-node", + moreSpecificResources: []string{"homepage-docker", "jellyfin"}, + moreSpecificIDs: []string{"system-container:proxmox:141", "vm:proxmox:100"}, + childKinds: []string{"system-container", "vm"}, + message: "target_host 'pve-node' has more specific children: [homepage-docker jellyfin]", + wantIDsKey: true, + wantIDsValue: []string{"system-container:proxmox:141", "vm:proxmox:100"}, + wantResourcesAssertionType: true, + }, + { + name: "without_canonical_ids_omits_more_specific_resource_ids", + targetHost: "pve-node", + moreSpecificResources: []string{"homepage-docker"}, + moreSpecificIDs: nil, // forces the else branch + childKinds: nil, + message: "target_host 'pve-node' has more specific children: [homepage-docker]", + wantIDsKey: false, + wantResourcesAssertionType: true, + }, + { + name: "empty_canonical_ids_slice_also_takes_else_branch", + targetHost: "prox97", + moreSpecificResources: []string{}, + moreSpecificIDs: []string{}, + childKinds: []string{}, + message: "target_host 'prox97' has more specific children: []", + wantIDsKey: false, + wantResourcesAssertionType: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := &ErrRoutingMismatch{ + TargetHost: tc.targetHost, + MoreSpecificResources: tc.moreSpecificResources, + MoreSpecificIDs: tc.moreSpecificIDs, + ChildKinds: tc.childKinds, + Message: tc.message, + } + + resp := err.ToToolResponse() + + if resp.OK { + t.Fatalf("ToToolResponse().OK = true; routing mismatch must report OK=false") + } + if resp.Error == nil { + t.Fatalf("ToToolResponse().Error = nil; ROUTING_MISMATCH must populate Error envelope") + } + if resp.Error.Code != ErrCodeRoutingMismatch { + t.Fatalf("Error.Code = %q, want %q", resp.Error.Code, ErrCodeRoutingMismatch) + } + if !resp.Error.Blocked { + t.Errorf("Error.Blocked = false, want true (ROUTING_MISMATCH is a policy block)") + } + if resp.Error.Failed { + t.Errorf("Error.Failed = true, want false (block, not execution failure)") + } + if resp.Error.Message != tc.message { + t.Errorf("Error.Message = %q, want %q", resp.Error.Message, tc.message) + } + + details := resp.Error.Details + if details == nil { + t.Fatalf("Error.Details = nil; expected populated routing metadata") + } + + if got, ok := details["target_host"].(string); !ok || got != tc.targetHost { + t.Errorf("Details[target_host] = %v, want %q", details["target_host"], tc.targetHost) + } + + if tc.wantResourcesAssertionType { + resources, ok := details["more_specific_resources"].([]string) + if !ok { + t.Fatalf("Details[more_specific_resources] type = %T, want []string", details["more_specific_resources"]) + } + if len(resources) != len(tc.moreSpecificResources) { + t.Fatalf("Details[more_specific_resources] = %v, want %v", resources, tc.moreSpecificResources) + } + for i := range resources { + if resources[i] != tc.moreSpecificResources[i] { + t.Errorf("Details[more_specific_resources][%d] = %q, want %q", i, resources[i], tc.moreSpecificResources[i]) + } + } + } + + idsValue, idsPresent := details["more_specific_resource_ids"] + if tc.wantIDsKey { + if !idsPresent { + t.Fatalf("Details[more_specific_resource_ids] missing; expected to be present when MoreSpecificIDs is non-empty") + } + idsSlice, ok := idsValue.([]string) + if !ok { + t.Fatalf("Details[more_specific_resource_ids] type = %T, want []string", idsValue) + } + if len(idsSlice) != len(tc.wantIDsValue) { + t.Fatalf("Details[more_specific_resource_ids] = %v, want %v", idsSlice, tc.wantIDsValue) + } + for i := range idsSlice { + if idsSlice[i] != tc.wantIDsValue[i] { + t.Errorf("Details[more_specific_resource_ids][%d] = %q, want %q", i, idsSlice[i], tc.wantIDsValue[i]) + } + } + } else { + if idsPresent { + t.Errorf("Details[more_specific_resource_ids] = %v; expected to be absent when MoreSpecificIDs is empty", idsValue) + } + } + + if got, ok := details["policy_boundary"].(string); !ok || got != wantPolicy { + t.Errorf("Details[policy_boundary] = %v, want %q", details["policy_boundary"], wantPolicy) + } + + if _, present := details["auto_recoverable"]; present { + t.Errorf("Details[auto_recoverable] present; routing mismatch must not surface auto-recovery hint") + } + }) + } +} diff --git a/internal/cloudcp/email/templates_branchcov0719pm_test.go b/internal/cloudcp/email/templates_branchcov0719pm_test.go new file mode 100644 index 000000000..0bbd5d033 --- /dev/null +++ b/internal/cloudcp/email/templates_branchcov0719pm_test.go @@ -0,0 +1,92 @@ +package email + +import ( + "strings" + "testing" +) + +// TestRenderMagicLinkEmail covers RenderMagicLinkEmail in templates.go. +// +// The function's only error arm comes from magicLinkTemplate.Execute, which is +// unreachable at runtime: magicLinkTemplate is a package-level template.Must of +// a constant parse string, so Execute cannot fail for the single string field +// MagicLinkData.MagicLinkURL. That arm is therefore not fabricated here. +func TestRenderMagicLinkEmail(t *testing.T) { + t.Run("populated URL substituted into html and text", func(t *testing.T) { + const url = "https://pulse.example.com/auth/magic?token=abc123XYZ" + + htmlBody, textBody, err := RenderMagicLinkEmail(MagicLinkData{MagicLinkURL: url}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if htmlBody == "" { + t.Fatal("expected non-empty html body") + } + if textBody == "" { + t.Fatal("expected non-empty text body") + } + + if !strings.Contains(htmlBody, url) { + t.Errorf("html body does not contain magic link URL;\nwant substring: %s\ngot: %s", url, htmlBody) + } + if !strings.Contains(htmlBody, "Sign in to Pulse") { + t.Errorf("html body does not contain expected heading text;\ngot: %s", htmlBody) + } + if !strings.Contains(htmlBody, `href="`+url+`"`) { + t.Errorf("html body does not contain href with the URL;\ngot: %s", htmlBody) + } + + if !strings.Contains(textBody, url) { + t.Errorf("text body does not contain magic link URL;\nwant substring: %s\ngot: %s", url, textBody) + } + if !strings.Contains(textBody, "Sign in to Pulse") { + t.Errorf("text body does not contain expected greeting;\ngot: %s", textBody) + } + if !strings.Contains(textBody, "expires in 15 minutes") { + t.Errorf("text body does not contain expiry note;\ngot: %s", textBody) + } + }) + + t.Run("html template escapes ampersands in URL, text body does not", func(t *testing.T) { + const rawURL = "https://pulse.example.com/auth/magic?token=abc&uid=42" + + htmlBody, textBody, err := RenderMagicLinkEmail(MagicLinkData{MagicLinkURL: rawURL}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + const escapedURL = "https://pulse.example.com/auth/magic?token=abc&uid=42" + if !strings.Contains(htmlBody, escapedURL) { + t.Errorf("html body should contain HTML-escaped URL %q;\ngot: %s", escapedURL, htmlBody) + } + if strings.Contains(htmlBody, `href="`+rawURL+`"`) { + t.Errorf("html body must not contain the raw unescaped URL inside href;\ngot: %s", htmlBody) + } + + if !strings.Contains(textBody, rawURL) { + t.Errorf("text body should contain the raw URL %q (no HTML escaping);\ngot: %s", rawURL, textBody) + } + if strings.Contains(textBody, escapedURL) { + t.Errorf("text body must not contain HTML-escaped ampersands;\ngot: %s", textBody) + } + }) + + t.Run("empty URL still renders without error", func(t *testing.T) { + htmlBody, textBody, err := RenderMagicLinkEmail(MagicLinkData{MagicLinkURL: ""}) + if err != nil { + t.Fatalf("unexpected error for empty URL: %v", err) + } + if htmlBody == "" { + t.Fatal("expected non-empty html body even with empty URL") + } + if textBody == "" { + t.Fatal("expected non-empty text body even with empty URL") + } + if !strings.Contains(htmlBody, "Sign in to Pulse") { + t.Errorf("html body missing heading for empty URL;\ngot: %s", htmlBody) + } + if !strings.Contains(textBody, "Sign in to Pulse") { + t.Errorf("text body missing greeting for empty URL;\ngot: %s", textBody) + } + }) +} diff --git a/internal/recovery/posture_detail_branchcov0719pm_test.go b/internal/recovery/posture_detail_branchcov0719pm_test.go new file mode 100644 index 000000000..55243e9b9 --- /dev/null +++ b/internal/recovery/posture_detail_branchcov0719pm_test.go @@ -0,0 +1,185 @@ +package recovery + +import ( + "testing" + "time" +) + +func TestRecoveryDetailStringBranch0719pm(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + point RecoveryPoint + key string + want string + }{ + { + name: "nil details returns empty", + point: RecoveryPoint{}, + key: "instance", + want: "", + }, + { + name: "populated details but key absent returns empty", + point: RecoveryPoint{ + Details: map[string]any{ + "instance": "pve-main", + }, + }, + key: "missing-key", + want: "", + }, + { + name: "key present but value is an int returns empty", + point: RecoveryPoint{ + Details: map[string]any{ + "vmid": 100, + }, + }, + key: "vmid", + want: "", + }, + { + name: "key present but value is nil returns empty", + point: RecoveryPoint{ + Details: map[string]any{ + "connectionId": nil, + }, + }, + key: "connectionId", + want: "", + }, + { + name: "key present with bare string value returns value", + point: RecoveryPoint{ + Details: map[string]any{ + "instance": "pve-main", + }, + }, + key: "instance", + want: "pve-main", + }, + { + name: "key present with surrounding whitespace returns trimmed value", + point: RecoveryPoint{ + Details: map[string]any{ + "k8sClusterId": " prod-eks ", + }, + }, + key: "k8sClusterId", + want: "prod-eks", + }, + { + name: "key present with whitespace-only string returns empty", + point: RecoveryPoint{ + Details: map[string]any{ + "instance": " ", + }, + }, + key: "instance", + want: "", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + got := recoveryDetailString(test.point, test.key) + if got != test.want { + t.Fatalf("recoveryDetailString(...) = %q, want %q", got, test.want) + } + }) + } +} + +func TestRecoveryPointObservedAtBranch0719pm(t *testing.T) { + t.Parallel() + + completedAt := time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC) + startedAt := time.Date(2026, 7, 19, 9, 0, 0, 0, time.UTC) + // Construct a non-UTC time so we can assert the result is normalised to UTC. + completedNonUTC := time.Date( + 2026, 7, 19, 8, 0, 0, 0, time.FixedZone("PVE", -2*3600), + ) + zeroTime := time.Time{} + + tests := []struct { + name string + point RecoveryPoint + want time.Time + }{ + { + name: "nil completed and nil started returns zero", + point: RecoveryPoint{}, + want: time.Time{}, + }, + { + name: "completed present returns completed in UTC", + point: RecoveryPoint{ + CompletedAt: &completedAt, + }, + want: completedAt.UTC(), + }, + { + name: "completed nil but started present returns started in UTC", + point: RecoveryPoint{ + StartedAt: &startedAt, + }, + want: startedAt.UTC(), + }, + { + name: "both completed and started present prefers completed", + point: RecoveryPoint{ + CompletedAt: &completedAt, + StartedAt: &startedAt, + }, + want: completedAt.UTC(), + }, + { + name: "non-nil completed pointer holding zero value falls through to started", + point: RecoveryPoint{ + CompletedAt: &zeroTime, + StartedAt: &startedAt, + }, + want: startedAt.UTC(), + }, + { + name: "non-nil started pointer holding zero value returns zero", + point: RecoveryPoint{ + StartedAt: &zeroTime, + }, + want: time.Time{}, + }, + { + name: "non-nil completed and started both zero returns zero", + point: RecoveryPoint{ + CompletedAt: &zeroTime, + StartedAt: &zeroTime, + }, + want: time.Time{}, + }, + { + name: "non-UTC completed is normalised to UTC equivalent", + point: RecoveryPoint{ + CompletedAt: &completedNonUTC, + }, + want: completedNonUTC.UTC(), + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + got := recoveryPointObservedAt(test.point) + if !got.Equal(test.want) { + t.Fatalf( + "recoveryPointObservedAt(...) = %v (loc %s), want %v (loc %s)", + got, got.Location(), test.want, test.want.Location(), + ) + } + }) + } +} diff --git a/pkg/reporting/pdf_logo_branchcov0719pm_test.go b/pkg/reporting/pdf_logo_branchcov0719pm_test.go new file mode 100644 index 000000000..f15d649d3 --- /dev/null +++ b/pkg/reporting/pdf_logo_branchcov0719pm_test.go @@ -0,0 +1,284 @@ +package reporting + +import ( + "bytes" + "image" + "image/color" + "image/png" + "math" + "testing" + + "github.com/go-pdf/fpdf" +) + +func approxEqual(a, b float64) bool { + const eps = 1e-6 + return math.Abs(a-b) <= eps +} + +// encodeLogoTestPNG returns a deterministic black-opaque RGBA PNG of the +// requested pixel dimensions. Used to drive scaledLogoSize with a real +// fpdf-registered image so the math runs against actual Width()/Height() +// values rather than a fabricated stub. +func encodeLogoTestPNG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetRGBA(x, y, color.RGBA{R: 0, G: 0, B: 0, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png %dx%d: %v", w, h, err) + } + return buf.Bytes() +} + +// registerLogoTestImage encodes a PNG of the given pixel size and registers +// it with a fresh fpdf instance, returning the resulting ImageInfoType. +func registerLogoTestImage(t *testing.T, w, h int, name string) *fpdf.ImageInfoType { + t.Helper() + pdf := fpdf.New("P", "mm", "A4", "") + info := pdf.RegisterImageOptionsReader( + name, + fpdf.ImageOptions{ImageType: "png", ReadDpi: true}, + bytes.NewReader(encodeLogoTestPNG(t, w, h)), + ) + if info == nil { + t.Fatalf("RegisterImageOptionsReader returned nil info for %dx%d", w, h) + } + if info.Width() <= 0 || info.Height() <= 0 { + t.Fatalf("registered image %dx%d reported non-positive extent w=%v h=%v", + w, h, info.Width(), info.Height()) + } + return info +} + +func TestReportLogoTypeFromData_BranchCov(t *testing.T) { + pngMagic8 := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'} + pngTail := []byte{0, 0, 0, 13, 'I', 'H', 'D', 'R'} + pngFull := append(append([]byte{}, pngMagic8...), pngTail...) + jpgMagic3 := []byte{0xff, 0xd8, 0xff} + jpgFull := append(append([]byte{}, jpgMagic3...), 0xe0, 0, 0x10, 0, 0x4a, 0x46, 0x49, 0x46) + gif87a := []byte("GIF87a") + gif89a := []byte("GIF89a") + unknown := []byte("not an image magic at all") + + tests := []struct { + name string + data []byte + configured string + want string + }{ + // --- configured short-circuit arm (normalizeLogoFormat != "") --- + {"configured png wins over jpg data", jpgFull, "png", "png"}, + {"configured jpg wins over png data", pngFull, "jpg", "jpg"}, + {"configured jpeg normalizes to jpg", pngFull, "jpeg", "jpg"}, + {"configured gif wins over png data", pngFull, "gif", "gif"}, + {"configured uppercase is normalized", jpgFull, "PNG", "png"}, + {"configured with whitespace trimmed", jpgFull, " jpg ", "jpg"}, + {"configured invalid falls through to png data", pngFull, "svg", "png"}, + {"configured invalid falls through to jpg data", jpgFull, "bmp", "jpg"}, + {"configured invalid falls through to gif data", gif87a, "weird", "gif"}, + + // --- data-detection arms with empty configured --- + {"empty configured + full png magic", pngFull, "", "png"}, + {"empty configured + exactly 8 png magic bytes", pngMagic8, "", "png"}, + {"empty configured + full jpg magic", jpgFull, "", "jpg"}, + {"empty configured + exactly 3 jpg magic bytes", jpgMagic3, "", "jpg"}, + {"empty configured + gif87a", gif87a, "", "gif"}, + {"empty configured + gif89a", gif89a, "", "gif"}, + {"empty configured + exactly 6 gif bytes (87a)", []byte("GIF87a"), "", "gif"}, + {"empty configured + exactly 6 gif bytes (89a)", []byte("GIF89a"), "", "gif"}, + + // --- default arm (no match) --- + {"empty configured + unknown bytes", unknown, "", ""}, + {"empty configured + nil data", nil, "", ""}, + {"empty configured + empty data", []byte{}, "", ""}, + {"png prefix too short (7 bytes)", pngMagic8[:7], "", ""}, + {"jpg prefix too short (2 bytes)", jpgMagic3[:2], "", ""}, + {"gif prefix too short (5 bytes)", gif87a[:5], "", ""}, + {"gif89a prefix wrong last char", []byte("GIF89b"), "", ""}, + {"gif87a prefix wrong last char", []byte("GIF87b"), "", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := reportLogoTypeFromData(tc.data, tc.configured) + if got != tc.want { + t.Fatalf("reportLogoTypeFromData(%v, %q) = %q, want %q", + tc.data, tc.configured, got, tc.want) + } + }) + } +} + +func TestReportLogoTypeFromPath_BranchCov(t *testing.T) { + tests := []struct { + name string + path string + configured string + want string + }{ + // --- configured short-circuit arm (normalizeLogoFormat != "") --- + {"configured png wins regardless of path", "/any/thing.xyz", "png", "png"}, + {"configured jpg wins regardless of path", "/any/thing", "jpg", "jpg"}, + {"configured jpeg normalizes to jpg", "/any/thing", "jpeg", "jpg"}, + {"configured gif wins regardless of path", "/any/thing", "gif", "gif"}, + {"configured uppercase normalized to png", "/any/thing", "PNG", "png"}, + {"configured whitespace trimmed to jpg", "/any/thing", " jpg ", "jpg"}, + {"configured invalid falls through to extension png", "/a.png", "svg", "png"}, + {"configured invalid falls through to default arm", "/a.svg", "svg", ""}, + + // --- extension switch arms with empty configured --- + {"png extension lowercase", "logo.png", "", "png"}, + {"png extension uppercase", "LOGO.PNG", "", "png"}, + {"png extension mixed case", "Logo.PnG", "", "png"}, + {"jpg extension", "logo.jpg", "", "jpg"}, + {"jpeg extension", "logo.jpeg", "", "jpg"}, + {"JPG extension uppercase", "LOGO.JPG", "", "jpg"}, + {"JPEG extension uppercase", "LOGO.JPEG", "", "jpg"}, + {"gif extension", "logo.gif", "", "gif"}, + {"GIF extension uppercase", "LOGO.GIF", "", "gif"}, + + // --- default arm (unknown / no extension) --- + {"unknown svg extension", "logo.svg", "", ""}, + {"unknown bmp extension", "logo.bmp", "", ""}, + {"unknown webp extension", "logo.webp", "", ""}, + {"unknown txt extension", "logo.txt", "", ""}, + {"no extension at all", "README", "", ""}, + {"trailing dot only", "logo.", "", ""}, + {"trailing dot uppercased", "LOGO.", "", ""}, + {"png with trailing wrong ext", "logo.png.bak", "", ""}, + + // --- realistic full paths --- + {"absolute path png", "/var/lib/pulse/assets/brand.png", "", "png"}, + {"url-style path jpg", "https://example.com/img/logo.jpg", "", "jpg"}, + {"windows-style path gif", `C:\assets\logo.gif`, "", "gif"}, + {"empty path with empty configured", "", "", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := reportLogoTypeFromPath(tc.path, tc.configured) + if got != tc.want { + t.Fatalf("reportLogoTypeFromPath(%q, %q) = %q, want %q", + tc.path, tc.configured, got, tc.want) + } + }) + } +} + +func TestScaledLogoSize_BranchCov(t *testing.T) { + const eps = 1e-6 + + t.Run("nil_info_returns_zero_zero", func(t *testing.T) { + w, h := scaledLogoSize(nil, 100, 100) + if w != 0 || h != 0 { + t.Fatalf("scaledLogoSize(nil, 100, 100) = (%v, %v), want (0, 0)", w, h) + } + }) + + t.Run("zero_maxW_hits_scale_zero_arm", func(t *testing.T) { + info := registerLogoTestImage(t, 8, 4, "zero-maxw") + w, h := scaledLogoSize(info, 0, 100) + if w != 0 || h != 0 { + t.Fatalf("scaledLogoSize(info, 0, 100) = (%v, %v), want (0, 0)", w, h) + } + }) + + t.Run("zero_maxH_hits_scale_zero_arm", func(t *testing.T) { + info := registerLogoTestImage(t, 8, 4, "zero-maxh") + w, h := scaledLogoSize(info, 100, 0) + if w != 0 || h != 0 { + t.Fatalf("scaledLogoSize(info, 100, 0) = (%v, %v), want (0, 0)", w, h) + } + }) + + t.Run("negative_maxW_hits_scale_zero_arm", func(t *testing.T) { + info := registerLogoTestImage(t, 8, 4, "neg-maxw") + w, h := scaledLogoSize(info, -10, 100) + if w != 0 || h != 0 { + t.Fatalf("scaledLogoSize(info, -10, 100) = (%v, %v), want (0, 0)", w, h) + } + }) + + t.Run("negative_maxH_hits_scale_zero_arm", func(t *testing.T) { + info := registerLogoTestImage(t, 8, 4, "neg-maxh") + w, h := scaledLogoSize(info, 100, -10) + if w != 0 || h != 0 { + t.Fatalf("scaledLogoSize(info, 100, -10) = (%v, %v), want (0, 0)", w, h) + } + }) + + t.Run("square_image_square_box_fills_both_axes", func(t *testing.T) { + info := registerLogoTestImage(t, 4, 4, "square") + const maxW, maxH = 100.0, 100.0 + w, h := scaledLogoSize(info, maxW, maxH) + if !approxEqual(w, 100) || !approxEqual(h, 100) { + t.Fatalf("scaledLogoSize(square 4x4, 100, 100) = (%v, %v), want (100, 100)", w, h) + } + }) + + t.Run("wide_image_in_square_box_width_binds", func(t *testing.T) { + // 8x4 aspect 2:1 in a 1:1 box: width binds, output is (100, 50). + info := registerLogoTestImage(t, 8, 4, "wide-sqbox") + const maxW, maxH = 100.0, 100.0 + w, h := scaledLogoSize(info, maxW, maxH) + if !approxEqual(w, 100) || !approxEqual(h, 50) { + t.Fatalf("scaledLogoSize(8x4, 100, 100) = (%v, %v), want (100, 50)", w, h) + } + if w > maxW+eps || h > maxH+eps { + t.Fatalf("output (%v, %v) exceeds box (%v, %v)", w, h, maxW, maxH) + } + }) + + t.Run("tall_image_in_square_box_height_binds", func(t *testing.T) { + // 4x8 aspect 1:2 in a 1:1 box: height binds, output is (50, 100). + info := registerLogoTestImage(t, 4, 8, "tall-sqbox") + const maxW, maxH = 100.0, 100.0 + w, h := scaledLogoSize(info, maxW, maxH) + if !approxEqual(w, 50) || !approxEqual(h, 100) { + t.Fatalf("scaledLogoSize(4x8, 100, 100) = (%v, %v), want (50, 100)", w, h) + } + if w > maxW+eps || h > maxH+eps { + t.Fatalf("output (%v, %v) exceeds box (%v, %v)", w, h, maxW, maxH) + } + }) + + t.Run("wide_image_in_matched_aspect_box_fills_both", func(t *testing.T) { + // 8x4 aspect 2:1 in a 100x50 box (also 2:1): both axes bind at once. + info := registerLogoTestImage(t, 8, 4, "wide-matched") + const maxW, maxH = 100.0, 50.0 + w, h := scaledLogoSize(info, maxW, maxH) + if !approxEqual(w, 100) || !approxEqual(h, 50) { + t.Fatalf("scaledLogoSize(8x4, 100, 50) = (%v, %v), want (100, 50)", w, h) + } + }) + + t.Run("tall_image_in_matched_aspect_box_fills_both", func(t *testing.T) { + // 4x8 aspect 1:2 in a 50x100 box (also 1:2): both axes bind at once. + info := registerLogoTestImage(t, 4, 8, "tall-matched") + const maxW, maxH = 50.0, 100.0 + w, h := scaledLogoSize(info, maxW, maxH) + if !approxEqual(w, 50) || !approxEqual(h, 100) { + t.Fatalf("scaledLogoSize(4x8, 50, 100) = (%v, %v), want (50, 100)", w, h) + } + }) + + t.Run("image_smaller_than_box_uses_uniform_scale", func(t *testing.T) { + // Verify aspect ratio is preserved across a non-square box where the + // image is upscaling rather than downscaling. 6x2 aspect 3:1 in a + // 300x100 box (also 3:1): both axes bind. + info := registerLogoTestImage(t, 6, 2, "upscale") + const maxW, maxH = 300.0, 100.0 + w, h := scaledLogoSize(info, maxW, maxH) + if !approxEqual(w, 300) || !approxEqual(h, 100) { + t.Fatalf("scaledLogoSize(6x2, 300, 100) = (%v, %v), want (300, 100)", w, h) + } + if w > maxW+eps || h > maxH+eps { + t.Fatalf("output (%v, %v) exceeds box (%v, %v)", w, h, maxW, maxH) + } + }) +}