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

71 lines
2.7 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// `09` §3 decision 185 (D1), the design's red test (4), wire half: the report reply lists a pending
// operator action until a result arrives, then not; a result naming another customer's id is ignored;
// a closed result becomes a hub-minted event.
func ackActions(t *testing.T, h *Handler, customerID, extra string) []store.OperatorActionDirective {
t.Helper()
body := `{"customer_id":"` + customerID + `"` + extra + `}`
rr := do(h, http.MethodPost, "/report", globalKey, body)
if rr.Code != http.StatusOK {
t.Fatalf("report: %d %s", rr.Code, rr.Body.String())
}
var ack struct {
OperatorActions []store.OperatorActionDirective `json:"operator_actions"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil {
t.Fatal(err)
}
return ack.OperatorActions
}
func TestReportACK_OperatorActionsUntilResult(t *testing.T) {
h, st, _ := newTestHandler(t)
id, err := st.CreateOperatorAction("c1", "offsite_backup_now", "", "operator browser session from 10.0.0.1")
if err != nil {
t.Fatal(err)
}
// Listed to c1, not to c2.
if got := ackActions(t, h, "c1", ""); len(got) != 1 || got[0].ID != id || got[0].Action != "offsite_backup_now" {
t.Fatalf("c1 ACK = %+v", got)
}
if got := ackActions(t, h, "c2", ""); len(got) != 0 {
t.Fatalf("c2 sees c1's action: %+v", got)
}
// c2 reports a result for c1's id: ignored — c1's ACK still lists it, and no event is saved.
res := fmt.Sprintf(`,"operator_action_results":[{"id":%d,"outcome":"done","message":"forged"}]`, id)
ackActions(t, h, "c2", res)
if got := ackActions(t, h, "c1", ""); len(got) != 1 {
t.Fatalf("a cross-customer result closed c1's action: %+v", got)
}
if evs, _ := st.GetRecentEvents("c2", 10); len(evs) != 0 {
t.Fatalf("a cross-customer result minted an event: %+v", evs)
}
// c1's own result: closed, and THE SAME reply no longer lists it.
res = fmt.Sprintf(`,"operator_action_results":[{"id":%d,"outcome":"done","message":"the off-site backup finished"}]`, id)
if got := ackActions(t, h, "c1", res); len(got) != 0 {
t.Fatalf("still listed in the reply to its own result: %+v", got)
}
evs, _ := st.GetRecentEvents("c1", 10)
if len(evs) != 1 || evs[0].EventType != "operator_action" || evs[0].Source != "hub" ||
!strings.Contains(evs[0].Message, "offsite_backup_now") || !strings.Contains(evs[0].Message, "10.0.0.1") {
t.Fatalf("events = %+v", evs)
}
// The box re-sends until it sees the id gone: a no-op, no second event.
ackActions(t, h, "c1", res)
if evs, _ := st.GetRecentEvents("c1", 10); len(evs) != 1 {
t.Fatalf("a re-sent result minted %d events", len(evs))
}
}