package store import ( "reflect" "strings" "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) { // D64 = B (operator 2026-10-29 12:10): clear_hold joins the controller's list. if got, want := OperatorActionNames(), []string{"abandon_extend", "abandon_stop", "clear_hold", "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) } } // The AGENT's closed list (operator brief 2026-10-29, Part 1; D65 = A adds reboot_guest). RED-PROOF: seen failing before // the list existed, and again before reboot_guest joined it. func TestHostOperatorActions_ClosedList(t *testing.T) { if got, want := HostOperatorActionNames(), []string{"reboot_guest", "restart_controller"}; !reflect.DeepEqual(got, want) { t.Fatalf("host operator actions = %v, want exactly %v — a new verb needs the operator's word", got, want) } for _, a := range OperatorActionNames() { if IsHostOperatorAction(a) { t.Errorf("%s is on BOTH lists — a controller action must never reach the agent", a) } } } // Each list refuses the other's verbs and every bad argument BEFORE storing. func TestHostOperatorActions_RefusedBeforeStoring(t *testing.T) { s := newTestStore(t) bad := []struct{ action, arg string }{ {"restart_controller", ""}, {"restart_controller", "abc"}, {"restart_controller", "99"}, {"restart_controller", "9201 "}, {"restart_controller", "-1"}, {"restart_controller", "10000000000"}, {"reboot_host", "9201"}, {"clear_hold", "radicale"}, {"offsite_backup_now", ""}, {"reboot_guest", ""}, {"reboot_guest", "abc"}, {"reboot_guest", "99"}, } for _, b := range bad { if _, err := s.CreateHostOperatorAction("c1", "h1", b.action, b.arg, "operator"); err == nil { t.Errorf("host action %s(%q) was accepted", b.action, b.arg) } } for _, b := range []struct{ action, arg string }{{"clear_hold", ""}, {"clear_hold", "../x"}, {"clear_hold", "Rad icale"}, {"clear_hold", "a/b"}, {"restart_controller", "9201"}} { if _, err := s.CreateOperatorAction("c1", b.action, b.arg, "operator"); err == nil { t.Errorf("controller action %s(%q) was accepted", b.action, b.arg) } } if _, err := s.CreateOperatorAction("c1", "clear_hold", "radicale", "operator"); err != nil { t.Fatalf("clear_hold radicale refused: %v", err) } if _, err := s.CreateHostOperatorAction("c1", "h1", "restart_controller", "9201", "operator"); err != nil { t.Fatalf("restart_controller 9201 refused: %v", err) } if _, err := s.CreateHostOperatorAction("c1", "h1", "reboot_guest", "9201", "operator"); err != nil { t.Fatalf("reboot_guest 9201 refused: %v", err) } if _, err := s.CreateOperatorAction("c1", "reboot_guest", "9201", "operator"); err == nil { t.Error("reboot_guest reached the controller's list") } } // D65 = A: a reboot the agent has not picked up in 30 minutes expires — a box that was off must not reboot by itself // when it comes back hours later. A restart keeps the day. RED-PROOF: drop the reboot TTL → the 31-minute-old reboot is // still listed → FAILS. func TestHostOperatorActions_ARebootExpiresAfter30Minutes(t *testing.T) { s := newTestStore(t) rb, _ := s.CreateHostOperatorAction("c1", "h1", "reboot_guest", "9201", "operator") rs, _ := s.CreateHostOperatorAction("c1", "h1", "restart_controller", "9201", "operator") if _, err := s.db.Exec(`UPDATE operator_actions SET requested_at = ? WHERE id IN (?, ?)`, time.Now().UTC().Add(-31*time.Minute), rb, rs); err != nil { t.Fatal(err) } p, _ := s.PendingHostOperatorActions("h1") if len(p) != 1 || p[0].ID != rs { t.Fatalf("pending = %+v, want only the restart #%d", p, rs) } if r, _ := s.getOperatorAction(rb); r == nil || r.Outcome != OperatorActionExpired || !strings.Contains(r.Message, "30 minutes") { t.Fatalf("the old reboot = %+v, want expired, naming 30 minutes", r) } } // An agent row reaches only its host's envelope, a controller row only the customer's ACK; a result closes only a row of // the reporting host. RED-PROOF: drop `AND host_id = ”` from PendingOperatorActions → the restart reaches the controller. func TestHostOperatorActions_EachSideSeesOnlyItsOwn(t *testing.T) { s := newTestStore(t) cid, err := s.CreateOperatorAction("c1", "clear_hold", "radicale", "operator") if err != nil { t.Fatal(err) } hid, err := s.CreateHostOperatorAction("c1", "h1", "restart_controller", "9201", "operator") if err != nil { t.Fatal(err) } ctl, _ := s.PendingOperatorActions("c1") if len(ctl) != 1 || ctl[0].ID != cid { t.Fatalf("the controller ACK lists %+v, want only #%d", ctl, cid) } agent, _ := s.PendingHostOperatorActions("h1") if len(agent) != 1 || agent[0].ID != hid || agent[0].Action != "restart_controller" || agent[0].Arg != "9201" { t.Fatalf("the agent envelope lists %+v, want only #%d", agent, hid) } if other, _ := s.PendingHostOperatorActions("h2"); len(other) != 0 { t.Fatalf("another host sees %+v", other) } if row, _ := s.RecordHostOperatorActionResult("h2", hid, "done", "x"); row != nil { t.Fatal("another host closed the row") } if row, _ := s.RecordOperatorActionResult("c1", hid, "done", "x"); row != nil { t.Fatal("the controller's result channel closed an agent row") } row, err := s.RecordHostOperatorActionResult("h1", hid, "done", "the controller answered after 14 s") if err != nil || row == nil || row.Outcome != "done" || row.HostID != "h1" { t.Fatalf("result: %+v %v", row, err) } if left, _ := s.PendingHostOperatorActions("h1"); len(left) != 0 { t.Fatalf("a closed row is still listed: %+v", left) } list, _ := s.ListOperatorActions("c1", 10) if len(list) != 2 { t.Fatalf("the host page lists %d rows, want both", len(list)) } }