From 9869f6e6b40c0d586943ca0d67980807b37f1d36 Mon Sep 17 00:00:00 2001 From: Christopher Langton Date: Thu, 12 Nov 2026 21:34:06 +1100 Subject: [PATCH] test(sensor): cover restorePrev, stamp freshness, failed rollbacks and the writable check The scenario tests already drive reconcilePending through confirm, window, outage and rollback, and lockUpdate. These add the rules they do not reach: restorePrev's missing, empty and good .prev; a health stamp counts only for the new version written at or after the swap; nothing pending is no verdict; a rollback that cannot restore still adds the release to the bad list with the reason; and updateSkipReason refuses a read-only install directory. Closes #290 --- .../updatehealth_rules_test.go | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 cmd/vulnetix-sensor/updatehealth_rules_test.go diff --git a/cmd/vulnetix-sensor/updatehealth_rules_test.go b/cmd/vulnetix-sensor/updatehealth_rules_test.go new file mode 100644 index 0000000000..0fdd0b4977 --- /dev/null +++ b/cmd/vulnetix-sensor/updatehealth_rules_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The update-safety rules the scenario tests in updatehealth_test.go do not +// reach directly: restorePrev's three outcomes, which health stamps count as +// proof, the bad list on a failed rollback, and the writable check. + +func TestRestorePrev(t *testing.T) { + t.Run("no previous binary", func(t *testing.T) { + _, target := installed(t) + err := restorePrev(target) + if err == nil || !strings.Contains(err.Error(), "no previous binary to restore") { + t.Fatalf("err = %v", err) + } + if b, _ := os.ReadFile(target); string(b) != "old" { + t.Fatalf("the installed binary must be left alone: %q", b) + } + }) + + t.Run("empty previous binary", func(t *testing.T) { + _, target := installed(t) + if err := os.WriteFile(target+".prev", nil, 0o755); err != nil { + t.Fatal(err) + } + err := restorePrev(target) + if err == nil || !strings.Contains(err.Error(), "the previous binary is empty") { + t.Fatalf("err = %v", err) + } + if b, _ := os.ReadFile(target); string(b) != "old" { + t.Fatalf("an empty .prev must never replace the binary: %q", b) + } + }) + + t.Run("good previous binary", func(t *testing.T) { + _, target := installed(t) + if err := os.WriteFile(target+".prev", []byte("previous"), 0o600); err != nil { + t.Fatal(err) + } + if err := restorePrev(target); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(target) + if err != nil || string(b) != "previous" { + t.Fatalf("target = %q, %v", b, err) + } + info, err := os.Stat(target) + if err != nil || info.Mode().Perm() != 0o755 { + t.Fatalf("the restored binary must be executable: %v %v", info.Mode(), err) + } + if _, err := os.Stat(target + ".prev"); err != nil { + t.Fatalf(".prev is kept: %v", err) + } + }) +} + +// Only a stamp from the new version, written after the swap, proves it. +func TestOnlyAFreshStampFromTheNewVersionConfirms(t *testing.T) { + swapped := time.Now().Add(-2 * time.Minute) + for _, tt := range []struct { + name string + stamp healthState + want string + }{ + {"new version after the swap", healthState{Version: "v3.200.0", At: swapped.Add(time.Second)}, "ok"}, + {"new version at the swap", healthState{Version: "v3.200.0", At: swapped}, "ok"}, + {"new version before the swap", healthState{Version: "v3.200.0", At: swapped.Add(-time.Second)}, ""}, + {"old version after the swap", healthState{Version: "v3.158.0", At: swapped.Add(time.Second)}, ""}, + } { + t.Run(tt.name, func(t *testing.T) { + dir, target := installed(t) + saveUpdateState(dir, updateState{Pending: &pendingUpdate{From: "v3.158.0", To: "v3.200.0", At: swapped}}) + writeStamp(t, dir, tt.stamp) + // Two minutes in, a healthy unit, the server reachable: inside the window. + action, _, err := reconcilePending(dir, target, swapped.Add(2*time.Minute), false, true) + if err != nil || action != tt.want { + t.Fatalf("action = %q, %v; want %q", action, err, tt.want) + } + if pending := loadUpdateState(dir).Pending; (pending == nil) != (tt.want == "ok") { + t.Fatalf("pending = %+v after %q", pending, action) + } + }) + } +} + +func writeStamp(t *testing.T, dir string, h healthState) { + t.Helper() + b := []byte(`{"version":"` + h.Version + `","at":"` + h.At.Format(time.RFC3339Nano) + `"}`) + if err := os.WriteFile(filepath.Join(dir, healthStamp), b, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestNothingPendingIsNoVerdict(t *testing.T) { + dir, target := installed(t) + action, out, err := reconcilePending(dir, target, time.Now(), true, false) + if action != "" || out != nil || err != nil { + t.Fatalf("got %q %+v %v", action, out, err) + } +} + +// A rollback that cannot restore still marks the release bad, so the guard does +// not take it again, and says why in the outcome. +func TestAFailedRollbackStillMarksTheReleaseBad(t *testing.T) { + dir, target := installed(t) + saveUpdateState(dir, updateState{ + Bad: []string{"v3.199.0"}, + Pending: &pendingUpdate{From: "v3.158.0", To: "v3.200.0", At: time.Now().Add(-2 * time.Minute)}, + }) + _, out, err := reconcilePending(dir, target, time.Now(), true, true) + if err == nil || out == nil || out.State != "failed" { + t.Fatalf("got %+v %v", out, err) + } + if !strings.Contains(out.Reason, "the sensor unit failed") || !strings.Contains(out.Reason, "rollback failed: no previous binary") { + t.Errorf("reason = %q", out.Reason) + } + st := loadUpdateState(dir) + if st.Pending != nil || len(st.Bad) != 2 || st.Bad[1] != "v3.200.0" { + t.Errorf("state = %+v", st) + } +} + +func TestUpdateSkipReasonNeedsAWritableDirectory(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root writes to a read-only directory") + } + dir := t.TempDir() + bin := filepath.Join(dir, "vulnetix-sensor") + if err := os.WriteFile(bin, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + if r := updateSkipReason(bin, bin); !strings.Contains(r, "not writable") { + t.Errorf("a read-only install directory: %q", r) + } +}