package web import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/osupdates" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "golang.org/x/crypto/bcrypt" ) const sysFull = `{"host":{"cpu_percent":1},"cloudflared":{"status":"running"},"system":{"pve_version":"pve-manager/9.0.11/abc", "kernel_version":"Linux 7.0.14-20-pve #1","vmid":9201,"facts":{"host":{"debian":"13.7","kernel_running":"7.0.14-20-pve", "kernel_next_boot":"7.0.2-6-pve","kernel_next_boot_source":"saved default","held":["tzdata"],"kernel_panic":0,"oops_this_boot":false, "crash_guard":{"armed":false,"tripped":true,"tripped_at":"2026-10-04T16:00:00Z","tripped_reason":"2 unclean boots within 60 minutes","unclean_boots_24h":2}}, "guest":{"debian":"13.7","docker_engine":"29.8.2","containerd":"2.3.6-1~debian.13~trixie","live_restore":"on"}}}}` const sysPartial = `{"host":{"cpu_percent":1},"system":{"pve_version":"pve-manager/9.0.10/x","kernel_version":"Linux 7.0.2-6-pve #1","facts_error":"no running customer guest"}}` const sysOld = `{"host":{"cpu_percent":1}}` func systemServer(t *testing.T) (*Server, *store.Store, *osupdates.Service) { s, st := newTestServer(t) for _, h := range []struct{ id, cust, body string }{{"full-1", "c-full", sysFull}, {"part-1", "c-part", sysPartial}, {"old-1", "c-old", sysOld}} { if err := st.UpsertHost(&store.Host{HostID: h.id, CustomerID: h.cust, APIKey: "k-" + h.id}); err != nil { t.Fatal(err) } if err := st.SaveHostReport(h.id, h.cust, []byte(h.body), store.HostReportDenorm{AgentVersion: "0.142.0", CloudflaredStatus: "running"}); err != nil { t.Fatal(err) } } svc := &osupdates.Service{Store: st, ApproveAfter: 24 * time.Hour, NightsRequired: 1} s.SetOSUpdateAdmin(svc) return s, st, svc } func getSystem(t *testing.T, s *Server) string { t.Helper() rr := httptest.NewRecorder() s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/system", nil)) if rr.Code != 200 { t.Fatalf("GET /system = %d %s", rr.Code, rr.Body.String()) } return rr.Body.String() } // Full, partial and old-agent boxes render; an unreadable value says "unknown", never empty or guessed. // Red-proof: render KernelRunning with no unknownCell and the partial box shows an empty cell. func TestSystemPage_FullPartialUnknown(t *testing.T) { s, _, _ := systemServer(t) b := getSystem(t, s) for _, want := range []string{"9.0.11", "7.0.14-20-pve", "29.8.2", "tzdata", "TRIPPED", "0 (stays off)", "9.0.10", "partial: no running customer guest", "no versions reported (agent older than v0.142.0)", `action="/os/ring/full-1"`, `action="/os/enabled/part-1"`, `action="/os/approve-now"`} { if !strings.Contains(b, want) { t.Errorf("System page lacks %q", want) } } if strings.Count(b, ">unknown<") < 6 { t.Errorf("the partial and old boxes must read unknown, got %d unknown cells", strings.Count(b, ">unknown<")) } if strings.Count(b, `class="c-warn" title="the box could not read it`) < 6 { t.Errorf("an unknown value must be shown amber with its reason, got %d", strings.Count(b, `class="c-warn" title="the box could not read it`)) } if !strings.Contains(b, `class="c-bad" title="2 unclean boots`) { t.Error("a tripped crash guard must be red") } } // The "Approve Docker set" button appears ONLY when the rule allows it (seam rule: one render test per branch). func TestSystemPage_DockerButtonOnlyWhenReady(t *testing.T) { s, st, svc := systemServer(t) if strings.Contains(getSystem(t, s), `action="/os/approve-docker"`) { t.Fatal("button shown with no Docker candidate") } _ = st.SetOSRing("full-1", 0) // A fixed clock: on the real one the page's first-seen stamp could fall one second after the first night's report // and drop it (the flake seen 2026-10-05 under full-suite load). clock := time.Date(2026, 10, 5, 3, 0, 0, 0, time.UTC) svc.Now = func() time.Time { return clock } night := func() { if err := svc.Ingest("full-1", osupdates.Report{RunID: time.Now().String(), Layer: "docker", Trigger: "night", Mode: "apply", Outcome: "nothing", Healthy: true, Installed: []osupdates.Package{{Name: "docker-ce", Version: "5:29.8.2-1", Origin: "Docker"}}}); err != nil { t.Fatal(err) } } night() if strings.Contains(getSystem(t, s), `action="/os/approve-docker"`) { t.Fatal("button shown after ONE night") } night() if !strings.Contains(getSystem(t, s), `action="/os/approve-docker"`) { t.Fatal("button missing after two healthy nights") } } // Every button needs the operator login; a box's own API key is not one. Red-proof: route /os/ outside RequireAuth. func TestSystemButtons_NeedTheOperatorLogin(t *testing.T) { s, st, _ := systemServer(t) h, _ := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost) s.configPasswordHash = string(h) for _, tc := range []struct{ method, path, body string }{ {http.MethodGet, "/system", ""}, {http.MethodPost, "/os/ring/full-1", "ring=0&return=%2Fsystem"}, {http.MethodPost, "/os/enabled/full-1", "on=0&return=%2Fsystem"}, {http.MethodPost, "/os/approve-now", "return=%2Fsystem"}, {http.MethodPost, "/os/approve-docker", "return=%2Fsystem"}, } { for _, auth := range []string{"", "Bearer k-full-1"} { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("X-Requested-With", "XMLHttpRequest") if auth != "" { req.Header.Set("Authorization", auth) } rr := httptest.NewRecorder() s.RequireAuth(http.HandlerFunc(s.ServeHTTP)).ServeHTTP(rr, req) if rr.Code == http.StatusOK || rr.Code == http.StatusSeeOther { t.Errorf("%s %s with auth %q = %d — must be refused", tc.method, tc.path, auth, rr.Code) } } } if st.GetOSHostSettings("full-1").Enabled != true || st.GetOSHostSettings("full-1").Ring != 1 { t.Fatal("an unauthenticated POST changed a box") } } // With the operator's session a page button changes the box and returns to the page. func TestSystemButtons_RedirectBackToThePage(t *testing.T) { s, st, _ := systemServer(t) h, _ := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost) s.configPasswordHash = string(h) cookie, csrf := newRevealSession(t, s) form := url.Values{"on": {"0"}, "return": {"/system"}, "_csrf": {csrf}} req := httptest.NewRequest(http.MethodPost, "/os/enabled/full-1", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rr := httptest.NewRecorder() s.RequireAuth(http.HandlerFunc(s.ServeHTTP)).ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther || !strings.HasPrefix(rr.Header().Get("Location"), "/system?flash=") { t.Fatalf("= %d %q", rr.Code, rr.Header().Get("Location")) } if st.GetOSHostSettings("full-1").Enabled { t.Fatal("the switch did not change") } } // The Hosts page shows the Proxmox version and the running kernel (unknown for an old agent). func TestHostsPage_ProxmoxKernelColumn(t *testing.T) { s, _, _ := systemServer(t) rr := httptest.NewRecorder() s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/hosts", nil)) b := rr.Body.String() if !strings.Contains(b, "Proxmox / kernel") || !strings.Contains(b, "9.0.11") || !strings.Contains(b, "7.0.2-6-pve") { t.Fatalf("hosts column missing:\n%s", b[:min(len(b), 400)]) } }