3f84f82c3d
gates / gates (push) Successful in 56s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
200 lines
7.4 KiB
Go
200 lines
7.4 KiB
Go
package report
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"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)
|
|
}
|
|
}
|
|
// „done" for a job means it ran, never what it found (security review 2026-10-08): the two off-site checks
|
|
// return nil whatever their verdict, so the message must not read as a pass.
|
|
if r := resultFor(t, o, 3); !strings.Contains(r.Message, "does not say what it found") {
|
|
t.Errorf("run_job done message = %q — it must say it does not report the finding", r.Message)
|
|
}
|
|
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)
|
|
}
|
|
}
|