mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
331 lines
12 KiB
Go
331 lines
12 KiB
Go
package unifiedresources
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestResourceOperatorState_IsEmpty(t *testing.T) {
|
|
if !(ResourceOperatorState{}).IsEmpty() {
|
|
t.Error("zero value must report IsEmpty=true; the default no-state posture has no operator intent")
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
state ResourceOperatorState
|
|
}{
|
|
{"intentionally offline", ResourceOperatorState{IntentionallyOffline: true}},
|
|
{"never auto remediate", ResourceOperatorState{NeverAutoRemediate: true}},
|
|
{"criticality", ResourceOperatorState{Criticality: CriticalityHigh}},
|
|
{"note", ResourceOperatorState{Note: "intentionally archived"}},
|
|
{"maintenance reason only", ResourceOperatorState{MaintenanceReason: "scheduled"}},
|
|
{"maintenance window", ResourceOperatorState{
|
|
MaintenanceStartAt: timePointer(time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)),
|
|
MaintenanceEndAt: timePointer(time.Date(2026, 5, 9, 1, 0, 0, 0, time.UTC)),
|
|
}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.state.IsEmpty() {
|
|
t.Errorf("state %+v must not report IsEmpty=true; it carries operator intent", tc.state)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResourceOperatorState_IsInMaintenanceAt(t *testing.T) {
|
|
start := time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)
|
|
end := time.Date(2026, 5, 9, 14, 0, 0, 0, time.UTC)
|
|
state := ResourceOperatorState{
|
|
MaintenanceStartAt: &start,
|
|
MaintenanceEndAt: &end,
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
now time.Time
|
|
want bool
|
|
}{
|
|
{"before window", time.Date(2026, 5, 9, 11, 59, 59, 0, time.UTC), false},
|
|
{"exactly start is in", start, true},
|
|
{"midway is in", time.Date(2026, 5, 9, 13, 0, 0, 0, time.UTC), true},
|
|
{"exactly end is out (half-open interval)", end, false},
|
|
{"after end is out", time.Date(2026, 5, 9, 14, 0, 1, 0, time.UTC), false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := state.IsInMaintenanceAt(tc.now); got != tc.want {
|
|
t.Errorf("IsInMaintenanceAt(%v) = %v, want %v", tc.now, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("no window configured", func(t *testing.T) {
|
|
empty := ResourceOperatorState{}
|
|
if empty.IsInMaintenanceAt(start) {
|
|
t.Error("no-window state must always report IsInMaintenanceAt=false")
|
|
}
|
|
})
|
|
|
|
t.Run("only start set is treated as no window", func(t *testing.T) {
|
|
half := ResourceOperatorState{MaintenanceStartAt: &start}
|
|
if half.IsInMaintenanceAt(time.Date(2026, 5, 9, 13, 0, 0, 0, time.UTC)) {
|
|
t.Error("partial maintenance window must not gate suppression")
|
|
}
|
|
})
|
|
|
|
t.Run("end before start is treated as no window", func(t *testing.T) {
|
|
bad := ResourceOperatorState{MaintenanceStartAt: &end, MaintenanceEndAt: &start}
|
|
if bad.IsInMaintenanceAt(time.Date(2026, 5, 9, 13, 0, 0, 0, time.UTC)) {
|
|
t.Error("inverted maintenance window must not gate suppression — validation should also reject this on Set")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestValidateResourceOperatorState(t *testing.T) {
|
|
t.Run("rejects empty canonical id", func(t *testing.T) {
|
|
err := ValidateResourceOperatorState(ResourceOperatorState{CanonicalID: " "})
|
|
if !errors.Is(err, ErrResourceOperatorStateInvalid) {
|
|
t.Fatalf("expected ErrResourceOperatorStateInvalid, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects unknown criticality", func(t *testing.T) {
|
|
err := ValidateResourceOperatorState(ResourceOperatorState{
|
|
CanonicalID: "vm:101",
|
|
Criticality: "very-high", // not one of the canonical levels
|
|
})
|
|
if !errors.Is(err, ErrResourceOperatorStateInvalid) {
|
|
t.Fatalf("expected ErrResourceOperatorStateInvalid for unknown criticality, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "criticality") {
|
|
t.Errorf("error should name the criticality field; got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects half-set maintenance window", func(t *testing.T) {
|
|
start := time.Now()
|
|
err := ValidateResourceOperatorState(ResourceOperatorState{
|
|
CanonicalID: "vm:101",
|
|
MaintenanceStartAt: &start,
|
|
})
|
|
if !errors.Is(err, ErrResourceOperatorStateInvalid) {
|
|
t.Fatalf("expected ErrResourceOperatorStateInvalid for half-set window, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects end equal to start (zero-length window has no semantics)", func(t *testing.T) {
|
|
t0 := time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)
|
|
err := ValidateResourceOperatorState(ResourceOperatorState{
|
|
CanonicalID: "vm:101",
|
|
MaintenanceStartAt: &t0,
|
|
MaintenanceEndAt: &t0,
|
|
})
|
|
if !errors.Is(err, ErrResourceOperatorStateInvalid) {
|
|
t.Fatalf("expected ErrResourceOperatorStateInvalid for zero-length window, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("accepts canonical empty (default) criticality", func(t *testing.T) {
|
|
err := ValidateResourceOperatorState(ResourceOperatorState{
|
|
CanonicalID: "vm:101",
|
|
})
|
|
if err != nil {
|
|
t.Errorf("default state must validate clean; got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("accepts canonical levels", func(t *testing.T) {
|
|
for _, level := range []ResourceCriticality{CriticalityHigh, CriticalityMedium, CriticalityLow} {
|
|
err := ValidateResourceOperatorState(ResourceOperatorState{
|
|
CanonicalID: "vm:101",
|
|
Criticality: level,
|
|
})
|
|
if err != nil {
|
|
t.Errorf("criticality %q must validate; got %v", level, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestNormalizeResourceOperatorState_TrimsAndLowersCriticality(t *testing.T) {
|
|
got := NormalizeResourceOperatorState(ResourceOperatorState{
|
|
CanonicalID: " vm:101 ",
|
|
MaintenanceReason: " weekend window ",
|
|
Note: "\tarchived\n",
|
|
SetBy: " agent:ops ",
|
|
Criticality: " HIGH ",
|
|
})
|
|
if got.CanonicalID != "vm:101" {
|
|
t.Errorf("canonical id should be trimmed; got %q", got.CanonicalID)
|
|
}
|
|
if got.MaintenanceReason != "weekend window" {
|
|
t.Errorf("maintenance reason should be trimmed; got %q", got.MaintenanceReason)
|
|
}
|
|
if got.Note != "archived" {
|
|
t.Errorf("note should be trimmed; got %q", got.Note)
|
|
}
|
|
if got.SetBy != "agent:ops" {
|
|
t.Errorf("set_by should be trimmed; got %q", got.SetBy)
|
|
}
|
|
if got.Criticality != CriticalityHigh {
|
|
t.Errorf("criticality should be trimmed and lowered; got %q", got.Criticality)
|
|
}
|
|
}
|
|
|
|
func TestResourceOperatorState_BuildMaintenanceWindowLifecycleChange(t *testing.T) {
|
|
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
|
start := now.Add(time.Hour)
|
|
end := now.Add(2 * time.Hour)
|
|
updatedEnd := now.Add(3 * time.Hour)
|
|
|
|
current := ResourceOperatorState{
|
|
CanonicalID: "vm:101",
|
|
MaintenanceStartAt: &start,
|
|
MaintenanceEndAt: &end,
|
|
MaintenanceReason: "kernel patch",
|
|
SetAt: now,
|
|
SetBy: "operator:richard",
|
|
}
|
|
|
|
scheduled, ok := BuildMaintenanceWindowLifecycleChange(ResourceOperatorState{}, false, current, true, time.Time{}, "")
|
|
if !ok {
|
|
t.Fatal("expected a lifecycle change when a maintenance window is scheduled")
|
|
}
|
|
if scheduled.Kind != ChangeActivity {
|
|
t.Fatalf("kind = %q want activity", scheduled.Kind)
|
|
}
|
|
if scheduled.SourceType != SourceUserAction {
|
|
t.Fatalf("source type = %q want user_action", scheduled.SourceType)
|
|
}
|
|
if scheduled.SourceAdapter != resourceOperatorStateSourceAdapter {
|
|
t.Fatalf("source adapter = %q want operator_state", scheduled.SourceAdapter)
|
|
}
|
|
if scheduled.Actor != "operator:richard" {
|
|
t.Fatalf("actor = %q want operator:richard", scheduled.Actor)
|
|
}
|
|
if scheduled.Reason != "Maintenance window scheduled" {
|
|
t.Fatalf("reason = %q want scheduled", scheduled.Reason)
|
|
}
|
|
if scheduled.From != "no maintenance window" || !strings.Contains(scheduled.To, "kernel patch") {
|
|
t.Fatalf("unexpected from/to: %q -> %q", scheduled.From, scheduled.To)
|
|
}
|
|
if scheduled.Metadata["activityType"] != MaintenanceWindowLifecycleEventScheduled {
|
|
t.Fatalf("activityType = %#v want scheduled", scheduled.Metadata["activityType"])
|
|
}
|
|
if scheduled.Metadata["maintenanceStartAt"] != start.Format(time.RFC3339) {
|
|
t.Fatalf("maintenanceStartAt metadata = %#v", scheduled.Metadata["maintenanceStartAt"])
|
|
}
|
|
|
|
unchanged, ok := BuildMaintenanceWindowLifecycleChange(current, true, current, true, now, "")
|
|
if ok || unchanged.ID != "" {
|
|
t.Fatalf("unchanged maintenance window should not produce a change: %+v", unchanged)
|
|
}
|
|
|
|
updated := current
|
|
updated.MaintenanceEndAt = &updatedEnd
|
|
updated.SetAt = now.Add(time.Minute)
|
|
changed, ok := BuildMaintenanceWindowLifecycleChange(current, true, updated, true, time.Time{}, "")
|
|
if !ok {
|
|
t.Fatal("expected a lifecycle change when the maintenance window end changes")
|
|
}
|
|
if changed.Metadata["activityType"] != MaintenanceWindowLifecycleEventUpdated {
|
|
t.Fatalf("activityType = %#v want updated", changed.Metadata["activityType"])
|
|
}
|
|
if changed.Metadata["previousMaintenanceEndAt"] != end.Format(time.RFC3339) {
|
|
t.Fatalf("previous end metadata = %#v", changed.Metadata["previousMaintenanceEndAt"])
|
|
}
|
|
|
|
cleared, ok := BuildMaintenanceWindowLifecycleChange(updated, true, ResourceOperatorState{CanonicalID: "vm:101"}, false, now.Add(2*time.Minute), "operator:richard")
|
|
if !ok {
|
|
t.Fatal("expected a lifecycle change when the maintenance window is cleared")
|
|
}
|
|
if cleared.Metadata["activityType"] != MaintenanceWindowLifecycleEventCleared {
|
|
t.Fatalf("activityType = %#v want cleared", cleared.Metadata["activityType"])
|
|
}
|
|
if cleared.To != "no maintenance window" {
|
|
t.Fatalf("cleared to = %q want no maintenance window", cleared.To)
|
|
}
|
|
}
|
|
|
|
func TestMemoryStore_ResourceOperatorState_SetGetClearRoundTrip(t *testing.T) {
|
|
store := NewMemoryStore()
|
|
|
|
// Initial GET on a fresh store returns no entry.
|
|
state, found, err := store.GetResourceOperatorState("vm:101")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error on initial get: %v", err)
|
|
}
|
|
if found {
|
|
t.Errorf("expected no entry on fresh store; got %+v", state)
|
|
}
|
|
|
|
start := time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)
|
|
end := time.Date(2026, 5, 9, 14, 0, 0, 0, time.UTC)
|
|
setAt := time.Date(2026, 5, 9, 11, 59, 0, 0, time.UTC)
|
|
original := ResourceOperatorState{
|
|
CanonicalID: "vm:101",
|
|
IntentionallyOffline: true,
|
|
NeverAutoRemediate: true,
|
|
MaintenanceStartAt: &start,
|
|
MaintenanceEndAt: &end,
|
|
MaintenanceReason: "Q3 storage upgrade",
|
|
Criticality: CriticalityHigh,
|
|
Note: "do not auto-fix; restoration in progress",
|
|
SetAt: setAt,
|
|
SetBy: "operator:richard",
|
|
}
|
|
if err := store.SetResourceOperatorState(original); err != nil {
|
|
t.Fatalf("set failed: %v", err)
|
|
}
|
|
|
|
state, found, err = store.GetResourceOperatorState("vm:101")
|
|
if err != nil {
|
|
t.Fatalf("get failed: %v", err)
|
|
}
|
|
if !found {
|
|
t.Fatal("expected entry after Set")
|
|
}
|
|
if !state.IntentionallyOffline {
|
|
t.Error("intentionally_offline must round-trip")
|
|
}
|
|
if !state.NeverAutoRemediate {
|
|
t.Error("never_auto_remediate must round-trip")
|
|
}
|
|
if state.MaintenanceStartAt == nil || !state.MaintenanceStartAt.Equal(start) {
|
|
t.Errorf("maintenance start must round-trip; got %v", state.MaintenanceStartAt)
|
|
}
|
|
if state.MaintenanceEndAt == nil || !state.MaintenanceEndAt.Equal(end) {
|
|
t.Errorf("maintenance end must round-trip; got %v", state.MaintenanceEndAt)
|
|
}
|
|
if state.Criticality != CriticalityHigh {
|
|
t.Errorf("criticality must round-trip; got %q", state.Criticality)
|
|
}
|
|
if state.Note != "do not auto-fix; restoration in progress" {
|
|
t.Errorf("note must round-trip; got %q", state.Note)
|
|
}
|
|
|
|
// Clear is idempotent and removes the entry.
|
|
if err := store.ClearResourceOperatorState("vm:101"); err != nil {
|
|
t.Fatalf("clear failed: %v", err)
|
|
}
|
|
if _, found, _ := store.GetResourceOperatorState("vm:101"); found {
|
|
t.Error("entry must be gone after Clear")
|
|
}
|
|
if err := store.ClearResourceOperatorState("vm:101"); err != nil {
|
|
t.Errorf("Clear must be idempotent; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMemoryStore_SetResourceOperatorState_RejectsInvalid(t *testing.T) {
|
|
store := NewMemoryStore()
|
|
err := store.SetResourceOperatorState(ResourceOperatorState{Criticality: "bogus"})
|
|
if !errors.Is(err, ErrResourceOperatorStateInvalid) {
|
|
t.Fatalf("invalid state must be rejected with ErrResourceOperatorStateInvalid; got %v", err)
|
|
}
|
|
}
|
|
|
|
func timePointer(t time.Time) *time.Time {
|
|
return &t
|
|
}
|