Files
felhom-controller/controller/internal/report/opactions_test.go
T

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)
}
}