Files
felhom.eu/hub/internal/store/opactions_test.go
T
admin 87af859fc3 hub: operator actions for a box (D1, R-314/R-279/R-177, decision 185)
Host page "Operator Actions" card: run off-site backup now, run a check now
(fixed job list), stop / extend (1-30 days) a deletion countdown. POST
/hosts/{id}/operator-action validates against the CLOSED list before
storing (unknown -> 400, no row), stores operator_actions(id, customer_id,
action, arg, requested_at, requested_by, done_at, outcome, message), logs
who pressed (channel + address) and bumps the box's intent. The report ACK
lists pending rows as operator_actions until the box's
operator_action_results closes them (matched on id AND reporting
customer); each closed row becomes a hub-minted operator_action event
(stored, never dispatched). Unanswered after 24 h: expired. A customer
RESET cancels pending rows. Wire gate: new root + field-by-field mirror
(controller report.OperatorAction) — needs the controller commit first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 15:17:06 +02:00

111 lines
4.5 KiB
Go

package store
import (
"reflect"
"testing"
"time"
)
// `09` §3 decision 185 (D1). See opactions.go.
// The list is CLOSED, on the hub side too — this fails when an action or a job is added, by design. The
// controller pins the same four (internal/report TestOpActions_ClosedList).
func TestOperatorActions_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)
}
}
func TestOperatorActions_RefusedBeforeStoring(t *testing.T) {
s := newTestStore(t)
bad := []struct{ action, arg string }{
{"delete_everything", ""},
{"run_job", "offsite-abandon-sweep"},
{"run_job", ""},
{"abandon_extend", "0"},
{"abandon_extend", "31"},
{"abandon_extend", "x"},
{"abandon_stop", "1"},
{"offsite_backup_now", "now"},
}
for _, b := range bad {
if _, err := s.CreateOperatorAction("c1", b.action, b.arg, "operator"); err == nil {
t.Errorf("%s(%q) was accepted", b.action, b.arg)
}
}
if rows, _ := s.ListOperatorActions("c1", 50); len(rows) != 0 {
t.Fatalf("a refused action stored %d row(s)", len(rows))
}
for _, ok := range []struct{ action, arg string }{{"offsite_backup_now", ""}, {"abandon_stop", ""}, {"abandon_extend", "1"}, {"abandon_extend", "30"}, {"run_job", "fill-watch"}} {
if _, err := s.CreateOperatorAction("c1", ok.action, ok.arg, "operator"); err != nil {
t.Errorf("%s(%q) refused: %v", ok.action, ok.arg, err)
}
}
}
// Red test (4), store half: listed until a result arrives, then not; a result naming another
// customer's id changes nothing.
func TestOperatorActions_ListedUntilResult_OtherCustomerIgnored(t *testing.T) {
s := newTestStore(t)
id, err := s.CreateOperatorAction("c1", "run_job", "fill-watch", "operator session from 10.0.0.1")
if err != nil {
t.Fatal(err)
}
p, _ := s.PendingOperatorActions("c1")
if len(p) != 1 || p[0] != (OperatorActionDirective{ID: id, Action: "run_job", Arg: "fill-watch"}) {
t.Fatalf("pending = %+v", p)
}
if p2, _ := s.PendingOperatorActions("c2"); len(p2) != 0 {
t.Fatalf("another customer sees c1's action: %+v", p2)
}
// c2 reports a result for c1's id → ignored.
if row, err := s.RecordOperatorActionResult("c2", id, "done", "x"); err != nil || row != nil {
t.Fatalf("cross-customer result recorded: row=%+v err=%v", row, err)
}
if p, _ := s.PendingOperatorActions("c1"); len(p) != 1 {
t.Fatal("a cross-customer result closed the row")
}
if _, err := s.RecordOperatorActionResult("c1", id, "exploded", "x"); err == nil {
t.Fatal("an unknown outcome was accepted")
}
row, err := s.RecordOperatorActionResult("c1", id, "done", "the job fill-watch ran")
if err != nil || row == nil || row.Outcome != "done" || row.DoneAt == nil || row.RequestedBy != "operator session from 10.0.0.1" {
t.Fatalf("record: row=%+v err=%v", row, err)
}
if p, _ := s.PendingOperatorActions("c1"); len(p) != 0 {
t.Fatalf("still listed after its result: %+v", p)
}
// A repeated result (the box re-sends until it sees the id gone) is a no-op.
if row, _ := s.RecordOperatorActionResult("c1", id, "failed", "late"); row != nil {
t.Fatal("a second result overwrote the first")
}
}
func TestOperatorActions_ExpireAndResetCancel(t *testing.T) {
s := newTestStore(t)
old, _ := s.CreateOperatorAction("c1", "offsite_backup_now", "", "operator")
if _, err := s.db.Exec(`UPDATE operator_actions SET requested_at = ? WHERE id = ?`, time.Now().UTC().Add(-25*time.Hour), old); err != nil {
t.Fatal(err)
}
fresh, _ := s.CreateOperatorAction("c1", "abandon_stop", "", "operator")
p, _ := s.PendingOperatorActions("c1")
if len(p) != 1 || p[0].ID != fresh {
t.Fatalf("a day-old action is still listed: %+v", p)
}
if r, _ := s.getOperatorAction(old); r == nil || r.Outcome != OperatorActionExpired {
t.Fatalf("old row = %+v, want expired", r)
}
if err := s.PurgeCustomerResetDBState("c1", false); err != nil {
t.Fatal(err)
}
if p, _ := s.PendingOperatorActions("c1"); len(p) != 0 {
t.Fatalf("a RESET left a pending action for the next box: %+v", p)
}
if r, _ := s.getOperatorAction(fresh); r == nil || r.Outcome != OperatorActionCancelled {
t.Fatalf("fresh row after reset = %+v, want cancelled (kept as a record)", r)
}
}