package report import ( "context" "encoding/json" "errors" "reflect" "sync" "testing" "time" ) // `09` §3 decision 185 (D1): the operator's actions. See opactions.go's header. // The list is CLOSED. This test fails when an action or a job is added, by design: a new entry is an // operator decision (no action may delete data, start a countdown or shorten one), not an edit. The // hub pins the same four in its own test (hub internal/store opactions_test.go). func TestOpActions_ClosedList(t *testing.T) { if got, want := OperatorActionNames(), []string{"abandon_extend", "abandon_stop", "offsite_backup_now", "run_job"}; !reflect.DeepEqual(got, want) { t.Fatalf("operator actions = %v, want exactly %v — a new action needs the operator's word (decision 185)", got, want) } if got, want := OperatorJobNames(), []string{"disk-health-check", "fill-watch", "offsite-integrity", "offsite-proof"}; !reflect.DeepEqual(got, want) { t.Fatalf("run_job names = %v, want exactly %v", got, want) } } // calls records every handler call. type calls struct { mu sync.Mutex list []string } func (c *calls) add(s string) { c.mu.Lock(); c.list = append(c.list, s); c.mu.Unlock() } func (c *calls) get() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.list...) } func recordingHandlers(c *calls) OperatorActionHandlers { return OperatorActionHandlers{ OffsiteBackupNow: func() (string, func(context.Context) error) { c.add("offsite-check") return "", func(context.Context) error { c.add("offsite-run"); return nil } }, AbandonStop: func() error { c.add("stop"); return nil }, AbandonExtend: func(d int) (time.Time, error) { c.add("extend") return time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), nil }, RunJob: func(name string) (<-chan error, error) { c.add("job:" + name) ch := make(chan error, 1) ch <- nil close(ch) return ch, nil }, } } func resultFor(t *testing.T, o *OperatorActions, id int64) OperatorActionResult { t.Helper() for _, r := range o.Results() { if r.ID == id { return r } } t.Fatalf("no result for #%d in %+v", id, o.Results()) return OperatorActionResult{} } // Red test (3) of the design: an unknown action, an unknown job, an argument out of range → refused, // and NOTHING is called. func TestOpActions_UnknownIsRefusedAndCallsNothing(t *testing.T) { c := &calls{} o := NewOperatorActions(context.Background(), recordingHandlers(c), nil) list := []OperatorAction{ {ID: 1, Action: "delete_everything"}, {ID: 2, Action: OpRunJob, Arg: "offsite-abandon-sweep"}, // a real job, NOT on the list: it deletes {ID: 3, Action: OpRunJob, Arg: ""}, {ID: 4, Action: OpAbandonExtend, Arg: "0"}, {ID: 5, Action: OpAbandonExtend, Arg: "31"}, {ID: 6, Action: OpAbandonExtend, Arg: "-3"}, {ID: 7, Action: OpAbandonExtend, Arg: "7 "}, {ID: 8, Action: OpAbandonStop, Arg: "x"}, {ID: 9, Action: OpOffsiteBackupNow, Arg: "x"}, } o.Reconcile(list) o.running.Wait() if got := c.get(); len(got) != 0 { t.Fatalf("a refused action called %v", got) } for _, a := range list { if r := resultFor(t, o, a.ID); r.Outcome != OutcomeRefused || r.Message == "" { t.Errorf("#%d %s(%q): %+v, want refused with a reason", a.ID, a.Action, a.Arg, r) } } } // Red test (2): the same id delivered twice → the action runs ONCE; its result is re-sent while listed. func TestOpActions_OncePerID(t *testing.T) { c := &calls{} o := NewOperatorActions(context.Background(), recordingHandlers(c), nil) a := OperatorAction{ID: 42, Action: OpAbandonStop} o.Reconcile([]OperatorAction{a}) o.Reconcile([]OperatorAction{a}) o.Reconcile([]OperatorAction{a}) if got := c.get(); len(got) != 1 || got[0] != "stop" { t.Fatalf("calls = %v, want exactly one stop", got) } if r := resultFor(t, o, 42); r.Outcome != OutcomeDone { t.Fatalf("result = %+v", r) } // The hub has the result and stops listing the id → it is no longer sent, and is not run again. o.Reconcile(nil) if rs := o.Results(); len(rs) != 0 { t.Fatalf("a result the hub no longer waits for is still sent: %+v", rs) } o.Reconcile([]OperatorAction{a}) if got := c.get(); len(got) != 1 { t.Fatalf("re-listed id ran again: %v", got) } } func TestOpActions_EachDoorAndItsOutcome(t *testing.T) { c := &calls{} h := recordingHandlers(c) fired := 0 var fmu sync.Mutex h.ResultReady = func() { fmu.Lock(); fired++; fmu.Unlock() } o := NewOperatorActions(context.Background(), h, nil) o.Reconcile([]OperatorAction{ {ID: 1, Action: OpOffsiteBackupNow}, {ID: 2, Action: OpAbandonExtend, Arg: "30"}, {ID: 3, Action: OpRunJob, Arg: "fill-watch"}, }) o.running.Wait() for id := int64(1); id <= 3; id++ { if r := resultFor(t, o, id); r.Outcome != OutcomeDone { t.Errorf("#%d: %+v", id, r) } } if r := resultFor(t, o, 2); r.Message != "the set-aside history is now deleted on 2026-11-01" { t.Errorf("extend message = %q", r.Message) } fmu.Lock() defer fmu.Unlock() if fired != 3 { t.Errorf("ResultReady fired %d times, want 3 (one per finished action)", fired) } } func TestOpActions_RefusalsAndFailuresAreDistinct(t *testing.T) { h := OperatorActionHandlers{ OffsiteBackupNow: func() (string, func(context.Context) error) { return "a backup run is already in flight", nil }, AbandonStop: func() error { return errors.New("no abandonment countdown is running on this box") }, RunJob: func(string) (<-chan error, error) { return nil, errors.New("the job is already running") }, } o := NewOperatorActions(context.Background(), h, nil) o.Reconcile([]OperatorAction{{ID: 1, Action: OpOffsiteBackupNow}, {ID: 2, Action: OpAbandonStop}, {ID: 3, Action: OpRunJob, Arg: "offsite-proof"}, {ID: 4, Action: OpAbandonExtend, Arg: "5"}}) o.running.Wait() want := map[int64]string{1: OutcomeRefused, 2: OutcomeFailed, 3: OutcomeRefused, 4: OutcomeRefused /* nil handler */} for id, w := range want { if r := resultFor(t, o, id); r.Outcome != w { t.Errorf("#%d: outcome %q, want %q (%s)", id, r.Outcome, w, r.Message) } } } // The wire: the reply's operator_actions decodes into PushResponse, and the report's // operator_action_results is what BuildReport attaches. func TestOpActions_WireShapes(t *testing.T) { var pr PushResponse if err := json.Unmarshal([]byte(`{"status":"ok","operator_actions":[{"id":7,"action":"run_job","arg":"fill-watch"}]}`), &pr); err != nil { t.Fatal(err) } if len(pr.OperatorActions) != 1 || pr.OperatorActions[0] != (OperatorAction{ID: 7, Action: "run_job", Arg: "fill-watch"}) { t.Fatalf("decoded %+v", pr.OperatorActions) } c := &calls{} o := NewOperatorActions(context.Background(), recordingHandlers(c), nil) SetOperatorActions(o) defer SetOperatorActions(nil) o.Reconcile(pr.OperatorActions) o.running.Wait() b, _ := json.Marshal(Report{OperatorActionResults: pendingOperatorActionResults()}) var back struct { Results []map[string]interface{} `json:"operator_action_results"` } if err := json.Unmarshal(b, &back); err != nil || len(back.Results) != 1 || back.Results[0]["outcome"] != "done" || back.Results[0]["id"].(float64) != 7 { t.Fatalf("report carried %s", b) } }