mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add Go branch-coverage tests for canonical trust and recovery helpers
Cover pure functions the recent canonical Operational Trust and protection posture work landed with no unit test: - recovery/model posture: enum Valid, struct Clone/Validate/Payload and the normalize/cloneTime/validOutcome/sortedUniqueStrings/compareProviderStates helpers (20 funcs, all 0%->100%) - operationaltrust contracts: EvidencePayloadRef/Acknowledgement/Suppression/ LifecycleTransition Validate plus LifecycleTransition/NotificationLink Clone - cloudcp/proxytrust: ClientIP, ExtractRemoteIP, rightMostUntrustedForwardedIP and IsTrustedProxyIP forwarded-header and CIDR parsing - mockmodel: NormalizeBlendWeight, SeriesForTimestamps, seriesForProfile, diskIOValue and flatValue deterministic seeded math - updatesignature: DecodePrivateKey and HasTrustedPublicKeys - recovery keys: ProxmoxPBSGuestLooseContinuityKey branch guards - vmware SourceID and truenas availableAppLogContainers formatting Test-only, contract-neutral. New *_branchcov0719_test.go files only; no source or existing test touched.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 := "<nil>"
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user