Files
felhom.eu/hub/internal/web/host_opactions_test.go
T

163 lines
7.3 KiB
Go

package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// Operator brief 2026-10-29: „Restart controller" (the AGENT's closed list) and „Clear hold" (D64 = B, the
// controller's list) on the host page — each behind a confirm step, CSRF like every form.
func heldFixture(t *testing.T) (*Server, *store.Store) {
t.Helper()
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
t.Fatal(err)
}
now := time.Now()
if err := st.UpsertGuestFromReport(&store.Guest{GuestID: "h1-9201", CustomerID: "c1", HostID: "h1", VMID: 9201, DisplayName: "felhom",
Status: "running", ControllerVersion: "0.347.0", LastSeenAt: &now}); err != nil {
t.Fatal(err)
}
if err := st.UpsertHeldApp("c1", "radicale", "restore_mixed", now.Add(-time.Hour), "a restore stopped half-way", now); err != nil {
t.Fatal(err)
}
if err := st.UpsertHeldApp("c1", "gokapi", "unhealthy_stop", now.Add(-time.Hour), "stopped in a crash loop", now); err != nil {
t.Fatal(err)
}
return s, st
}
// RED-PROOF: seen failing before the forms existed.
func TestHostPage_RestartAndClearHoldRenderBehindAConfirm(t *testing.T) {
s, _ := heldFixture(t)
body := renderHost(t, s, "h1")
for _, want := range []string{
`data-restart-controller="9201"`, `name="action" value="restart_controller"`, `name="arg" value="9201"`,
"Restart the controller of guest 9201 on h1 (c1)?",
`data-clear-hold="radicale"`, `name="action" value="clear_hold"`, `name="arg" value="radicale"`,
"This hold protects data from a failed restore. If you clear it, the app may start on half-restored data. Check the restore copy first.",
`data-clear-hold="gokapi"`,
} {
if !strings.Contains(body, want) {
t.Errorf("host page lacks %q", want)
}
}
// the warning only on the data-protecting hold
gk := body[strings.Index(body, `data-clear-hold="gokapi"`):]
gk = gk[:strings.Index(gk, "</details>")]
if strings.Contains(gk, "half-restored") {
t.Error("a crash-loop hold shows the restore warning")
}
if !strings.Contains(gk, "unhealthy_stop") {
t.Error("the confirm step does not name the hold's reason")
}
}
// restart_controller: a row for the AGENT of this host (never the controller's ACK); clear_hold only for an app that
// IS held; every other verb or argument refused with no row. RED-PROOF: seen failing before the handler routed them.
func TestHostPage_RestartAndClearHoldPosts(t *testing.T) {
s, st := heldFixture(t)
if rr := postOperatorAction(t, s, "h1", url.Values{"action": {"restart_controller"}, "arg": {"9201"}}); rr.Code != http.StatusSeeOther {
t.Fatalf("restart: %d %s", rr.Code, rr.Body.String())
}
if a, _ := st.PendingHostOperatorActions("h1"); len(a) != 1 || a[0].Arg != "9201" {
t.Fatalf("the agent's reply would list %+v", a)
}
if c, _ := st.PendingOperatorActions("c1"); len(c) != 0 {
t.Fatalf("the restart reached the controller's ACK: %+v", c)
}
if rr := postOperatorAction(t, s, "h1", url.Values{"action": {"clear_hold"}, "arg": {"radicale"}}); rr.Code != http.StatusSeeOther {
t.Fatalf("clear_hold: %d %s", rr.Code, rr.Body.String())
}
if c, _ := st.PendingOperatorActions("c1"); len(c) != 1 || c[0].Action != "clear_hold" || c[0].Arg != "radicale" {
t.Fatalf("the controller's ACK would list %+v", c)
}
for _, f := range []url.Values{
{"action": {"clear_hold"}, "arg": {"vikunja"}}, // not held
{"action": {"restart_controller"}, "arg": {"9202"}}, // not a guest of this host
{"action": {"restart_controller"}, "arg": {"abc"}},
{"action": {"reboot_host"}, "arg": {"9201"}},
{"action": {"reboot_guest"}, "arg": {"9202"}}, // not a guest of this host
} {
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", 20); len(rows) != 2 {
t.Fatalf("rows = %d, want the two accepted presses only", len(rows))
}
}
// Live 2026-10-29 (hub 0.176.0, Tester 1): the agent reports guests WITHOUT a controller version (the column reads „—"),
// so a button gated on the version never rendered. Every guest of a customer host gets it; the agent refuses a guest it
// did not provision. RED-PROOF: seen failing on 0.176.0's template (gated on .ControllerVersion).
func TestHostPage_RestartRendersForAGuestWithNoReportedVersion(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
t.Fatal(err)
}
now := time.Now()
if err := st.UpsertGuestFromReport(&store.Guest{GuestID: "h1-9201", CustomerID: "c1", HostID: "h1", VMID: 9201, Status: "running", LastSeenAt: &now}); err != nil {
t.Fatal(err)
}
if body := renderHost(t, s, "h1"); !strings.Contains(body, `data-restart-controller="9201"`) {
t.Fatal("no Restart controller for a guest whose controller version the agent does not report")
}
}
// Live 2026-10-29: presses through CC's access token were recorded as „operator CLI (basic auth)". A press is named by
// its channel; a token press names the token (never its secret). RED-PROOF: seen failing on 0.176.0's operatorActor.
func TestOperatorActor_NamesTheAccessToken(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/hosts/h1/operator-action", nil)
r.Header.Set("X-Forwarded-For", "10.9.8.7")
r = r.WithContext(context.WithValue(r.Context(), tokenCtxKey{}, &store.APIToken{Name: "CC_HOSTS"}))
if got := operatorActor(r); !strings.Contains(got, `access token "CC_HOSTS"`) || strings.Contains(got, "basic auth") {
t.Fatalf("actor = %q", got)
}
}
// D65 = A: „Reboot guest" beside „Restart controller", behind a confirm step that says the apps stop for about a minute;
// the press stores a row for THIS host's agent only. RED-PROOF: seen failing before the button and the route existed.
func TestHostPage_RebootGuestRendersAndPosts(t *testing.T) {
s, st := heldFixture(t)
body := renderHost(t, s, "h1")
for _, want := range []string{`data-reboot-guest="9201"`, `name="action" value="reboot_guest"`,
"Reboot guest 9201 on h1 (c1)?", "every app of the household stops for about a minute"} {
if !strings.Contains(body, want) {
t.Errorf("host page lacks %q", want)
}
}
if rr := postOperatorAction(t, s, "h1", url.Values{"action": {"reboot_guest"}, "arg": {"9201"}}); rr.Code != http.StatusSeeOther {
t.Fatalf("reboot: %d %s", rr.Code, rr.Body.String())
}
if a, _ := st.PendingHostOperatorActions("h1"); len(a) != 1 || a[0].Action != "reboot_guest" || a[0].Arg != "9201" {
t.Fatalf("the agent's reply would list %+v", a)
}
if c, _ := st.PendingOperatorActions("c1"); len(c) != 0 {
t.Fatalf("the reboot reached the controller's ACK: %+v", c)
}
}
// A host with no customer has no buttons (no controller, no household) — the other branch of the template gate.
func TestHostPage_NoRebootWithoutACustomer(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "h9", APIKey: "k"}); err != nil {
t.Fatal(err)
}
now := time.Now()
if err := st.UpsertGuestFromReport(&store.Guest{GuestID: "h9-9201", HostID: "h9", VMID: 9201, Status: "running", LastSeenAt: &now}); err != nil {
t.Fatal(err)
}
if body := renderHost(t, s, "h9"); strings.Contains(body, "data-reboot-guest") {
t.Fatal("a host with no customer shows Reboot guest")
}
}