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, "")] 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") } }