package web import ( "bytes" "fmt" "os" "path/filepath" "regexp" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/osupdates" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "gitea.dooplex.hu/admin/felhom-hub/internal/sysfacts" ) // The System page's 2026-10-10 layout: Needs attention, Waiting for you, Boxes, Details. These tests render the page // from a fixture shaped like the fleet on 2026-10-10 (made-up host ids, no real key or address). var fixNow = time.Date(2026, 10, 10, 15, 17, 0, 0, time.UTC) func fixFacts(kernel, nextBoot string, trimAgo time.Duration) sysfacts.System { trim := fixNow.Add(-trimAgo).Format(time.RFC3339) return sysfacts.Parse(fmt.Sprintf(`{"host":{"cpu_percent":1},"guest_disk_trim":{"schedule":"weekly","guests":[{"vmid":9201, "last_attempt_at":%q,"last_ok_at":%q,"ok":true,"bytes_trimmed":3221225472}]}, "system":{"pve_version":"pve-manager/9.0.11/abc","kernel_version":"Linux %s #1","vmid":9201, "config_bundle":{"version":"0.155.0","bundle_sha256":"vouched-sha"}, "facts":{"host":{"debian":"13.7","kernel_running":%q,"kernel_next_boot":%q,"kernel_next_boot_source":"saved default","held":[], "kernel_panic":10,"oops_this_boot":false,"crash_guard":{"armed":true,"tripped":false,"unclean_boots_24h":0}, "kernel_lane":{"running":%q,"default":%q,"phase":"none"}}, "guest":{"debian":"13.7","docker_engine":"29.8.2","containerd":"2.3.6-1~debian.13~trixie","live_restore":"on"}}}}`, trim, trim, kernel, kernel, nextBoot, kernel, nextBoot)) } func fixLeg(rel, outcome string, ago time.Duration) osupdates.LayerLine { at := fixNow.Add(-ago) return osupdates.LayerLine{ReleaseID: rel, LastOutcome: outcome, LastAt: at, LastSuccessfulLeg: at, WrapperPassSeconds: 41} } // fixtureInput is today's real situation, made up: two ring-0 demo boxes, two testers; the kernel set approved, the // Docker set still being tested, Tester 2 on an agent that reports no versions ("unknown"); four TEST approvals // cancelled. readyPVE adds a Proxmox set ready to approve (a "Waiting for you" card). func fixtureInput(readyPVE bool) systemInput { g, h := "os-guest-20261009-031500", "os-host-20261009-031500" lines := []osupdates.FleetLine{ {HostID: "demo-felhom-a1b2c3", Ring: 0, Enabled: true, Tunnel: "running", Guest: fixLeg(g, "applied", 12*time.Hour), Host: fixLeg(h, "nothing", 12*time.Hour), Docker: fixLeg("os-docker-20261001-031500", "nothing", 12*time.Hour)}, {HostID: "demo-hp-d4e5f6", Ring: 0, Enabled: true, Tunnel: "running", Guest: fixLeg(g, "applied", 12*time.Hour), Host: fixLeg(h, "applied", 12*time.Hour), Docker: fixLeg("os-docker-20261001-031500", "nothing", 12*time.Hour)}, {HostID: "tester1-0f1e2d", Ring: 1, Enabled: true, Tunnel: "running", Guest: fixLeg("os-guest-20261008-031500", "applied", 36*time.Hour), Host: fixLeg(h, "nothing", 12*time.Hour)}, {HostID: "tester2-9a8b7c", Ring: 1, Enabled: true, Tunnel: "running", Guest: fixLeg(g, "nothing", 12*time.Hour), Host: fixLeg(h, "nothing", 12*time.Hour)}, } k := "7.0.14-23-pve" facts := map[string]sysfacts.System{ "demo-felhom-a1b2c3": fixFacts(k, k, 2*24*time.Hour), "demo-hp-d4e5f6": fixFacts(k, k, 3*24*time.Hour), "tester1-0f1e2d": fixFacts("7.0.14-20-pve", "7.0.14-20-pve", 4*24*time.Hour), "tester2-9a8b7c": sysfacts.Parse(`{"host":{"cpu_percent":1}}`), } approved := func(layer, id string, n int, test bool) osupdates.ReleaseInfo { return osupdates.ReleaseInfo{Layer: layer, ID: id, ApprovedAt: fixNow.Add(-30 * time.Hour), ApprovedBy: "auto", Packages: n, Test: test} } rels := []osupdates.ReleaseInfo{approved("guest", g, 214, false), approved("host", h, 389, false), approved("docker", "os-docker-20261001-031500", 4, false), approved("pve", "os-pve-20261007-090000", 31, false), approved("kernel", "os-kernel-20261010-091200", 2, false)} rels[4].ApprovedBy = "operator" var cancelled []osupdates.ReleaseInfo for i, l := range []string{"guest", "host", "docker", "pve"} { c := approved(l, fmt.Sprintf("os-%s-20261006-1%d0000", l, i), 3, true) c.Cancelled = "2026-10-07 08:00:00" cancelled = append(cancelled, c) } cands := []osupdates.Status{ {Layer: "guest", Fingerprint: "fp-g", FirstSeen: fixNow.Add(-36 * time.Hour), Packages: 214, Approved: g}, {Layer: "host", Fingerprint: "fp-h", FirstSeen: fixNow.Add(-36 * time.Hour), Packages: 389, Approved: h}, {Layer: "docker", Fingerprint: "fp-d", FirstSeen: fixNow.Add(-20 * time.Hour), Packages: 4, Waiting: "demo-hp-d4e5f6 has 1 of 2 healthy night Docker step(s) with this set"}, {Layer: "pve", Fingerprint: "fp-p", FirstSeen: fixNow.Add(-4 * 24 * time.Hour), Packages: 31, Approved: "os-pve-20261007-090000", Waiting: "already approved"}, {Layer: "kernel", Fingerprint: "fp-k", FirstSeen: fixNow.Add(-26 * time.Hour), Packages: 2, Approved: "os-kernel-20261010-091200", Waiting: "already approved"}, } if readyPVE { cands[3] = osupdates.Status{Layer: "pve", Fingerprint: "fp-p2", FirstSeen: fixNow.Add(-50 * time.Hour), Packages: 33} } pkgs := map[string][]osupdates.Package{ "fp-d": {{Name: "containerd.io", Version: "2.3.7-1"}, {Name: "docker-ce", Version: "5:29.8.3-1~debian.13~trixie"}}, "fp-p2": {{Name: "pve-manager", Version: "9.0.12"}, {Name: "libpve-common-perl", Version: "9.0.8"}}, "fp-k": {{Name: "proxmox-kernel-7.0", Version: "7.0.14-23"}}, } return systemInput{ Lines: lines, Facts: facts, Names: map[string]string{"demo-felhom-a1b2c3": "Demo N100", "demo-hp-d4e5f6": "Demo HP", "tester1-0f1e2d": "Tester 1", "tester2-9a8b7c": "Tester 2"}, Controllers: map[string]string{"demo-felhom-a1b2c3": "0.232.0", "demo-hp-d4e5f6": "0.232.0", "tester1-0f1e2d": "0.231.0", "tester2-9a8b7c": "0.229.0"}, Agents: map[string]string{"demo-felhom-a1b2c3": "0.156.0", "demo-hp-d4e5f6": "0.156.0", "tester1-0f1e2d": "0.155.0", "tester2-9a8b7c": "0.141.0"}, KernelLines: map[string]osupdates.KernelLine{ "demo-felhom-a1b2c3": {LastOutcome: "applied", LastKernel: k, LastAt: fixNow.Add(-30 * time.Hour)}, "demo-hp-d4e5f6": {LastOutcome: "applied", LastKernel: k, LastAt: fixNow.Add(-30 * time.Hour)}, "tester1-0f1e2d": {Due: k}, }, BundleSince: map[string]time.Time{}, AgentSince: map[string]time.Time{"tester1-0f1e2d": fixNow.Add(-2 * 24 * time.Hour), "tester2-9a8b7c": fixNow.Add(-9 * 24 * time.Hour)}, Stale: 7 * 24 * time.Hour, Reboot: 14 * 24 * time.Hour, NotCov: 14 * 24 * time.Hour, BundleAfter: 7 * 24 * time.Hour, AgentAfter: 7 * 24 * time.Hour, VouchedAgent: "0.156.0", VouchedBundle: "vouched-sha", GlobalFloor: "0.229.0", Floors: []store.CustomerFloorOverride{{CustomerID: "c-tester1", CustomerName: "Tester 1", Version: "0.231.0", SetAt: fixNow.Add(-5 * 24 * time.Hour)}}, Releases: rels, Cancelled: cancelled, Candidates: cands, Packages: func(fp string) []osupdates.Package { return pkgs[fp] }, Nights: func(string, string, time.Time) int { return 2 }, Now: fixNow, } } func renderSystem(t *testing.T, in systemInput) string { t.Helper() s, _ := newTestServer(t) data := buildSystemPage(in) data["CSRFToken"] = "csrf-fixture-token" var b bytes.Buffer if err := s.templates.ExecuteTemplate(&b, "system.html", data); err != nil { t.Fatal(err) } return b.String() } // sysSection returns the page text between two section ids, so a check is made where the item is meant to be. func sysSection(page, id string) string { i := strings.Index(page, `id="`+id+`"`) if i < 0 { return "" } rest := page[i:] end := len(rest) for _, m := range []string{"= 0 && j+1 < end { end = j + 1 } } return rest[:end] } // The contract: every item and button the page had before 2026-10-10 is still on it — in the main part or in Details. // COMPANION RED-PROOF (observed): drop the Docker-engine group from the box panel → "lacks \">containerd\"". func TestSystemPage_EveryItemStillOnThePage(t *testing.T) { page := renderSystem(t, fixtureInput(true)) boxes, details := sysSection(page, "boxes"), sysSection(page, "details") for _, want := range []string{ // per box, in the box panel (every column of the old wide table) `href="/hosts/demo-hp-d4e5f6"`, "Demo HP", `action="/os/ring/demo-hp-d4e5f6"`, `action="/os/enabled/tester1-0f1e2d"`, ">Tunnel", ">Proxmox", ">Kernel (running)", ">Kernel (next boot)", ">Kernel (default)", ">Kernel step", ">Debian", ">Felhom release", ">Pending", ">Not covered", ">Held", ">Reboot needed", ">kernel.panic", ">Oops", ">Crash restarts 24 h", ">Crash guard", ">Root files", ">Agent", ">Guest Debian", ">Restart needed", ">Last disk trim", ">Docker", ">containerd", ">live-restore", ">Docker release", ">Last OS leg", "no versions reported (agent older than v0.142.0)", "9.0.11", "29.8.2", "2.3.6-1~debian.13~trixie", } { if !strings.Contains(boxes, want) { t.Errorf("Boxes lacks %q", want) } } for _, want := range []string{ "Approved releases", "os-kernel-20261010-091200", "214 packages", "by operator", "Cancelled approvals (last 7 days)", "os-docker-20261006-120000", "a TEST approval", "boxes that installed it keep it", "What ring 0 runs now", "first seen", "approved as os-guest-20261009-031500", "1 of 2 healthy night Docker step", `action="/os/approve-now"`, "Version floors", "Global controller floor: 0.229.0", "vouched agent: 0.156.0", `href="/customers/c-tester1"`, "Global floor moves it?", "Crash guard and root files", } { if !strings.Contains(details, want) { t.Errorf("Details lacks %q", want) } } if !strings.Contains(sysSection(page, "waiting"), `action="/os/approve-pve"`) { t.Error("the ready Proxmox set has no button in Waiting for you") } if strings.Count(page, ">unknown<") < 6 { t.Errorf("Tester 2's values must read unknown, got %d", strings.Count(page, ">unknown<")) } //
carries the click-to-open parts: the page works without JavaScript, and Details is closed by default. if !strings.Contains(page, `
`) || strings.Contains(page, `id="details" open`) { t.Error("Details must be a
element, closed by default") } if strings.Count(page, `
`).FindAllStringSubmatch(page, -1) if len(forms) < 10 { t.Fatalf("only %d forms on the page", len(forms)) } allowed := regexp.MustCompile(`^/os/(ring/[a-z0-9-]+|enabled/[a-z0-9-]+|approve-now|approve-docker|approve-pve|approve-kernel)$`) for _, f := range forms { if !strings.Contains(f[0], `name="_csrf" value="csrf-fixture-token"`) || !strings.Contains(f[0], `name="return" value="/system"`) { t.Errorf("form %s lacks the CSRF or return field", f[1]) } if !allowed.MatchString(f[1]) { t.Errorf("form posts to a route the page never had: %s", f[1]) } } } func attentionOf(t *testing.T, in systemInput) string { t.Helper() return sysSection(renderSystem(t, in), "attention") } // Needs attention: a green box is not listed, an amber and a red one are, each with its reason; all green → one line. // COMPANION RED-PROOF (observed): skip the cells in summariseRow → "the amber box (agent behind) is missing or has no reason". func TestSystemPage_NeedsAttention(t *testing.T) { in := fixtureInput(false) att := attentionOf(t, in) if strings.Contains(att, "demo-felhom-a1b2c3") { t.Error("a green box is listed") } if !strings.Contains(att, "tester1-0f1e2d") || !strings.Contains(att, "agent behind: 0.155.0 → 0.156.0") { t.Errorf("the amber box (agent behind) is missing or has no reason:\n%s", att) } if !strings.Contains(att, `class="mark c-bad"`) || !strings.Contains(att, "tester2-9a8b7c") { t.Errorf("the red box (agent behind 9 days) is missing:\n%s", att) } if !strings.Contains(att, "no versions reported") { t.Error("Tester 2's unknown values have no plain reason") } if strings.Contains(att, "All boxes look fine") { t.Error("says all fine while two boxes are not") } // A red cell of its own: the kernel step's failed revert. in.KernelLines["demo-hp-d4e5f6"] = osupdates.KernelLine{LastOutcome: "revert_failed", LastKernel: "7.0.14-23-pve", LastAt: fixNow.Add(-9 * time.Hour)} att = attentionOf(t, in) if !strings.Contains(att, "kernel step: 7.0.14-23-pve revert failed 9 h ago") { t.Errorf("the kernel step's failure has no plain reason:\n%s", att) } // Updates switched off: amber, in plain words. in.Lines[0].Enabled = false if att = attentionOf(t, in); !strings.Contains(att, "OS updates switched off") { t.Error("a box with updates off is not listed") } // All green. green := fixtureInput(false) green.Lines, green.Facts = green.Lines[:2], map[string]sysfacts.System{"demo-felhom-a1b2c3": green.Facts["demo-felhom-a1b2c3"], "demo-hp-d4e5f6": green.Facts["demo-hp-d4e5f6"]} if att = attentionOf(t, green); !strings.Contains(att, "All boxes look fine.") || strings.Contains(att, `class="att"`) { t.Errorf("all-green fleet:\n%s", att) } } // Waiting for you: one card per operator lane that the button may approve (the same gate as before), the empty line, and // guest/host never as a card (they approve themselves). // COMPANION RED-PROOF (observed): drop `c.Waiting != ""` from buildWaiting's test → "kernel not ready: cards [...]" (a card before the rule allows it). func TestSystemPage_WaitingCards(t *testing.T) { ring0 := []string{"demo-felhom-a1b2c3", "demo-hp-d4e5f6"} pk := func(fp string) []osupdates.Package { return map[string][]osupdates.Package{ "k": {{Name: "proxmox-kernel-7.0.14-23-pve-signed", Version: "7.0.14-23"}}, "d": {{Name: "docker-ce", Version: "5:29.8.3-1"}}, "p": {{Name: "pve-manager", Version: "9.0.12"}}, }[fp] } two := func(string, string, time.Time) int { return 2 } for _, c := range []struct { layer, fp, what, action, evidence string }{ {"kernel", "k", "Kernel 7.0.14-23", "/os/approve-kernel", "Started without problems after a night step on demo-felhom-a1b2c3 and demo-hp-d4e5f6."}, {"docker", "d", "Docker engine 29.8.3-1", "/os/approve-docker", "demo-hp-d4e5f6: 2 healthy night(s)"}, {"pve", "p", "Proxmox packages 9.0.12", "/os/approve-pve", "demo-felhom-a1b2c3: 2 healthy night(s)"}, } { cards, testing := buildWaiting([]osupdates.Status{{Layer: c.layer, Fingerprint: c.fp, FirstSeen: fixNow.Add(-26 * time.Hour), Packages: 2}}, ring0, pk, two, fixNow) if len(cards) != 1 || len(testing) != 0 || cards[0].What != c.what || cards[0].Action != c.action || !strings.Contains(cards[0].Evidence, c.evidence) { t.Errorf("%s: cards %+v testing %+v", c.layer, cards, testing) } // Not ready yet → a "still being tested" line, no card. cards, testing = buildWaiting([]osupdates.Status{{Layer: c.layer, Fingerprint: c.fp, Waiting: "needs another night"}}, ring0, pk, two, fixNow) if len(cards) != 0 || len(testing) != 1 || testing[0].Why != "needs another night" { t.Errorf("%s not ready: cards %+v testing %+v", c.layer, cards, testing) } // Approved → neither. cards, testing = buildWaiting([]osupdates.Status{{Layer: c.layer, Fingerprint: c.fp, Approved: "os-x", Waiting: "already approved"}}, ring0, pk, two, fixNow) if len(cards)+len(testing) != 0 { t.Errorf("%s approved: cards %+v testing %+v", c.layer, cards, testing) } } if cards, _ := buildWaiting([]osupdates.Status{{Layer: "guest", Fingerprint: "g", Packages: 3}, {Layer: "host", Fingerprint: "h", Packages: 3}}, ring0, pk, two, fixNow); len(cards) != 0 { t.Errorf("guest/host became cards: %+v", cards) } // Rendered: the card with its button, and the empty line. page := renderSystem(t, fixtureInput(true)) w := sysSection(page, "waiting") if !strings.Contains(w, "Proxmox packages 9.0.12") || !strings.Contains(w, "Approve Proxmox set") { t.Errorf("the ready card is not rendered:\n%s", w) } if !strings.Contains(w, "Docker engine 29.8.3-1") || !strings.Contains(w, "still being tested") { t.Errorf("the Docker set still being tested is not shown:\n%s", w) } if w = sysSection(renderSystem(t, fixtureInput(false)), "waiting"); !strings.Contains(w, "Nothing waits for your approval.") || strings.Contains(w, `); a no-op otherwise. func TestSystemPage_WriteFixture(t *testing.T) { dir := os.Getenv("SYSTEM_PAGE_FIXTURE_OUT") if dir == "" { t.Skip("set SYSTEM_PAGE_FIXTURE_OUT to write the fixture pages") } css, err := os.ReadFile("templates/style.css") if err != nil { t.Fatal(err) } fonts, err := filepath.Abs("static/fonts") if err != nil { t.Fatal(err) } css = bytes.ReplaceAll(css, []byte("url('/static/fonts/"), []byte("url('file://"+fonts+"/")) link := regexp.MustCompile(``) for name, ready := range map[string]bool{"system-fixture.html": false, "system-fixture-card.html": true} { page := link.ReplaceAllString(renderSystem(t, fixtureInput(ready)), "") if err := os.WriteFile(filepath.Join(dir, name), []byte(page), 0o644); err != nil { t.Fatal(err) } } }