diff --git a/internal/cloudcp/proxytrust/client_ip_branchcov0719_test.go b/internal/cloudcp/proxytrust/client_ip_branchcov0719_test.go new file mode 100644 index 000000000..f6cf7eedd --- /dev/null +++ b/internal/cloudcp/proxytrust/client_ip_branchcov0719_test.go @@ -0,0 +1,241 @@ +package proxytrust + +import ( + "net/http" + "testing" +) + +// setupTrustEnv resets the package-level sync.Once and trusted CIDRs, sets both +// the primary and fallback env vars for the duration of the subtest, and +// schedules a final reset so global state never leaks to sibling tests. +// +// loadTrustedProxyCIDRs reads env inside trustedProxyOnce.Do, so the reset MUST +// happen before t.Setenv and before the call under test. +func setupTrustEnv(t *testing.T, primary, fallback string) { + t.Helper() + ResetForTesting() + t.Setenv("CP_TRUSTED_PROXY_CIDRS", primary) + t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", fallback) + t.Cleanup(ResetForTesting) +} + +func makeReq(t *testing.T, remote string, headers map[string]string) *http.Request { + t.Helper() + h := http.Header{} + for k, v := range headers { + h.Set(k, v) + } + return &http.Request{RemoteAddr: remote, Header: h} +} + +func TestExtractRemoteIP(t *testing.T) { + tests := []struct { + name string + remoteAddr string + want string + }{ + {"ipv4 host port", "1.2.3.4:5678", "1.2.3.4"}, + {"bare ipv4 no port", "1.2.3.4", "1.2.3.4"}, + {"empty string", "", ""}, + {"ipv6 bracketed with port", "[::1]:80", "::1"}, + {"ipv6 bracketed no port", "[::1]", "::1"}, + {"ipv6 bare no port", "::1", "::1"}, + {"whitespace only", " ", " "}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ExtractRemoteIP(tc.remoteAddr) + if got != tc.want { + t.Errorf("ExtractRemoteIP(%q) = %q, want %q", tc.remoteAddr, got, tc.want) + } + }) + } +} + +func TestIsTrustedProxyIP(t *testing.T) { + tests := []struct { + name string + primary string + fallback string + rawIP string + want bool + }{ + {"inside v4 cidr", "10.0.0.0/8", "", "10.5.6.7", true}, + {"outside v4 cidr", "10.0.0.0/8", "", "192.168.1.1", false}, + {"bare v4 exact match", "10.0.0.5", "", "10.0.0.5", true}, + {"bare v4 different host", "10.0.0.5", "", "10.0.0.6", false}, + {"bare v6 exact match", "::1", "", "::1", true}, + {"v6 inside cidr", "fc00::/7", "", "fc00::1234", true}, + {"bracketed raw input stripped", "10.0.0.0/8", "", "[10.0.0.5]", true}, + {"unparseable input", "10.0.0.0/8", "", "not-an-ip", false}, + {"empty input", "10.0.0.0/8", "", "", false}, + {"no config returns false", "", "", "10.0.0.5", false}, + {"invalid cidr entry ignored valid one used", "not-a-cidr,10.0.0.0/8", "", "10.0.0.5", true}, + {"all invalid entries yields false", "not-a-cidr,also-bad", "", "10.0.0.5", false}, + {"fallback env used when primary empty", "", "10.0.0.0/8", "10.0.0.5", true}, + {"multiple cidrs any match", "10.0.0.0/8,192.168.0.0/16", "", "192.168.50.1", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setupTrustEnv(t, tc.primary, tc.fallback) + got := IsTrustedProxyIP(tc.rawIP) + if got != tc.want { + t.Errorf("IsTrustedProxyIP(%q) primary=%q fallback=%q = %v, want %v", + tc.rawIP, tc.primary, tc.fallback, got, tc.want) + } + }) + } +} + +func TestRightMostUntrustedForwardedIP(t *testing.T) { + tests := []struct { + name string + primary string + fallback string + header string + want string + }{ + {"empty header", "10.0.0.0/8", "", "", ""}, + {"single untrusted hop", "10.0.0.0/8", "", "203.0.113.5", "203.0.113.5"}, + {"single trusted hop returns self", "10.0.0.0/8", "", "10.0.0.1", "10.0.0.1"}, + {"rightmost untrusted wins over trusted", "10.0.0.0/8", "", "203.0.113.5, 10.0.0.1", "203.0.113.5"}, + {"all trusted returns leftmost valid", "10.0.0.0/8", "", "10.0.0.1, 10.0.0.2", "10.0.0.1"}, + {"invalid entries skipped returns leftmost valid", "10.0.0.0/8", "", "10.0.0.1, not-an-ip", "10.0.0.1"}, + {"all invalid returns empty", "10.0.0.0/8", "", "garbage, also-bad", ""}, + {"multi hop mixed picks rightmost untrusted", "10.0.0.0/8", "", "203.0.113.5, 10.0.0.1, 10.0.0.2", "203.0.113.5"}, + {"bracketed entries stripped", "10.0.0.0/8", "", "[203.0.113.5], [10.0.0.1]", "203.0.113.5"}, + {"no config treats all as untrusted rightmost wins", "", "", "1.2.3.4, 5.6.7.8", "5.6.7.8"}, + {"no config single ip returns it", "", "", "1.2.3.4", "1.2.3.4"}, + {"fallback env provides trust set", "", "10.0.0.0/8", "203.0.113.5, 10.0.0.1", "203.0.113.5"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setupTrustEnv(t, tc.primary, tc.fallback) + got := rightMostUntrustedForwardedIP(tc.header) + if got != tc.want { + t.Errorf("rightMostUntrustedForwardedIP(%q) primary=%q = %q, want %q", + tc.header, tc.primary, got, tc.want) + } + }) + } +} + +func TestClientIP(t *testing.T) { + const trustCIDR = "10.0.0.0/8" + + tests := []struct { + name string + req *http.Request + want string + setupEnv bool // when false, still sets env to trustCIDR for determinism + }{ + { + name: "nil request returns empty", + req: nil, + want: "", + setupEnv: true, + }, + { + name: "empty remote addr returns empty", + req: makeReq(t, "", nil), + want: "", + setupEnv: true, + }, + { + name: "untrusted remote ignores xff", + req: makeReq(t, "192.168.1.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}), + want: "192.168.1.1", + setupEnv: true, + }, + { + name: "untrusted remote ignores xreal ip", + req: makeReq(t, "192.168.1.1:1234", map[string]string{"X-Real-IP": "203.0.113.5"}), + want: "192.168.1.1", + setupEnv: true, + }, + { + name: "untrusted remote no headers returns remote", + req: makeReq(t, "192.168.1.1:1234", nil), + want: "192.168.1.1", + setupEnv: true, + }, + { + name: "trusted remote single untrusted xff hop", + req: makeReq(t, "10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}), + want: "203.0.113.5", + setupEnv: true, + }, + { + name: "trusted remote xff chain mixed trust", + req: makeReq(t, "10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5, 10.0.0.2"}), + want: "203.0.113.5", + setupEnv: true, + }, + { + name: "trusted remote all trusted xff returns leftmost", + req: makeReq(t, "10.0.0.1:1234", map[string]string{"X-Forwarded-For": "10.0.0.7, 10.0.0.8"}), + want: "10.0.0.7", + setupEnv: true, + }, + { + name: "trusted remote invalid xff falls back to valid xreal ip", + req: makeReq(t, "10.0.0.1:1234", map[string]string{"X-Forwarded-For": "not-an-ip", "X-Real-IP": "203.0.113.7"}), + want: "203.0.113.7", + setupEnv: true, + }, + { + name: "trusted remote empty xff falls back to xreal ip", + req: makeReq(t, "10.0.0.1:1234", map[string]string{"X-Real-IP": "203.0.113.9"}), + want: "203.0.113.9", + setupEnv: true, + }, + { + name: "trusted remote invalid xreal ip returns remote", + req: makeReq(t, "10.0.0.1:1234", map[string]string{"X-Real-IP": "not-an-ip"}), + want: "10.0.0.1", + setupEnv: true, + }, + { + name: "trusted remote bracketed xreal ip stripped", + req: makeReq(t, "10.0.0.1:1234", map[string]string{"X-Real-IP": "[203.0.113.10]"}), + want: "203.0.113.10", + setupEnv: true, + }, + { + name: "trusted remote empty xff and empty xreal ip returns remote", + req: makeReq(t, "10.0.0.1:1234", nil), + want: "10.0.0.1", + setupEnv: true, + }, + { + name: "trusted remote unparseable remote addr falls through", + req: makeReq(t, "garbage-no-port", map[string]string{"X-Forwarded-For": "203.0.113.5"}), + want: "garbage-no-port", + setupEnv: true, + }, + { + name: "no trust config treats remote as untrusted returns remote", + req: makeReq(t, "10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}), + want: "10.0.0.1", + setupEnv: false, // explicitly empty config below + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.setupEnv { + setupTrustEnv(t, trustCIDR, "") + } else { + // Explicit empty config: no CIDRs loaded. + setupTrustEnv(t, "", "") + } + got := ClientIP(tc.req) + if got != tc.want { + t.Errorf("ClientIP() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/mockmodel/metrics_branchcov0719_test.go b/internal/mockmodel/metrics_branchcov0719_test.go new file mode 100644 index 000000000..ea616f875 --- /dev/null +++ b/internal/mockmodel/metrics_branchcov0719_test.go @@ -0,0 +1,279 @@ +package mockmodel + +import ( + "math" + "testing" + "time" +) + +// fixedAnchor is the deterministic reference timestamp used across the +// branch-coverage tests in this file. The mock model is fully seeded, so fixing +// the timestamp and seeds makes every assertion below reproducible. +var fixedAnchor = time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC) + +// hourlyTimestamps returns n evenly-spaced hourly timestamps ending at end +// inclusive. For n<=0 it returns nil so callers can exercise the empty-input +// branch. +func hourlyTimestamps(end time.Time, n int) []time.Time { + if n <= 0 { + return nil + } + out := make([]time.Time, n) + for i := 0; i < n; i++ { + out[n-1-i] = end.Add(-time.Duration(i) * time.Hour) + } + return out +} + +// styleName renders a SeriesStyle for subtest labels. +func styleName(s SeriesStyle) string { + switch s { + case StyleSpiky: + return "spiky" + case StylePlateau: + return "plateau" + case StyleFlat: + return "flat" + default: + return "unknown" + } +} + +func TestNormalizeBlendWeight_BranchesAndClamps(t *testing.T) { + const eps = 1e-9 + cases := []struct { + name string + weight float64 + step time.Duration + reference time.Duration + wantExact bool + want float64 // exact expected value when wantExact is true + lo, hi float64 // inclusive bounds for the in-range scaling cases + }{ + {name: "negative weight clamps to floor", weight: -0.5, step: time.Minute, reference: 2 * time.Minute, wantExact: true, want: 0.01}, + {name: "zero weight clamps to floor", weight: 0, step: time.Minute, reference: 2 * time.Minute, wantExact: true, want: 0.01}, + {name: "at one returns one", weight: 1, step: time.Minute, reference: 2 * time.Minute, wantExact: true, want: 1}, + {name: "above one clamps to one", weight: 2.5, step: time.Minute, reference: 2 * time.Minute, wantExact: true, want: 1}, + {name: "zero step returns weight unchanged", weight: 0.5, step: 0, reference: 2 * time.Minute, wantExact: true, want: 0.5}, + {name: "zero reference returns weight unchanged", weight: 0.5, step: time.Minute, reference: 0, wantExact: true, want: 0.5}, + {name: "negative step returns weight unchanged", weight: 0.5, step: -time.Minute, reference: 2 * time.Minute, wantExact: true, want: 0.5}, + {name: "negative reference returns weight unchanged", weight: 0.5, step: time.Minute, reference: -2 * time.Minute, wantExact: true, want: 0.5}, + {name: "equal step and reference returns weight unchanged", weight: 0.5, step: 2 * time.Minute, reference: 2 * time.Minute, wantExact: true, want: 0.5}, + {name: "in-range short step scales below weight", weight: 0.5, step: time.Minute, reference: 4 * time.Minute, wantExact: false, lo: 0.0005, hi: 0.5}, + {name: "in-range long step scales above weight", weight: 0.5, step: 4 * time.Minute, reference: time.Minute, wantExact: false, lo: 0.5, hi: 0.999}, + {name: "extreme ratio triggers upper clamp", weight: 0.99, step: 24 * time.Hour, reference: time.Millisecond, wantExact: true, want: 0.999}, + {name: "tiny ratio triggers lower clamp", weight: 0.01, step: time.Millisecond, reference: time.Hour, wantExact: true, want: 0.0005}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := NormalizeBlendWeight(tc.weight, tc.step, tc.reference) + if tc.wantExact { + if math.Abs(got-tc.want) > eps { + t.Fatalf("NormalizeBlendWeight(%v,%v,%v) = %v, want %v", tc.weight, tc.step, tc.reference, got, tc.want) + } + return + } + if got < tc.lo || got > tc.hi { + t.Fatalf("NormalizeBlendWeight(%v,%v,%v) = %v, want within [%v,%v]", tc.weight, tc.step, tc.reference, got, tc.lo, tc.hi) + } + }) + } +} + +func TestNormalizeBlendWeight_Deterministic(t *testing.T) { + args := []struct { + w float64 + step time.Duration + ref time.Duration + }{ + {0.5, time.Minute, 4 * time.Minute}, + {0.3, 2 * time.Minute, 3 * time.Minute}, + {0.7, 30 * time.Second, 90 * time.Second}, + } + for _, a := range args { + first := NormalizeBlendWeight(a.w, a.step, a.ref) + second := NormalizeBlendWeight(a.w, a.step, a.ref) + third := NormalizeBlendWeight(a.w, a.step, a.ref) + if first != second || second != third { + t.Fatalf("expected deterministic output for (%v,%v,%v), got %v %v %v", a.w, a.step, a.ref, first, second, third) + } + } +} + +func TestSeriesForTimestamps_EmptyTimestampsReturnsEmpty(t *testing.T) { + for _, style := range []SeriesStyle{StyleSpiky, StylePlateau, StyleFlat} { + style := style + t.Run(styleName(style), func(t *testing.T) { + got := SeriesForTimestamps(50, nil, 7, 0, 100, style) + if len(got) != 0 { + t.Fatalf("expected len=0 for nil timestamps, got %d", len(got)) + } + got2 := SeriesForTimestamps(50, []time.Time{}, 7, 0, 100, style) + if len(got2) != 0 { + t.Fatalf("expected len=0 for zero-length timestamps, got %d", len(got2)) + } + }) + } +} + +func TestSeriesForTimestamps_LengthBoundsAndDeterminism(t *testing.T) { + timestamps := hourlyTimestamps(fixedAnchor, 6) + const min, max = 5.0, 95.0 + for _, style := range []SeriesStyle{StyleSpiky, StylePlateau, StyleFlat} { + style := style + t.Run(styleName(style), func(t *testing.T) { + series := SeriesForTimestamps(50, timestamps, 11, min, max, style) + if len(series) != len(timestamps) { + t.Fatalf("expected len=%d, got %d", len(timestamps), len(series)) + } + for i, v := range series { + if math.IsNaN(v) || math.IsInf(v, 0) { + t.Fatalf("idx %d: non-finite value %v", i, v) + } + if v < min || v > max { + t.Fatalf("idx %d: value %v outside [%v,%v]", i, v, min, max) + } + } + // In-range current anchors the tail to that exact value. + if series[len(series)-1] != 50.0 { + t.Fatalf("expected tail to equal in-range current 50, got %v", series[len(series)-1]) + } + // Determinism: identical args must produce identical output. + again := SeriesForTimestamps(50, timestamps, 11, min, max, style) + for i := range series { + if series[i] != again[i] { + t.Fatalf("non-deterministic at idx %d: %v vs %v", i, series[i], again[i]) + } + } + }) + } +} + +func TestSeriesForTimestamps_ClampsOutOfRangeCurrentToTail(t *testing.T) { + timestamps := hourlyTimestamps(fixedAnchor, 4) + const min, max = 10.0, 90.0 + + hi := SeriesForTimestamps(500, timestamps, 3, min, max, StyleSpiky) + if hi[len(hi)-1] != max { + t.Fatalf("expected above-range current clamped to max=%v at tail, got %v", max, hi[len(hi)-1]) + } + for _, v := range hi { + if v < min || v > max { + t.Fatalf("above-range series value %v outside [%v,%v]", v, min, max) + } + } + + lo := SeriesForTimestamps(-50, timestamps, 3, min, max, StyleSpiky) + if lo[len(lo)-1] != min { + t.Fatalf("expected below-range current clamped to min=%v at tail, got %v", min, lo[len(lo)-1]) + } + for _, v := range lo { + if v < min || v > max { + t.Fatalf("below-range series value %v outside [%v,%v]", v, min, max) + } + } +} + +// TestSeriesForProfile_AllProfilesBoundedAndDeterministic exercises the internal +// seriesForProfile helper directly across every metricProfile so each of its +// style branches receives coverage attribution. The values it produces must be +// finite, within [min,max], and identical across repeated calls. +func TestSeriesForProfile_AllProfilesBoundedAndDeterministic(t *testing.T) { + timestamps := hourlyTimestamps(fixedAnchor, 8) + const min, max = 0.0, 100.0 + profiles := []struct { + name string + p metricProfile + }{ + {"compute", profileCompute}, + {"memory", profileMemory}, + {"diskio", profileDiskIO}, + {"network", profileNetwork}, + {"capacity", profileCapacity}, + {"thermal", profileThermal}, + {"flat", profileFlat}, + } + for _, pc := range profiles { + pc := pc + t.Run(pc.name, func(t *testing.T) { + first := seriesForProfile(50, timestamps, 17, min, max, pc.p) + second := seriesForProfile(50, timestamps, 17, min, max, pc.p) + if len(first) != len(timestamps) { + t.Fatalf("%s: expected len=%d, got %d", pc.name, len(timestamps), len(first)) + } + for i, v := range first { + if math.IsNaN(v) || math.IsInf(v, 0) { + t.Fatalf("%s idx %d: non-finite %v", pc.name, i, v) + } + if v < min || v > max { + t.Fatalf("%s idx %d: %v outside [%v,%v]", pc.name, i, v, min, max) + } + if v != second[i] { + t.Fatalf("%s idx %d: non-deterministic %v vs %v", pc.name, i, v, second[i]) + } + } + if first[len(first)-1] != 50.0 { + t.Fatalf("%s: expected tail to equal in-range current 50, got %v", pc.name, first[len(first)-1]) + } + }) + } +} + +func TestSeriesForProfile_EmptyTimestampsReturnsNil(t *testing.T) { + for _, p := range []metricProfile{profileCompute, profileMemory, profileDiskIO, profileFlat} { + got := seriesForProfile(50, nil, 1, 0, 100, p) + if got != nil { + t.Fatalf("profile %d: expected nil for empty timestamps, got %v (len=%d)", p, got, len(got)) + } + } +} + +// TestDiskIOValue_BoundedAndDeterministic asserts the documented behaviour of +// diskIOValue: identical inputs produce identical output, the result is finite, +// and across every realistic role modifier set the value stays inside +// [min, min+span]. The bounds are verified empirically across all role profiles. +func TestDiskIOValue_BoundedAndDeterministic(t *testing.T) { + const min, span = 10.0, 100.0 + const hi = min + span + for _, role := range []string{"", "database", "backup", "web", "storage", "ci"} { + mods := metricRoleProfile(role) + for _, seed := range []uint64{0, 1, 7, 42, 99} { + a := diskIOValue(seed, min, span, mods, fixedAnchor) + b := diskIOValue(seed, min, span, mods, fixedAnchor) + if a != b { + t.Fatalf("role=%q seed=%d: non-deterministic %v vs %v", role, seed, a, b) + } + if math.IsNaN(a) || math.IsInf(a, 0) { + t.Fatalf("role=%q seed=%d: non-finite %v", role, seed, a) + } + if a < min || a > hi { + t.Fatalf("role=%q seed=%d: %v outside [%v,%v]", role, seed, a, min, hi) + } + } + } +} + +// TestFlatValue_BoundedAndDeterministic asserts the same shape of guarantees +// for flatValue as TestDiskIOValue does for diskIOValue. +func TestFlatValue_BoundedAndDeterministic(t *testing.T) { + const min, span = 10.0, 100.0 + const hi = min + span + for _, role := range []string{"", "database", "storage", "cache", "media"} { + mods := metricRoleProfile(role) + for _, seed := range []uint64{0, 1, 5, 23, 77} { + a := flatValue(seed, min, span, mods, fixedAnchor) + b := flatValue(seed, min, span, mods, fixedAnchor) + if a != b { + t.Fatalf("role=%q seed=%d: non-deterministic %v vs %v", role, seed, a, b) + } + if math.IsNaN(a) || math.IsInf(a, 0) { + t.Fatalf("role=%q seed=%d: non-finite %v", role, seed, a) + } + if a < min || a > hi { + t.Fatalf("role=%q seed=%d: %v outside [%v,%v]", role, seed, a, min, hi) + } + } + } +} diff --git a/internal/operationaltrust/contracts_branchcov0719_test.go b/internal/operationaltrust/contracts_branchcov0719_test.go new file mode 100644 index 000000000..29f02a927 --- /dev/null +++ b/internal/operationaltrust/contracts_branchcov0719_test.go @@ -0,0 +1,391 @@ +package operationaltrust + +import ( + "strings" + "testing" + "time" +) + +func TestEvidencePayloadRefValidate(t *testing.T) { + valid := EvidencePayloadRef{Kind: "metric", ID: "payload-1"} + + tests := []struct { + name string + ref EvidencePayloadRef + wantError string + }{ + { + name: "valid", + ref: valid, + wantError: "", + }, + { + name: "missing kind", + ref: EvidencePayloadRef{ID: "payload-1"}, + wantError: "kind is required", + }, + { + name: "whitespace kind", + ref: EvidencePayloadRef{Kind: " ", ID: "payload-1"}, + wantError: "kind is required", + }, + { + name: "missing id", + ref: EvidencePayloadRef{Kind: "metric"}, + wantError: "id is required", + }, + { + name: "whitespace id", + ref: EvidencePayloadRef{Kind: "metric", ID: "\t "}, + wantError: "id is required", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.ref.Validate() + if tc.wantError == "" { + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("Validate() error = %v, want containing %q", err, tc.wantError) + } + }) + } +} + +func TestAcknowledgementValidate(t *testing.T) { + at := time.Date(2026, 7, 18, 20, 0, 0, 0, time.UTC) + valid := Acknowledgement{At: at, By: "operator-1", Note: "acknowledged"} + + tests := []struct { + name string + ack Acknowledgement + wantError string + }{ + { + name: "valid", + ack: valid, + wantError: "", + }, + { + name: "zero time", + ack: Acknowledgement{By: "operator-1"}, + wantError: "time is required", + }, + { + name: "missing actor", + ack: Acknowledgement{At: at, By: ""}, + wantError: "actor is required", + }, + { + name: "whitespace actor", + ack: Acknowledgement{At: at, By: " "}, + wantError: "actor is required", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.ack.Validate() + if tc.wantError == "" { + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("Validate() error = %v, want containing %q", err, tc.wantError) + } + }) + } +} + +func TestSuppressionValidate(t *testing.T) { + at := time.Date(2026, 7, 18, 20, 0, 0, 0, time.UTC) + later := at.Add(time.Hour) + earlier := at.Add(-time.Minute) + equal := at + + tests := []struct { + name string + sup Suppression + wantError string + }{ + { + name: "valid without expiry", + sup: Suppression{At: at, By: "operator-1", Reason: "maintenance"}, + wantError: "", + }, + { + name: "valid with expiry", + sup: Suppression{At: at, By: "operator-1", Reason: "maintenance", ExpiresAt: &later}, + wantError: "", + }, + { + name: "zero time", + sup: Suppression{By: "operator-1", Reason: "maintenance"}, + wantError: "time is required", + }, + { + name: "missing actor", + sup: Suppression{At: at, Reason: "maintenance"}, + wantError: "actor is required", + }, + { + name: "whitespace reason", + sup: Suppression{At: at, By: "operator-1", Reason: " "}, + wantError: "reason is required", + }, + { + name: "expiry before at", + sup: Suppression{At: at, By: "operator-1", Reason: "maintenance", ExpiresAt: &earlier}, + wantError: "expiry must follow", + }, + { + name: "expiry equal at", + sup: Suppression{At: at, By: "operator-1", Reason: "maintenance", ExpiresAt: &equal}, + wantError: "expiry must follow", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.sup.Validate() + if tc.wantError == "" { + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("Validate() error = %v, want containing %q", err, tc.wantError) + } + }) + } +} + +func TestLifecycleTransitionClone(t *testing.T) { + t.Run("evidence slice is independent", func(t *testing.T) { + transition := LifecycleTransition{ + ID: "transition-1", + OperationalRecordID: "record-1", + From: OperationalObserving, + To: OperationalOpen, + EvidenceIDs: []string{"evidence-a", "evidence-b"}, + } + clone := transition.Clone() + clone.ID = "transition-2" + clone.EvidenceIDs[0] = "evidence-mutated" + clone.EvidenceIDs = append(clone.EvidenceIDs, "evidence-new") + + if transition.ID != "transition-1" { + t.Fatalf("clone mutated source ID: got %q", transition.ID) + } + if transition.EvidenceIDs[0] != "evidence-a" { + t.Fatalf("clone mutated source EvidenceIDs[0]: got %q", transition.EvidenceIDs[0]) + } + if len(transition.EvidenceIDs) != 2 { + t.Fatalf("clone mutated source EvidenceIDs length: got %d, want 2", len(transition.EvidenceIDs)) + } + if clone.EvidenceIDs[0] != "evidence-mutated" { + t.Fatalf("clone did not receive mutation: got %q", clone.EvidenceIDs[0]) + } + }) + + t.Run("nil evidence slice stays nil-safe", func(t *testing.T) { + transition := LifecycleTransition{ + ID: "transition-1", + OperationalRecordID: "record-1", + From: OperationalObserving, + To: OperationalOpen, + } + clone := transition.Clone() + if clone.ID != "transition-1" { + t.Fatalf("clone lost scalar ID: got %q", clone.ID) + } + if clone.From != OperationalObserving || clone.To != OperationalOpen { + t.Fatalf("clone lost state scalars: from=%q to=%q", clone.From, clone.To) + } + if clone.EvidenceIDs != nil { + t.Fatalf("clone EvidenceIDs = %v, want nil", clone.EvidenceIDs) + } + }) +} + +func TestLifecycleTransitionValidate(t *testing.T) { + at := time.Date(2026, 7, 18, 20, 0, 0, 0, time.UTC) + base := LifecycleTransition{ + ID: "transition-1", + OperationalRecordID: "record-1", + From: OperationalObserving, + To: OperationalOpen, + At: at, + Cause: TransitionDetectorDecision, + CauseKey: "cpu-high", + EvidenceIDs: []string{"evidence-1"}, + } + + tests := []struct { + name string + mutate func(LifecycleTransition) LifecycleTransition + wantError string + }{ + { + name: "valid", + mutate: func(t LifecycleTransition) LifecycleTransition { return t }, + wantError: "", + }, + { + name: "missing id", + mutate: func(t LifecycleTransition) LifecycleTransition { + t.ID = "" + return t + }, + wantError: "transition id is required", + }, + { + name: "whitespace id treated as missing", + mutate: func(t LifecycleTransition) LifecycleTransition { + t.ID = " " + return t + }, + wantError: "transition id is required", + }, + { + name: "delegates missing operational record id", + mutate: func(t LifecycleTransition) LifecycleTransition { + t.OperationalRecordID = "" + return t + }, + wantError: "operational record id is required", + }, + { + name: "delegates invalid from state", + mutate: func(t LifecycleTransition) LifecycleTransition { + t.From = OperationalState("bogus") + return t + }, + wantError: "from state", + }, + { + name: "delegates invalid to state", + mutate: func(t LifecycleTransition) LifecycleTransition { + t.To = OperationalState("bogus") + return t + }, + wantError: "to state", + }, + { + name: "delegates no-op transition", + mutate: func(t LifecycleTransition) LifecycleTransition { + t.To = OperationalObserving + return t + }, + wantError: "must change state", + }, + { + name: "delegates missing transition time", + mutate: func(t LifecycleTransition) LifecycleTransition { + t.At = time.Time{} + return t + }, + wantError: "transition time is required", + }, + { + name: "delegates missing cause key", + mutate: func(t LifecycleTransition) LifecycleTransition { + t.CauseKey = "" + return t + }, + wantError: "cause key is required", + }, + { + name: "delegates detector decision requires evidence", + mutate: func(t LifecycleTransition) LifecycleTransition { + t.EvidenceIDs = nil + return t + }, + wantError: "detector decision requires evidence", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + transition := tc.mutate(base) + err := transition.Validate() + if tc.wantError == "" { + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("Validate() error = %v, want containing %q", err, tc.wantError) + } + }) + } +} + +func TestNotificationLinkClone(t *testing.T) { + t.Run("time pointers are independent", func(t *testing.T) { + attemptedAt := time.Date(2026, 7, 18, 20, 0, 0, 0, time.UTC) + completedAt := attemptedAt.Add(time.Second) + link := NotificationLink{ + NotificationID: "notification-1", + OperationalRecordID: "record-1", + TransitionID: "transition-1", + LifecycleState: OperationalOpen, + CauseKey: "cpu-high", + DestinationID: "email-primary", + DeliveryState: NotificationQueued, + AttemptedAt: &attemptedAt, + CompletedAt: &completedAt, + } + originalAttempted := *link.AttemptedAt + originalCompleted := *link.CompletedAt + + clone := link.Clone() + *clone.AttemptedAt = attemptedAt.Add(time.Hour) + *clone.CompletedAt = completedAt.Add(time.Hour) + clone.NotificationID = "notification-2" + + if link.NotificationID != "notification-1" { + t.Fatalf("clone mutated source NotificationID: got %q", link.NotificationID) + } + if !link.AttemptedAt.Equal(originalAttempted) { + t.Fatalf("clone mutated source AttemptedAt: got %v, want %v", link.AttemptedAt, originalAttempted) + } + if !link.CompletedAt.Equal(originalCompleted) { + t.Fatalf("clone mutated source CompletedAt: got %v, want %v", link.CompletedAt, originalCompleted) + } + if !clone.AttemptedAt.Equal(attemptedAt.Add(time.Hour)) { + t.Fatalf("clone did not receive AttemptedAt mutation: got %v", clone.AttemptedAt) + } + }) + + t.Run("nil time pointers stay nil", func(t *testing.T) { + link := NotificationLink{ + NotificationID: "notification-1", + OperationalRecordID: "record-1", + TransitionID: "transition-1", + LifecycleState: OperationalOpen, + CauseKey: "cpu-high", + DestinationID: "email-primary", + DeliveryState: NotificationQueued, + } + clone := link.Clone() + if clone.NotificationID != "notification-1" { + t.Fatalf("clone lost scalar NotificationID: got %q", clone.NotificationID) + } + if clone.AttemptedAt != nil { + t.Fatalf("clone AttemptedAt = %v, want nil", clone.AttemptedAt) + } + if clone.CompletedAt != nil { + t.Fatalf("clone CompletedAt = %v, want nil", clone.CompletedAt) + } + }) +} diff --git a/internal/recovery/keys_loosecontinuity_branchcov0719_test.go b/internal/recovery/keys_loosecontinuity_branchcov0719_test.go new file mode 100644 index 000000000..d4e5bb5ea --- /dev/null +++ b/internal/recovery/keys_loosecontinuity_branchcov0719_test.go @@ -0,0 +1,195 @@ +package recovery + +import "testing" + +// TestProxmoxPBSGuestLooseContinuityKey exercises every branch of +// ProxmoxPBSGuestLooseContinuityKey in keys.go: the itemType guard (default +// arm), the empty entityIDLabel guard, the empty/whitespace subjectLabel guard +// (via normalizeContinuityLabel), the numeric-only subjectLabel guard, and the +// success path for both "vm" and "system-container" (including normalization of +// raw item-type aliases through NormalizeRecoveryItemType). +func TestProxmoxPBSGuestLooseContinuityKey(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + subjectLabel string + itemType string + entityIDLabel string + want string + }{ + // itemType switch: default arm returns "" for any item type that does + // not normalize to "vm" or "system-container". + { + name: "itemType app-container rejected", + subjectLabel: "web-server", + itemType: "app-container", + entityIDLabel: "100", + want: "", + }, + { + name: "itemType pvc rejected", + subjectLabel: "web-server", + itemType: "pvc", + entityIDLabel: "100", + want: "", + }, + { + name: "itemType empty rejected", + subjectLabel: "web-server", + itemType: "", + entityIDLabel: "100", + want: "", + }, + { + name: "itemType all normalizes to empty and is rejected", + subjectLabel: "web-server", + itemType: "all", + entityIDLabel: "100", + want: "", + }, + // entityIDLabel empty guard (both literal empty and whitespace-only, + // since the implementation strings.TrimSpace's the value before checking). + { + name: "vm empty entityIDLabel returns empty", + subjectLabel: "web-server", + itemType: "vm", + entityIDLabel: "", + want: "", + }, + { + name: "vm whitespace entityIDLabel returns empty", + subjectLabel: "web-server", + itemType: "vm", + entityIDLabel: " ", + want: "", + }, + // subjectLabel that normalizes to empty (normalizeContinuityLabel + // returns "" for empty/whitespace-only input). + { + name: "vm empty subjectLabel returns empty", + subjectLabel: "", + itemType: "vm", + entityIDLabel: "100", + want: "", + }, + { + name: "vm whitespace subjectLabel returns empty", + subjectLabel: " \t\n", + itemType: "vm", + entityIDLabel: "100", + want: "", + }, + // numeric-only subjectLabel guard (isNumericOnlyLabel matches digits only). + { + name: "vm numeric-only subjectLabel returns empty", + subjectLabel: "100", + itemType: "vm", + entityIDLabel: "100", + want: "", + }, + { + name: "system-container numeric-only subjectLabel returns empty", + subjectLabel: "00140", + itemType: "system-container", + entityIDLabel: "140", + want: "", + }, + // Success path: vm. + { + name: "vm success builds loose key without namespace", + subjectLabel: "web-server", + itemType: "vm", + entityIDLabel: "100", + want: "proxmox-pbs-guest-loose:vm:100:web-server", + }, + // Success path: system-container. + { + name: "system-container success builds loose key without namespace", + subjectLabel: "pulse-v4-prod", + itemType: "system-container", + entityIDLabel: "140", + want: "proxmox-pbs-guest-loose:system-container:140:pulse-v4-prod", + }, + // NormalizeRecoveryItemType maps alias item types onto "vm"/"system-container" + // and the loose key uses the normalized value. + { + name: "proxmox-vm alias normalizes to vm in loose key", + subjectLabel: "web-server", + itemType: "proxmox-vm", + entityIDLabel: "100", + want: "proxmox-pbs-guest-loose:vm:100:web-server", + }, + { + name: "lxc alias normalizes to system-container in loose key", + subjectLabel: "pulse-v4-prod", + itemType: "lxc", + entityIDLabel: "140", + want: "proxmox-pbs-guest-loose:system-container:140:pulse-v4-prod", + }, + // normalizeContinuityLabel lowercases input and collapses internal + // whitespace; the loose key embeds the normalized label. + { + name: "subjectLabel normalized (case folded, whitespace collapsed)", + subjectLabel: " Web Server ", + itemType: "vm", + entityIDLabel: "100", + want: "proxmox-pbs-guest-loose:vm:100:web server", + }, + // entityIDLabel is only TrimSpace'd (not lowercased); the loose key + // preserves its case and surrounding whitespace is stripped. + { + name: "entityIDLabel trimmed but case preserved", + subjectLabel: "web-server", + itemType: "vm", + entityIDLabel: " VM-100 ", + want: "proxmox-pbs-guest-loose:vm:VM-100:web-server", + }, + // Sanity check that the loose key is structurally distinct from the + // conservative key (no namespace segment between itemType and entityID). + { + name: "loose key omits namespace segment present in conservative key", + subjectLabel: "pulse-v4-prod", + itemType: "system-container", + entityIDLabel: "140", + want: "proxmox-pbs-guest-loose:system-container:140:pulse-v4-prod", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := ProxmoxPBSGuestLooseContinuityKey(tc.subjectLabel, tc.itemType, tc.entityIDLabel) + if got != tc.want { + t.Fatalf("ProxmoxPBSGuestLooseContinuityKey(%q, %q, %q) = %q, want %q", + tc.subjectLabel, tc.itemType, tc.entityIDLabel, got, tc.want) + } + }) + } +} + +// TestProxmoxPBSGuestLooseContinuityKey_OmitsNamespace guarantees directly that +// for the same (subjectLabel, itemType, entityIDLabel) tuple, the loose key +// never embeds a namespace segment and differs from a hypothetical key that did +// include one. This pins the doc-comment promise at keys.go:228-229. +func TestProxmoxPBSGuestLooseContinuityKey_OmitsNamespace(t *testing.T) { + t.Parallel() + + got := ProxmoxPBSGuestLooseContinuityKey("pulse-v4-prod", "system-container", "140") + want := "proxmox-pbs-guest-loose:system-container:140:pulse-v4-prod" + if got != want { + t.Fatalf("loose key = %q, want %q", got, want) + } + + // The conservative key for the same identity includes a namespace segment; + // the loose key must be a strict prefix-without-namespace and must not equal + // any key that contains a namespace value. + conservative := ProxmoxPBSGuestContinuityKey("pulse-v4-prod", "system-container", "pimox", "140") + if conservative == "" { + t.Fatalf("conservative key unexpectedly empty; test setup invalid") + } + if got == conservative { + t.Fatalf("loose key (%q) must not equal conservative key with namespace (%q)", got, conservative) + } +} diff --git a/internal/recovery/model/posture_branchcov0719_test.go b/internal/recovery/model/posture_branchcov0719_test.go new file mode 100644 index 000000000..547925414 --- /dev/null +++ b/internal/recovery/model/posture_branchcov0719_test.go @@ -0,0 +1,475 @@ +package model + +import ( + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust" +) + +// validProviderState returns a ProtectionProviderState that is known to pass +// Validate. Test cases mutate a single field off this baseline to trip one +// validation branch at a time. +func validProviderState() ProtectionProviderState { + return ProtectionProviderState{ + Provider: ProviderProxmoxPVE, + Source: "source-1", + Scope: "scope-1", + JobState: OutcomeSuccess, + HistoryCompleteness: ProtectionHistoryComplete, + Permissions: operationaltrust.EvidencePermissionsSufficient, + EvidenceIDs: []string{"evidence-1"}, + } +} + +// validPosture returns a ProtectionPosture that is known to pass Validate. +func validPosture() ProtectionPosture { + at := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + return ProtectionPosture{ + SubjectResourceID: "resource-1", + State: ProtectionStateProtected, + LastAttemptAt: &at, + LastSuccessfulPointAt: &at, + LastVerifiedAt: &at, + Freshness: ProtectionFreshnessCurrent, + Verification: ProtectionVerificationVerified, + Coverage: ProtectionCoverageComplete, + ProviderStates: []ProtectionProviderState{validProviderState()}, + RepositoryResourceIDs: []string{"repo-1"}, + EvidenceIDs: []string{"evidence-1"}, + Explanation: "explanation-1", + EvaluatedAt: at, + } +} + +func TestProtectionState_Valid(t *testing.T) { + for _, tc := range []struct { + name string + state ProtectionState + want bool + }{ + {"protected", ProtectionStateProtected, true}, + {"attention", ProtectionStateAttention, true}, + {"unprotected", ProtectionStateUnprotected, true}, + {"unknown", ProtectionStateUnknown, true}, + {"bogus", ProtectionState("bogus"), false}, + {"empty", ProtectionState(""), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.state.Valid(); got != tc.want { + t.Fatalf("ProtectionState(%q).Valid() = %v, want %v", tc.state, got, tc.want) + } + }) + } +} + +func TestProtectionFreshness_Valid(t *testing.T) { + for _, tc := range []struct { + name string + freshness ProtectionFreshness + want bool + }{ + {"current", ProtectionFreshnessCurrent, true}, + {"stale", ProtectionFreshnessStale, true}, + {"unknown", ProtectionFreshnessUnknown, true}, + {"bogus", ProtectionFreshness("bogus"), false}, + {"empty", ProtectionFreshness(""), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.freshness.Valid(); got != tc.want { + t.Fatalf("ProtectionFreshness(%q).Valid() = %v, want %v", tc.freshness, got, tc.want) + } + }) + } +} + +func TestProtectionVerification_Valid(t *testing.T) { + for _, tc := range []struct { + name string + verification ProtectionVerification + want bool + }{ + {"verified", ProtectionVerificationVerified, true}, + {"unverified", ProtectionVerificationUnverified, true}, + {"stale", ProtectionVerificationStale, true}, + {"unknown", ProtectionVerificationUnknown, true}, + {"bogus", ProtectionVerification("bogus"), false}, + {"empty", ProtectionVerification(""), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.verification.Valid(); got != tc.want { + t.Fatalf("ProtectionVerification(%q).Valid() = %v, want %v", tc.verification, got, tc.want) + } + }) + } +} + +func TestProtectionCoverage_Valid(t *testing.T) { + for _, tc := range []struct { + name string + coverage ProtectionCoverage + want bool + }{ + {"complete", ProtectionCoverageComplete, true}, + {"partial", ProtectionCoveragePartial, true}, + {"none", ProtectionCoverageNone, true}, + {"unknown", ProtectionCoverageUnknown, true}, + {"bogus", ProtectionCoverage("bogus"), false}, + {"empty", ProtectionCoverage(""), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.coverage.Valid(); got != tc.want { + t.Fatalf("ProtectionCoverage(%q).Valid() = %v, want %v", tc.coverage, got, tc.want) + } + }) + } +} + +func TestProtectionHistoryCompleteness_Valid(t *testing.T) { + for _, tc := range []struct { + name string + completeness ProtectionHistoryCompleteness + want bool + }{ + {"complete", ProtectionHistoryComplete, true}, + {"partial", ProtectionHistoryPartial, true}, + {"unavailable", ProtectionHistoryUnavailable, true}, + {"unknown", ProtectionHistoryUnknown, true}, + {"bogus", ProtectionHistoryCompleteness("bogus"), false}, + {"empty", ProtectionHistoryCompleteness(""), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.completeness.Valid(); got != tc.want { + t.Fatalf("ProtectionHistoryCompleteness(%q).Valid() = %v, want %v", tc.completeness, got, tc.want) + } + }) + } +} + +func TestProtectionProviderState_Clone(t *testing.T) { + at := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + state := ProtectionProviderState{ + Provider: ProviderProxmoxPVE, + Source: "source-1", + Scope: "scope-1", + JobState: OutcomeSuccess, + HistoryCompleteness: ProtectionHistoryComplete, + Permissions: operationaltrust.EvidencePermissionsSufficient, + LastAttemptAt: &at, + LastSuccessAt: &at, + LastVerifiedAt: &at, + EvidenceIDs: []string{"evidence-1", "evidence-2"}, + VerificationExpected: true, + } + + t.Run("evidence slice mutation is isolated", func(t *testing.T) { + clone := state.Clone() + clone.EvidenceIDs[0] = "evidence-mutated" + clone.EvidenceIDs = append(clone.EvidenceIDs, "evidence-3") + if state.EvidenceIDs[0] != "evidence-1" { + t.Fatalf("clone slice element mutation propagated to original: state.EvidenceIDs[0] = %q", state.EvidenceIDs[0]) + } + if len(state.EvidenceIDs) != 2 { + t.Fatalf("clone append propagated to original: len(state.EvidenceIDs) = %d, want 2", len(state.EvidenceIDs)) + } + }) + + t.Run("time pointer mutation is isolated", func(t *testing.T) { + clone := state.Clone() + if clone.LastAttemptAt == state.LastAttemptAt { + t.Fatal("Clone aliased LastAttemptAt pointer instead of copying") + } + *clone.LastAttemptAt = at.Add(time.Hour) + if !state.LastAttemptAt.Equal(at) { + t.Fatalf("clone pointer mutation propagated to original: state.LastAttemptAt = %v, want %v", state.LastAttemptAt, at) + } + }) + + t.Run("nil pointers and slices stay nil", func(t *testing.T) { + empty := ProtectionProviderState{} + clone := empty.Clone() + if clone.LastAttemptAt != nil || clone.LastSuccessAt != nil || clone.LastVerifiedAt != nil { + t.Fatalf("Clone of nil pointers produced non-nil pointers: %+v", clone) + } + if clone.EvidenceIDs != nil { + t.Fatalf("Clone of nil EvidenceIDs produced non-nil: %v", clone.EvidenceIDs) + } + }) +} + +func TestProtectionProviderState_Validate(t *testing.T) { + for _, tc := range []struct { + name string + modify func(s *ProtectionProviderState) + wantErr string // substring of error message; "" means expect success + }{ + { + name: "passes when fully populated", + modify: func(s *ProtectionProviderState) {}, + wantErr: "", + }, + { + name: "missing provider", + modify: func(s *ProtectionProviderState) { s.Provider = "" }, + wantErr: "protection provider is required", + }, + { + name: "whitespace-only provider", + modify: func(s *ProtectionProviderState) { s.Provider = Provider(" ") }, + wantErr: "protection provider is required", + }, + { + name: "missing source", + modify: func(s *ProtectionProviderState) { s.Source = "" }, + wantErr: "protection provider source is required", + }, + { + name: "missing scope", + modify: func(s *ProtectionProviderState) { s.Scope = "" }, + wantErr: "protection provider scope is required", + }, + { + name: "invalid job state", + modify: func(s *ProtectionProviderState) { s.JobState = Outcome("bogus") }, + wantErr: "is invalid", + }, + { + name: "invalid history completeness", + modify: func(s *ProtectionProviderState) { s.HistoryCompleteness = ProtectionHistoryCompleteness("bogus") }, + wantErr: "is invalid", + }, + { + name: "invalid permissions", + modify: func(s *ProtectionProviderState) { s.Permissions = operationaltrust.EvidencePermissions("bogus") }, + wantErr: "are invalid", + }, + { + name: "unsorted evidence ids", + modify: func(s *ProtectionProviderState) { s.EvidenceIDs = []string{"b", "a"} }, + wantErr: "must be sorted and unique", + }, + { + name: "duplicate evidence ids", + modify: func(s *ProtectionProviderState) { s.EvidenceIDs = []string{"a", "a"} }, + wantErr: "must be sorted and unique", + }, + { + name: "blank evidence id", + modify: func(s *ProtectionProviderState) { s.EvidenceIDs = []string{" "} }, + wantErr: "must be sorted and unique", + }, + } { + t.Run(tc.name, func(t *testing.T) { + state := validProviderState() + tc.modify(&state) + err := state.Validate() + if tc.wantErr == "" { + if err != nil { + t.Fatalf("Validate() unexpected error = %v", err) + } + return + } + if err == nil { + t.Fatalf("Validate() expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tc.wantErr) + } + }) + } +} + +func TestProtectionPosture_Clone(t *testing.T) { + at := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + posture := ProtectionPosture{ + SubjectResourceID: "resource-1", + State: ProtectionStateProtected, + LastAttemptAt: &at, + LastSuccessfulPointAt: &at, + LastVerifiedAt: &at, + Freshness: ProtectionFreshnessCurrent, + Verification: ProtectionVerificationVerified, + Coverage: ProtectionCoverageComplete, + ProviderStates: []ProtectionProviderState{ + { + Provider: ProviderProxmoxPVE, + Source: "source-1", + Scope: "scope-1", + JobState: OutcomeSuccess, + HistoryCompleteness: ProtectionHistoryComplete, + Permissions: operationaltrust.EvidencePermissionsSufficient, + EvidenceIDs: []string{"evidence-1"}, + }, + }, + RepositoryResourceIDs: []string{"repo-1"}, + EvidenceIDs: []string{"evidence-1"}, + Explanation: "explanation-1", + EvaluatedAt: at, + } + + t.Run("nested provider state slice is deep-copied", func(t *testing.T) { + clone := posture.Clone() + clone.ProviderStates[0].EvidenceIDs[0] = "evidence-mutated" + clone.ProviderStates[0].Source = "source-mutated" + if posture.ProviderStates[0].EvidenceIDs[0] != "evidence-1" { + t.Fatalf("nested evidence slice mutation propagated to original: %q", posture.ProviderStates[0].EvidenceIDs[0]) + } + if posture.ProviderStates[0].Source != "source-1" { + t.Fatalf("nested field mutation propagated to original: %q", posture.ProviderStates[0].Source) + } + }) + + t.Run("provider slice append is isolated", func(t *testing.T) { + clone := posture.Clone() + clone.ProviderStates = append(clone.ProviderStates, ProtectionProviderState{ + Provider: ProviderKubernetes, + }) + if len(posture.ProviderStates) != 1 { + t.Fatalf("clone append propagated to original: len(posture.ProviderStates) = %d, want 1", len(posture.ProviderStates)) + } + }) + + t.Run("time pointers are deep-copied", func(t *testing.T) { + clone := posture.Clone() + if clone.LastAttemptAt == posture.LastAttemptAt { + t.Fatal("Clone aliased LastAttemptAt pointer instead of copying") + } + *clone.LastAttemptAt = at.Add(time.Hour) + if !posture.LastAttemptAt.Equal(at) { + t.Fatalf("clone pointer mutation propagated to original: posture.LastAttemptAt = %v, want %v", posture.LastAttemptAt, at) + } + }) + + t.Run("string slices are deep-copied", func(t *testing.T) { + clone := posture.Clone() + clone.RepositoryResourceIDs[0] = "repo-mutated" + clone.EvidenceIDs[0] = "evidence-mutated" + if posture.RepositoryResourceIDs[0] != "repo-1" { + t.Fatalf("RepositoryResourceIDs mutation propagated to original: %q", posture.RepositoryResourceIDs[0]) + } + if posture.EvidenceIDs[0] != "evidence-1" { + t.Fatalf("EvidenceIDs mutation propagated to original: %q", posture.EvidenceIDs[0]) + } + }) +} + +func TestProtectionPosture_Validate(t *testing.T) { + for _, tc := range []struct { + name string + modify func(p *ProtectionPosture) + wantErr string + }{ + { + name: "passes when fully populated", + modify: func(p *ProtectionPosture) {}, + wantErr: "", + }, + { + name: "missing subject resource id", + modify: func(p *ProtectionPosture) { p.SubjectResourceID = "" }, + wantErr: "protection posture subject resource id is required", + }, + { + name: "invalid state", + modify: func(p *ProtectionPosture) { p.State = ProtectionState("bogus") }, + wantErr: "protection posture state", + }, + { + name: "invalid freshness", + modify: func(p *ProtectionPosture) { p.Freshness = ProtectionFreshness("bogus") }, + wantErr: "protection posture freshness", + }, + { + name: "invalid verification", + modify: func(p *ProtectionPosture) { p.Verification = ProtectionVerification("bogus") }, + wantErr: "protection posture verification", + }, + { + name: "invalid coverage", + modify: func(p *ProtectionPosture) { p.Coverage = ProtectionCoverage("bogus") }, + wantErr: "protection posture coverage", + }, + { + name: "zero evaluated at", + modify: func(p *ProtectionPosture) { p.EvaluatedAt = time.Time{} }, + wantErr: "protection posture evaluation time is required", + }, + { + name: "missing explanation", + modify: func(p *ProtectionPosture) { p.Explanation = "" }, + wantErr: "protection posture explanation is required", + }, + { + name: "unsorted repository resource ids", + modify: func(p *ProtectionPosture) { p.RepositoryResourceIDs = []string{"z", "a"} }, + wantErr: "protection repository resource ids must be sorted and unique", + }, + { + name: "unsorted evidence ids", + modify: func(p *ProtectionPosture) { p.EvidenceIDs = []string{"z", "a"} }, + wantErr: "protection evidence ids must be sorted and unique", + }, + { + name: "provider state validation error is wrapped with index", + modify: func(p *ProtectionPosture) { + p.ProviderStates = []ProtectionProviderState{ + { + Provider: "", + Source: "source-1", + Scope: "scope-1", + JobState: OutcomeSuccess, + HistoryCompleteness: ProtectionHistoryComplete, + Permissions: operationaltrust.EvidencePermissionsSufficient, + EvidenceIDs: []string{"evidence-1"}, + }, + } + }, + wantErr: "protection provider state 0:", + }, + { + name: "provider states must be sorted and unique", + modify: func(p *ProtectionPosture) { + p.ProviderStates = []ProtectionProviderState{ + { + Provider: ProviderProxmoxPVE, + Source: "source-1", + Scope: "scope-1", + JobState: OutcomeSuccess, + HistoryCompleteness: ProtectionHistoryComplete, + Permissions: operationaltrust.EvidencePermissionsSufficient, + EvidenceIDs: []string{"evidence-1"}, + }, + { + Provider: ProviderProxmoxPBS, + Source: "source-1", + Scope: "scope-1", + JobState: OutcomeSuccess, + HistoryCompleteness: ProtectionHistoryComplete, + Permissions: operationaltrust.EvidencePermissionsSufficient, + EvidenceIDs: []string{"evidence-2"}, + }, + } + }, + wantErr: "protection provider states must be sorted and unique", + }, + } { + t.Run(tc.name, func(t *testing.T) { + posture := validPosture() + tc.modify(&posture) + err := posture.Validate() + if tc.wantErr == "" { + if err != nil { + t.Fatalf("Validate() unexpected error = %v", err) + } + return + } + if err == nil { + t.Fatalf("Validate() expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tc.wantErr) + } + }) + } +} diff --git a/internal/recovery/model/posture_helpers_branchcov0719_test.go b/internal/recovery/model/posture_helpers_branchcov0719_test.go new file mode 100644 index 000000000..a15014110 --- /dev/null +++ b/internal/recovery/model/posture_helpers_branchcov0719_test.go @@ -0,0 +1,580 @@ +package model + +import ( + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust" +) + +// validProviderObservation builds a ProtectionProviderObservation that passes +// Validate(). Fields are concrete and deterministic so individual error +// branches can be exercised by mutating one field at a time. +func validProviderObservation(t *testing.T) ProtectionProviderObservation { + t.Helper() + observedAt := time.Date(2026, 7, 18, 20, 0, 0, 0, time.UTC) + ingestedAt := observedAt.Add(2 * time.Second) + source := operationaltrust.EvidenceSource{Provider: "proxmox", Collector: "pulse-core"} + subject := operationaltrust.EvidenceSubject{ResourceID: "resource-123"} + id, err := operationaltrust.NewEvidenceID(source, subject, observedAt, "provider-event-7") + if err != nil { + t.Fatalf("NewEvidenceID() error = %v", err) + } + envelope := operationaltrust.EvidenceEnvelope{ + ID: id, + Source: source, + Subject: subject, + ObservedAt: observedAt, + IngestedAt: ingestedAt, + Completeness: operationaltrust.EvidenceComplete, + Confidence: operationaltrust.EvidenceConfirmed, + Permissions: operationaltrust.EvidencePermissionsSufficient, + } + return ProtectionProviderObservation{ + ID: id, + Provider: ProviderProxmoxPVE, + Source: "pulse-core", + Scope: "pve-a", + JobState: OutcomeSuccess, + HistoryCompleteness: ProtectionHistoryComplete, + Permissions: operationaltrust.EvidencePermissionsSufficient, + ObservedAt: observedAt, + IngestedAt: ingestedAt, + Evidence: envelope, + } +} + +func TestProtectionProviderObservationCloneIsDeep(t *testing.T) { + base := validProviderObservation(t) + // Equip the evidence envelope with mutable pointer/map state so we can + // observe whether Clone() shares it with the original. + validUntil := base.ObservedAt.Add(time.Hour) + base.Evidence.ValidUntil = &validUntil + base.Evidence.Reason = &operationaltrust.EvidenceReason{Code: "inferred"} + base.Evidence.Correlation = &operationaltrust.IdentityCorrelation{ + Rule: "normalized_hostname", + MatchedFields: map[string]string{"hostname": "db-01"}, + CandidateCount: 1, + } + + clone := base.Clone() + + // Mutate every mutable subfield reachable through the clone's evidence. + *clone.Evidence.ValidUntil = validUntil.Add(time.Hour) + clone.Evidence.Reason.Code = "mutated" + clone.Evidence.Correlation.MatchedFields["hostname"] = "db-99" + + if !base.Evidence.ValidUntil.Equal(validUntil) { + t.Fatalf("Clone() shared ValidUntil pointer: base = %v, want %v", + *base.Evidence.ValidUntil, validUntil) + } + if base.Evidence.Reason == nil || base.Evidence.Reason.Code != "inferred" { + v := "" + if base.Evidence.Reason != nil { + v = base.Evidence.Reason.Code + } + t.Fatalf("Clone() shared Reason pointer: base.Code = %q, want %q", v, "inferred") + } + if base.Evidence.Correlation.MatchedFields["hostname"] != "db-01" { + t.Fatalf("Clone() shared Correlation map: base = %q, want %q", + base.Evidence.Correlation.MatchedFields["hostname"], "db-01") + } + + // Sanity: the clone must reflect the mutations we applied to it. + if !clone.Evidence.ValidUntil.Equal(validUntil.Add(time.Hour)) { + t.Fatalf("clone did not record ValidUntil mutation: = %v", *clone.Evidence.ValidUntil) + } + if clone.Evidence.Reason.Code != "mutated" { + t.Fatalf("clone did not record Reason mutation: = %q", clone.Evidence.Reason.Code) + } + if clone.Evidence.Correlation.MatchedFields["hostname"] != "db-99" { + t.Fatalf("clone did not record Correlation mutation: = %q", + clone.Evidence.Correlation.MatchedFields["hostname"]) + } + + // Scalar fields copied through. + if clone.ID != base.ID || clone.Provider != base.Provider || clone.Scope != base.Scope { + t.Fatalf("Clone() dropped scalar fields: %+v", clone) + } +} + +func TestProtectionProviderObservationValidate(t *testing.T) { + t.Run("valid", func(t *testing.T) { + obs := validProviderObservation(t) + if err := obs.Validate(); err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + }) + + cases := []struct { + name string + mutate func(*ProtectionProviderObservation) + errSubstr string + }{ + { + name: "empty id", + mutate: func(o *ProtectionProviderObservation) { o.ID = " " }, + errSubstr: "observation id is required", + }, + { + name: "empty provider", + mutate: func(o *ProtectionProviderObservation) { o.Provider = " " }, + errSubstr: "observation provider is required", + }, + { + name: "empty source", + mutate: func(o *ProtectionProviderObservation) { o.Source = "" }, + errSubstr: "observation source is required", + }, + { + name: "empty scope", + mutate: func(o *ProtectionProviderObservation) { o.Scope = " " }, + errSubstr: "observation scope is required", + }, + { + name: "invalid job state", + mutate: func(o *ProtectionProviderObservation) { o.JobState = Outcome("bogus") }, + errSubstr: "job state", + }, + { + name: "invalid history completeness", + mutate: func(o *ProtectionProviderObservation) { + o.HistoryCompleteness = ProtectionHistoryCompleteness("nope") + }, + errSubstr: "history completeness", + }, + { + name: "invalid permissions", + mutate: func(o *ProtectionProviderObservation) { + o.Permissions = operationaltrust.EvidencePermissions("nope") + }, + errSubstr: "permissions", + }, + { + name: "zero observedAt", + mutate: func(o *ProtectionProviderObservation) { o.ObservedAt = time.Time{} }, + errSubstr: "times are required", + }, + { + name: "zero ingestedAt", + mutate: func(o *ProtectionProviderObservation) { o.IngestedAt = time.Time{} }, + errSubstr: "times are required", + }, + { + name: "evidence observedAt mismatch", + mutate: func(o *ProtectionProviderObservation) { + o.ObservedAt = o.ObservedAt.Add(time.Minute) + }, + errSubstr: "observation times must match", + }, + { + name: "evidence ingestedAt mismatch", + mutate: func(o *ProtectionProviderObservation) { + o.IngestedAt = o.IngestedAt.Add(time.Minute) + }, + errSubstr: "ingestion times must match", + }, + { + name: "evidence id mismatch", + mutate: func(o *ProtectionProviderObservation) { o.ID += "-suffix" }, + errSubstr: "id must match its evidence id", + }, + { + name: "evidence validate fails", + mutate: func(o *ProtectionProviderObservation) { + o.Evidence.Completeness = operationaltrust.EvidenceCompleteness("bogus") + }, + errSubstr: "provider observation evidence:", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + obs := validProviderObservation(t) + tc.mutate(&obs) + err := obs.Validate() + if err == nil { + t.Fatalf("Validate() error = nil, want error containing %q", tc.errSubstr) + } + if !strings.Contains(err.Error(), tc.errSubstr) { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tc.errSubstr) + } + }) + } +} + +func TestProtectionPosturePolicyValidate(t *testing.T) { + cases := []struct { + name string + policy ProtectionPosturePolicy + wantErr bool + substr string + }{ + { + name: "valid with requireVerification", + policy: ProtectionPosturePolicy{FreshnessWindow: 5 * time.Minute, VerificationWindow: time.Hour, RequireVerification: true}, + wantErr: false, + }, + { + name: "freshness zero", + policy: ProtectionPosturePolicy{FreshnessWindow: 0, VerificationWindow: time.Hour}, + wantErr: true, + substr: "freshness window must be positive", + }, + { + name: "freshness negative", + policy: ProtectionPosturePolicy{FreshnessWindow: -time.Second, VerificationWindow: time.Hour}, + wantErr: true, + substr: "freshness window must be positive", + }, + { + name: "verification zero", + policy: ProtectionPosturePolicy{FreshnessWindow: time.Minute, VerificationWindow: 0}, + wantErr: true, + substr: "verification window must be positive", + }, + { + name: "verification negative", + policy: ProtectionPosturePolicy{FreshnessWindow: time.Minute, VerificationWindow: -time.Second}, + wantErr: true, + substr: "verification window must be positive", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.policy.Validate() + if tc.wantErr { + if err == nil { + t.Fatalf("Validate() error = nil, want error containing %q", tc.substr) + } + if !strings.Contains(err.Error(), tc.substr) { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tc.substr) + } + return + } + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + }) + } +} + +func TestProtectionPosturePolicyPayload(t *testing.T) { + t.Run("maps whole-second durations and flag", func(t *testing.T) { + policy := ProtectionPosturePolicy{ + FreshnessWindow: 90 * time.Second, + VerificationWindow: 3600 * time.Second, + RequireVerification: true, + } + payload := policy.Payload() + if payload.FreshnessWindowSeconds != 90 { + t.Fatalf("FreshnessWindowSeconds = %d, want 90", payload.FreshnessWindowSeconds) + } + if payload.VerificationWindowSeconds != 3600 { + t.Fatalf("VerificationWindowSeconds = %d, want 3600", payload.VerificationWindowSeconds) + } + if payload.RequireVerification != true { + t.Fatalf("RequireVerification = %v, want true", payload.RequireVerification) + } + }) + + t.Run("truncates sub-second durations toward zero", func(t *testing.T) { + // Payload uses int64(d / time.Second), which truncates fractional + // seconds toward zero for positive durations. + policy := ProtectionPosturePolicy{ + FreshnessWindow: 1500 * time.Millisecond, + VerificationWindow: 2*time.Second + 700*time.Millisecond, + RequireVerification: false, + } + payload := policy.Payload() + if payload.FreshnessWindowSeconds != 1 { + t.Fatalf("FreshnessWindowSeconds = %d, want 1 (truncated)", payload.FreshnessWindowSeconds) + } + if payload.VerificationWindowSeconds != 2 { + t.Fatalf("VerificationWindowSeconds = %d, want 2 (truncated)", payload.VerificationWindowSeconds) + } + if payload.RequireVerification != false { + t.Fatalf("RequireVerification = %v, want false", payload.RequireVerification) + } + }) +} + +func TestProtectionProviderSummaryNormalize(t *testing.T) { + t.Run("trims whitespace and preserves valid enums and pass-through fields", func(t *testing.T) { + attemptAt := time.Date(2026, 7, 18, 20, 0, 0, 0, time.UTC) + successAt := attemptAt.Add(time.Hour) + summary := ProtectionProviderSummary{ + Provider: " proxmox-pve ", + Source: " pulse-core\n", + Scope: "\tpve-a ", + JobState: OutcomeWarning, + HistoryCompleteness: ProtectionHistoryPartial, + Permissions: operationaltrust.EvidencePermissionsPartial, + RepositoryResourceIDs: []string{"repo-b", " ", "repo-a", "repo-a"}, + EvidenceIDs: []string{"e2", "e1", "e1", ""}, + LastAttemptAt: &attemptAt, + LastSuccessAt: &successAt, + BackupPointCount: 7, + SnapshotPointCount: 3, + VerificationExpected: true, + } + got := summary.normalize() + if got.Provider != ProviderProxmoxPVE { + t.Fatalf("Provider = %q, want %q", got.Provider, ProviderProxmoxPVE) + } + if got.Source != "pulse-core" { + t.Fatalf("Source = %q, want %q", got.Source, "pulse-core") + } + if got.Scope != "pve-a" { + t.Fatalf("Scope = %q, want %q", got.Scope, "pve-a") + } + if got.JobState != OutcomeWarning { + t.Fatalf("JobState = %q, want %q (valid value must be preserved)", + got.JobState, OutcomeWarning) + } + if got.HistoryCompleteness != ProtectionHistoryPartial { + t.Fatalf("HistoryCompleteness = %q, want %q", + got.HistoryCompleteness, ProtectionHistoryPartial) + } + if got.Permissions != operationaltrust.EvidencePermissionsPartial { + t.Fatalf("Permissions = %q, want %q", + got.Permissions, operationaltrust.EvidencePermissionsPartial) + } + wantRepos := []string{"repo-a", "repo-b"} + if len(got.RepositoryResourceIDs) != len(wantRepos) { + t.Fatalf("RepositoryResourceIDs = %v, want %v", got.RepositoryResourceIDs, wantRepos) + } + for i := range wantRepos { + if got.RepositoryResourceIDs[i] != wantRepos[i] { + t.Fatalf("RepositoryResourceIDs = %v, want %v", got.RepositoryResourceIDs, wantRepos) + } + } + wantEvidence := []string{"e1", "e2"} + if len(got.EvidenceIDs) != len(wantEvidence) { + t.Fatalf("EvidenceIDs = %v, want %v", got.EvidenceIDs, wantEvidence) + } + for i := range wantEvidence { + if got.EvidenceIDs[i] != wantEvidence[i] { + t.Fatalf("EvidenceIDs = %v, want %v", got.EvidenceIDs, wantEvidence) + } + } + if got.BackupPointCount != 7 || got.SnapshotPointCount != 3 || !got.VerificationExpected { + t.Fatalf("normalize dropped pass-through fields: %+v", got) + } + }) + + t.Run("replaces invalid enums with unknown sentinels", func(t *testing.T) { + summary := ProtectionProviderSummary{ + Provider: ProviderProxmoxPBS, + Source: "src", + Scope: "scope", + JobState: Outcome("bogus"), + HistoryCompleteness: ProtectionHistoryCompleteness("nope"), + Permissions: operationaltrust.EvidencePermissions("nope"), + } + got := summary.normalize() + if got.JobState != OutcomeUnknown { + t.Fatalf("JobState = %q, want %q", got.JobState, OutcomeUnknown) + } + if got.HistoryCompleteness != ProtectionHistoryUnknown { + t.Fatalf("HistoryCompleteness = %q, want %q", + got.HistoryCompleteness, ProtectionHistoryUnknown) + } + if got.Permissions != operationaltrust.EvidencePermissionsUnknown { + t.Fatalf("Permissions = %q, want %q", + got.Permissions, operationaltrust.EvidencePermissionsUnknown) + } + }) + + t.Run("nil slice fields become empty non-nil slices", func(t *testing.T) { + summary := ProtectionProviderSummary{ + Provider: ProviderProxmoxPVE, + Source: "src", + Scope: "scope", + } + got := summary.normalize() + if got.RepositoryResourceIDs == nil { + t.Fatal("RepositoryResourceIDs = nil, want non-nil empty slice") + } + if len(got.RepositoryResourceIDs) != 0 { + t.Fatalf("len(RepositoryResourceIDs) = %d, want 0", len(got.RepositoryResourceIDs)) + } + if got.EvidenceIDs == nil { + t.Fatal("EvidenceIDs = nil, want non-nil empty slice") + } + if len(got.EvidenceIDs) != 0 { + t.Fatalf("len(EvidenceIDs) = %d, want 0", len(got.EvidenceIDs)) + } + }) +} + +func TestCloneTime(t *testing.T) { + t.Run("nil returns nil", func(t *testing.T) { + if got := cloneTime(nil); got != nil { + t.Fatalf("cloneTime(nil) = %v, want nil", *got) + } + }) + + t.Run("non-nil returns distinct pointer with equal UTC value", func(t *testing.T) { + // Construct a time in a non-UTC fixed zone so we can also confirm the + // UTC normalisation performed by cloneTime (uses a fixed offset to + // avoid depending on system tzdata availability). + loc := time.FixedZone("EDT", -4*3600) + original := time.Date(2026, 7, 18, 16, 0, 0, 0, loc) // 16:00 EDT == 20:00 UTC + cloned := cloneTime(&original) + + if cloned == nil { + t.Fatal("cloneTime(non-nil) = nil, want non-nil") + } + if cloned == &original { + t.Fatal("cloneTime returned the same pointer as input, want a distinct pointer") + } + if !cloned.Equal(original) { + t.Fatalf("cloned time = %v, want Equal to original %v", *cloned, original) + } + if cloned.Location() != time.UTC { + t.Fatalf("cloned location = %v, want UTC", cloned.Location()) + } + // Mutating the clone must not move the original instant. + *cloned = cloned.Add(time.Hour) + if !original.Equal(time.Date(2026, 7, 18, 16, 0, 0, 0, loc)) { + t.Fatalf("original was mutated through clone: = %v", original) + } + }) +} + +func TestValidOutcome(t *testing.T) { + for _, tc := range []Outcome{ + OutcomeSuccess, + OutcomeWarning, + OutcomeFailed, + OutcomeRunning, + OutcomeUnknown, + } { + if !validOutcome(tc) { + t.Errorf("validOutcome(%q) = false, want true", string(tc)) + } + } + for _, tc := range []Outcome{"", "success ", "SUCCESS", "pending", "ok"} { + if validOutcome(tc) { + t.Errorf("validOutcome(%q) = true, want false", string(tc)) + } + } +} + +func TestValidEvidencePermissions(t *testing.T) { + for _, tc := range []operationaltrust.EvidencePermissions{ + operationaltrust.EvidencePermissionsSufficient, + operationaltrust.EvidencePermissionsPartial, + operationaltrust.EvidencePermissionsDenied, + operationaltrust.EvidencePermissionsUnknown, + } { + if !validEvidencePermissions(tc) { + t.Errorf("validEvidencePermissions(%q) = false, want true", string(tc)) + } + } + for _, tc := range []operationaltrust.EvidencePermissions{"", "FULL", "sufficient "} { + if validEvidencePermissions(tc) { + t.Errorf("validEvidencePermissions(%q) = true, want false", string(tc)) + } + } +} + +func TestNormalizeSortedStrings(t *testing.T) { + cases := []struct { + name string + in []string + want []string + }{ + {name: "nil", in: nil, want: []string{}}, + {name: "empty", in: []string{}, want: []string{}}, + {name: "all blanks dropped", in: []string{" ", "", "\t"}, want: []string{}}, + {name: "trims surrounding whitespace", in: []string{" b ", "a"}, want: []string{"a", "b"}}, + {name: "sorts and dedups", in: []string{"c", "a", "b", "a", "c"}, want: []string{"a", "b", "c"}}, + {name: "already clean stays clean", in: []string{"a", "b", "c"}, want: []string{"a", "b", "c"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := normalizeSortedStrings(tc.in) + if got == nil { + t.Fatalf("normalizeSortedStrings(%v) returned nil, want non-nil slice", tc.in) + } + if len(got) != len(tc.want) { + t.Fatalf("normalizeSortedStrings(%v) = %v, want %v", tc.in, got, tc.want) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Fatalf("normalizeSortedStrings(%v) = %v, want %v", tc.in, got, tc.want) + } + } + }) + } +} + +func TestSortedUniqueStrings(t *testing.T) { + cases := []struct { + name string + in []string + want bool + }{ + {name: "nil is vacuously true", in: nil, want: true}, + {name: "empty is vacuously true", in: []string{}, want: true}, + {name: "single element", in: []string{"a"}, want: true}, + {name: "sorted unique", in: []string{"a", "b", "c"}, want: true}, + {name: "whitespace-only rejected", in: []string{"a", " "}, want: false}, + {name: "empty string rejected", in: []string{"a", ""}, want: false}, + {name: "duplicate rejected", in: []string{"a", "a"}, want: false}, + {name: "unsorted rejected", in: []string{"b", "a"}, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := sortedUniqueStrings(tc.in); got != tc.want { + t.Fatalf("sortedUniqueStrings(%v) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +func TestCompareProviderStates(t *testing.T) { + mk := func(provider Provider, scope, source string) ProtectionProviderState { + return ProtectionProviderState{Provider: provider, Scope: scope, Source: source} + } + providerPVE := mk(ProviderProxmoxPVE, "scope-1", "src-1") + providerPBS := mk(ProviderProxmoxPBS, "scope-1", "src-1") + sameAsPVE := mk(ProviderProxmoxPVE, "scope-1", "src-1") + higherScope := mk(ProviderProxmoxPVE, "scope-2", "src-1") + higherSource := mk(ProviderProxmoxPVE, "scope-1", "src-2") + + t.Run("equal returns zero", func(t *testing.T) { + if got := compareProviderStates(providerPVE, sameAsPVE); got != 0 { + t.Fatalf("compareProviderStates(equal) = %d, want 0", got) + } + }) + t.Run("negative when provider ranks lower", func(t *testing.T) { + // "proxmox-pbs" < "proxmox-pve" + if got := compareProviderStates(providerPBS, providerPVE); got >= 0 { + t.Fatalf("compareProviderStates(pbs,pve) = %d, want negative", got) + } + }) + t.Run("positive when provider ranks higher", func(t *testing.T) { + if got := compareProviderStates(providerPVE, providerPBS); got <= 0 { + t.Fatalf("compareProviderStates(pve,pbs) = %d, want positive", got) + } + }) + t.Run("differentiates by scope when provider equal", func(t *testing.T) { + if got := compareProviderStates(providerPVE, higherScope); got >= 0 { + t.Fatalf("compareProviderStates(scope-1,scope-2) = %d, want negative", got) + } + if got := compareProviderStates(higherScope, providerPVE); got <= 0 { + t.Fatalf("compareProviderStates(scope-2,scope-1) = %d, want positive", got) + } + }) + t.Run("differentiates by source when provider and scope equal", func(t *testing.T) { + if got := compareProviderStates(providerPVE, higherSource); got >= 0 { + t.Fatalf("compareProviderStates(src-1,src-2) = %d, want negative", got) + } + if got := compareProviderStates(higherSource, providerPVE); got <= 0 { + t.Fatalf("compareProviderStates(src-2,src-1) = %d, want positive", got) + } + }) +} diff --git a/internal/truenas/provider_applogcontainers_branchcov0719_test.go b/internal/truenas/provider_applogcontainers_branchcov0719_test.go new file mode 100644 index 000000000..3a09a74ef --- /dev/null +++ b/internal/truenas/provider_applogcontainers_branchcov0719_test.go @@ -0,0 +1,93 @@ +package truenas + +import "testing" + +// TestAvailableAppLogContainersBranchCov exercises every branch of +// availableAppLogContainers: the empty-input short circuit, the +// post-filter empty fallback, the ServiceName→ID fallback, the +// skip-when-both-empty filter, the case-insensitive ID-equality guard +// that suppresses the parenthesised ID, the ID-in-parens formatting, +// whitespace trimming, and the multi-container join. +func TestAvailableAppLogContainersBranchCov(t *testing.T) { + tests := []struct { + name string + containers []AppContainer + want string + }{ + { + name: "empty slice returns empty string", + containers: nil, + want: "", + }, + { + name: "all containers filtered out returns empty string", + containers: []AppContainer{ + {ServiceName: " ", ID: ""}, + {ServiceName: "", ID: " "}, + }, + want: "", + }, + { + name: "service name only uses service name without parens", + containers: []AppContainer{ + {ServiceName: "svc-a", ID: ""}, + }, + want: "svc-a", + }, + { + name: "id only falls back to id without parens", + containers: []AppContainer{ + {ServiceName: "", ID: "id-1"}, + }, + want: "id-1", + }, + { + name: "service name equal to id case-insensitive suppresses parens", + containers: []AppContainer{ + {ServiceName: "svc", ID: "SVC"}, + }, + want: "svc", + }, + { + name: "service name with differing id appends id in parens", + containers: []AppContainer{ + {ServiceName: "svc-a", ID: "id-1"}, + }, + want: "svc-a (id-1)", + }, + { + name: "leading and trailing whitespace is trimmed before formatting", + containers: []AppContainer{ + {ServiceName: " padded-svc ", ID: " padded-id "}, + }, + want: "padded-svc (padded-id)", + }, + { + name: "trimmed service name matching trimmed id suppresses parens", + containers: []AppContainer{ + {ServiceName: " Name ", ID: " name "}, + }, + want: "Name", + }, + { + name: "multiple containers are comma joined in order", + containers: []AppContainer{ + {ServiceName: "svc-a", ID: "id-a"}, + {ServiceName: "", ID: "id-only"}, + {ServiceName: "svc-c", ID: ""}, + {ServiceName: " ", ID: " "}, + {ServiceName: "Same", ID: "same"}, + }, + want: "svc-a (id-a), id-only, svc-c, Same", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := availableAppLogContainers(tt.containers) + if got != tt.want { + t.Fatalf("availableAppLogContainers(%+v) = %q, want %q", tt.containers, got, tt.want) + } + }) + } +} diff --git a/internal/updatesignature/signature_branchcov0719_test.go b/internal/updatesignature/signature_branchcov0719_test.go new file mode 100644 index 000000000..540b7a665 --- /dev/null +++ b/internal/updatesignature/signature_branchcov0719_test.go @@ -0,0 +1,149 @@ +package updatesignature + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "strings" + "testing" +) + +func TestDecodePrivateKey(t *testing.T) { + t.Run("full key roundtrip", func(t *testing.T) { + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + encoded := base64.StdEncoding.EncodeToString(privateKey) + + decoded, err := DecodePrivateKey(encoded) + if err != nil { + t.Fatalf("DecodePrivateKey: %v", err) + } + if !bytesEqual(decoded, privateKey) { + t.Fatalf("decoded key = %x, want %x", []byte(decoded), []byte(privateKey)) + } + if !decoded.Equal(privateKey) { + t.Fatalf("decoded key does not equal original") + } + }) + + t.Run("seed roundtrip derives same key", func(t *testing.T) { + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + seed := privateKey.Seed() + encoded := base64.StdEncoding.EncodeToString(seed) + + decoded, err := DecodePrivateKey(encoded) + if err != nil { + t.Fatalf("DecodePrivateKey from seed: %v", err) + } + if !decoded.Equal(privateKey) { + t.Fatalf("seed-derived key = %x, want %x", []byte(decoded), []byte(privateKey)) + } + }) + + t.Run("empty returns error and nil key", func(t *testing.T) { + decoded, err := DecodePrivateKey("") + if err == nil { + t.Fatal("expected error for empty input, got nil") + } + if !strings.Contains(err.Error(), "empty signing key") { + t.Fatalf("error = %q, want substring %q", err.Error(), "empty signing key") + } + if decoded != nil { + t.Fatalf("expected nil key, got %x", []byte(decoded)) + } + }) + + t.Run("whitespace only returns empty error", func(t *testing.T) { + decoded, err := DecodePrivateKey(" \t\n ") + if err == nil { + t.Fatal("expected error for whitespace input, got nil") + } + if !strings.Contains(err.Error(), "empty signing key") { + t.Fatalf("error = %q, want substring %q", err.Error(), "empty signing key") + } + if decoded != nil { + t.Fatalf("expected nil key, got %x", []byte(decoded)) + } + }) + + t.Run("invalid base64 returns wrapped error and nil key", func(t *testing.T) { + decoded, err := DecodePrivateKey("not-base64!!!") + if err == nil { + t.Fatal("expected error for invalid base64, got nil") + } + if !strings.Contains(err.Error(), "invalid base64 signing key") { + t.Fatalf("error = %q, want substring %q", err.Error(), "invalid base64 signing key") + } + var inner interface{ Unwrap() error } + if !errors.As(err, &inner) { + t.Fatalf("expected wrapped error, got %T: %v", err, err) + } + if decoded != nil { + t.Fatalf("expected nil key, got %x", []byte(decoded)) + } + }) + + t.Run("valid base64 wrong length returns error and nil key", func(t *testing.T) { + bogus := []byte{1, 2, 3, 4, 5} + encoded := base64.StdEncoding.EncodeToString(bogus) + + decoded, err := DecodePrivateKey(encoded) + if err == nil { + t.Fatal("expected error for wrong-length key, got nil") + } + if !strings.Contains(err.Error(), "invalid signing key length") { + t.Fatalf("error = %q, want substring %q", err.Error(), "invalid signing key length") + } + if !strings.Contains(err.Error(), "5") { + t.Fatalf("error = %q, want it to contain the bad length 5", err.Error()) + } + if decoded != nil { + t.Fatalf("expected nil key, got %x", []byte(decoded)) + } + }) +} + +func TestHasTrustedPublicKeys(t *testing.T) { + original := EmbeddedTrustedPublicKeys + t.Cleanup(func() { EmbeddedTrustedPublicKeys = original }) + + t.Run("empty returns false", func(t *testing.T) { + EmbeddedTrustedPublicKeys = "" + if HasTrustedPublicKeys() { + t.Fatal("HasTrustedPublicKeys() = true, want false for empty") + } + }) + + t.Run("whitespace only returns false", func(t *testing.T) { + EmbeddedTrustedPublicKeys = " \t\n " + if HasTrustedPublicKeys() { + t.Fatal("HasTrustedPublicKeys() = true, want false for whitespace-only") + } + }) + + t.Run("populated returns true", func(t *testing.T) { + EmbeddedTrustedPublicKeys = "c29tZWtleQ==" + if !HasTrustedPublicKeys() { + t.Fatal("HasTrustedPublicKeys() = false, want true for populated value") + } + }) +} + +// bytesEqual is a tiny helper to avoid pulling in bytes for a single call. +func bytesEqual(a, b ed25519.PrivateKey) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/vmware/provider_sourceid_branchcov0719_test.go b/internal/vmware/provider_sourceid_branchcov0719_test.go new file mode 100644 index 000000000..ba7e0ae99 --- /dev/null +++ b/internal/vmware/provider_sourceid_branchcov0719_test.go @@ -0,0 +1,105 @@ +package vmware + +import ( + "strings" + "testing" +) + +// TestSourceID exercises the exported SourceID helper, which delegates to +// vmwareSourceID -> filterNonEmptyStrings -> strings.Join(parts, ":"). +// +// filterNonEmptyStrings (a) trims whitespace on each component, (b) drops +// any component that is empty after trimming, and (c) deduplicates the +// remaining components case-insensitively, preserving the first occurrence. +// The cases below drive each of those branches through the public SourceID +// entry point so coverage lands on the named target function. +func TestSourceID(t *testing.T) { + cases := []struct { + name string + connectionID string + entityType string + managedObjectID string + want string + }{ + { + name: "all three populated joins with colon", + connectionID: "vc-1", + entityType: "host", + managedObjectID: "host-101", + want: "vc-1:host:host-101", + }, + { + name: "empty middle component is dropped", + connectionID: "vc-1", + entityType: "", + managedObjectID: "vm-201", + want: "vc-1:vm-201", + }, + { + name: "whitespace-only component is treated as empty", + connectionID: "vc-1", + entityType: " ", + managedObjectID: "vm-201", + want: "vc-1:vm-201", + }, + { + name: "all components empty returns empty string", + connectionID: "", + entityType: "", + managedObjectID: "", + want: "", + }, + { + name: "leading and trailing whitespace trimmed from each component", + connectionID: " vc-1 ", + entityType: "\thost\n", + managedObjectID: " host-101 ", + want: "vc-1:host:host-101", + }, + { + name: "case-insensitive duplicate components collapse to first occurrence", + connectionID: "VC-1", + entityType: "vc-1", + managedObjectID: "VC-1", + want: "VC-1", + }, + { + name: "distinct inputs differ only in the third component keep both", + connectionID: "vc-1", + entityType: "host", + managedObjectID: "host-102", + want: "vc-1:host:host-102", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := SourceID(tc.connectionID, tc.entityType, tc.managedObjectID) + if got != tc.want { + t.Fatalf("SourceID(%q, %q, %q) = %q, want %q", + tc.connectionID, tc.entityType, tc.managedObjectID, got, tc.want) + } + }) + } + + t.Run("different inputs produce different IDs", func(t *testing.T) { + a := SourceID("vc-1", "host", "host-101") + b := SourceID("vc-1", "host", "host-102") + if a == b { + t.Fatalf("expected distinct IDs for distinct managedObjectID, got %q == %q", a, b) + } + }) + + t.Run("identical inputs are stable across calls", func(t *testing.T) { + first := SourceID("vc-1", "vm", "vm-201") + second := SourceID("vc-1", "vm", "vm-201") + if first != second { + t.Fatalf("SourceID not stable: first=%q second=%q", first, second) + } + // Sanity-check the stable value matches the documented colon-join + // format so this subtest asserts real behaviour, not just equality. + if want := strings.Join([]string{"vc-1", "vm", "vm-201"}, ":"); first != want { + t.Fatalf("stable SourceID = %q, want canonical %q", first, want) + } + }) +}