Files
felhom.eu/hub/internal/web/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

159 lines
6.0 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// `09` §3 decision 185 (D1). Red test (4) of the design, web half: a host-page POST stores a row and
// bumps the box's intent; an unknown action is refused with no row. Plus a render test per branch of
// the card's template gate (the seam-built-but-never-wired trap covers templates).
func postOperatorAction(t *testing.T, s *Server, hostID string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/hosts/"+hostID+"/operator-action", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Forwarded-For", "10.9.8.7")
// The CLI channel's CSRF pass (validateCSRF): Basic auth + the operator header.
req.SetBasicAuth("", "pw")
req.Header.Set(OperatorCLIHeader, "confirm")
rr := httptest.NewRecorder()
s.handleOperatorAction(rr, req, hostID)
return rr
}
func TestOperatorAction_PostStoresAndBumpsIntent(t *testing.T) {
s, st := newTestServer(t)
hub := intent.New()
s.SetIntentHub(hub)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
t.Fatal(err)
}
before := hub.Generation("c1")
rr := postOperatorAction(t, s, "h1", url.Values{"action": {"abandon_extend"}, "arg": {"14"}})
if rr.Code != http.StatusSeeOther {
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
}
rows, _ := st.ListOperatorActions("c1", 10)
if len(rows) != 1 || rows[0].Action != "abandon_extend" || rows[0].Arg != "14" || rows[0].DoneAt != nil {
t.Fatalf("rows = %+v", rows)
}
if !strings.Contains(rows[0].RequestedBy, "10.9.8.7") {
t.Errorf("requested_by = %q, want the operator's address", rows[0].RequestedBy)
}
if hub.Generation("c1") == before {
t.Error("the box's intent was not bumped — its wait channel would not wake")
}
if p, _ := st.PendingOperatorActions("c1"); len(p) != 1 {
t.Fatalf("the next ACK would list %d action(s), want 1", len(p))
}
}
func TestOperatorAction_UnknownRefusedNothingStored(t *testing.T) {
s, st := newTestServer(t)
hub := intent.New()
s.SetIntentHub(hub)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
t.Fatal(err)
}
before := hub.Generation("c1")
for _, f := range []url.Values{
{"action": {"delete_offsite"}},
{"action": {"run_job"}, "arg": {"offsite-abandon-sweep"}},
{"action": {"abandon_extend"}, "arg": {"45"}},
} {
if rr := postOperatorAction(t, s, "h1", f); rr.Code != http.StatusBadRequest {
t.Errorf("%v: status = %d, want 400", f, rr.Code)
}
}
if rows, _ := st.ListOperatorActions("c1", 10); len(rows) != 0 {
t.Fatalf("a refused press stored %+v", rows)
}
if hub.Generation("c1") != before {
t.Error("a refused press woke the box")
}
// A host with no customer has no controller to act.
if err := st.UpsertHost(&store.Host{HostID: "lonely", APIKey: "k2"}); err != nil {
t.Fatal(err)
}
if rr := postOperatorAction(t, s, "lonely", url.Values{"action": {"abandon_stop"}}); rr.Code != http.StatusBadRequest {
t.Errorf("no-customer host: status = %d, want 400", rr.Code)
}
}
func renderHost(t *testing.T, s *Server, hostID string) string {
t.Helper()
rr := httptest.NewRecorder()
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/"+hostID, nil), hostID)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
return rr.Body.String()
}
// One render per branch: customer + no rows, customer + rows (pending and closed), no customer.
func TestOperatorAction_CardRendersPerBranch(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
t.Fatal(err)
}
body := renderHost(t, s, "h1")
if got := strings.Count(body, `action="/hosts/h1/operator-action"`); got != 4 {
t.Errorf("customer host: %d operator-action forms, want 4", got)
}
for _, want := range []string{`value="offsite_backup_now"`, `value="abandon_stop"`, `value="abandon_extend"`, `value="run_job"`, `<option value="fill-watch">`, `max="30"`, "No operator actions for this box yet."} {
if !strings.Contains(body, want) {
t.Errorf("customer host, no rows: missing %q", want)
}
}
id, _ := st.CreateOperatorAction("c1", "run_job", "offsite-proof", "operator browser session from 10.0.0.1")
_, _ = st.CreateOperatorAction("c1", "abandon_stop", "", "operator browser session from 10.0.0.1")
if _, err := st.RecordOperatorActionResult("c1", id, "refused", "the job offsite-proof was not started"); err != nil {
t.Fatal(err)
}
body = renderHost(t, s, "h1")
for _, want := range []string{"run_job offsite-proof", ">refused<", "the job offsite-proof was not started", ">pending<", "from 10.0.0.1"} {
if !strings.Contains(body, want) {
t.Errorf("customer host, rows: missing %q", want)
}
}
if strings.Contains(body, "No operator actions for this box yet.") {
t.Error("the empty line rendered beside rows")
}
if err := st.UpsertHost(&store.Host{HostID: "lonely", APIKey: "k2"}); err != nil {
t.Fatal(err)
}
body = renderHost(t, s, "lonely")
if strings.Contains(body, "/operator-action") {
t.Error("a host with no customer rendered operator-action buttons")
}
if !strings.Contains(body, "there is no controller to act") {
t.Error("the no-customer branch did not say why there are no buttons")
}
}
func TestOperatorAction_NoCSRFNoRow(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/hosts/h1/operator-action", strings.NewReader("action=abandon_stop"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleOperatorAction(rr, req, "h1")
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rr.Code)
}
if rows, _ := st.ListOperatorActions("c1", 10); len(rows) != 0 {
t.Fatal("a press without CSRF stored a row")
}
}