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_guest"}, "arg": {"9201"}}, } { 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) } }