Files
felhom.eu/hub/internal/web/system_layout_test.go
T
admin cf6fec8d87
gates / gates (push) Successful in 6m17s
hub (unreleased): the System page answers 'is anything wrong?' and 'is anything waiting for me?' first
Needs attention, Waiting for you (one card per kernel/Docker/Proxmox set the button may approve), a
7-column Boxes table whose rows open to every old value, and a closed Details (release ids, cancelled
approvals, ring-0 counts, floors, crash guard and root files, Approve now - which now asks first).
Same data, buttons, routes and CSRF field. No deploy. Screenshots in audits/hub-system-page-2026-10-10/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-10 15:38:25 +02:00

335 lines
18 KiB
Go

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{"<section", `<details class="card more"`} {
if j := strings.Index(rest[1:], m); j >= 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</dt>\"".
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</dt>", ">Proxmox</dt>", ">Kernel (running)</dt>", ">Kernel (next boot)</dt>", ">Kernel (default)</dt>",
">Kernel step</dt>", ">Debian</dt>", ">Felhom release</dt>", ">Pending</dt>", ">Not covered</dt>", ">Held</dt>",
">Reboot needed</dt>", ">kernel.panic</dt>", ">Oops</dt>", ">Crash restarts 24 h</dt>", ">Crash guard</dt>",
">Root files</dt>", ">Agent</dt>", ">Guest Debian</dt>", ">Restart needed</dt>", ">Last disk trim</dt>",
">Docker</dt>", ">containerd</dt>", ">live-restore</dt>", ">Docker release</dt>", ">Last OS leg</dt>",
"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: <strong>0.229.0", "vouched agent: <strong>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<"))
}
// <details> carries the click-to-open parts: the page works without JavaScript, and Details is closed by default.
if !strings.Contains(page, `<details class="card more" id="details">`) || strings.Contains(page, `id="details" open`) {
t.Error("Details must be a <details> element, closed by default")
}
if strings.Count(page, `<details class="bx"`) != 4 {
t.Errorf("every box must open in place, got %d", strings.Count(page, `<details class="bx"`))
}
}
// "Approve now (guest + host)" is in Details, never above it, and asks before it posts.
func TestSystemPage_ApproveNowIsInDetailsAndAsks(t *testing.T) {
page := renderSystem(t, fixtureInput(false))
at := strings.Index(page, `action="/os/approve-now"`)
if at < 0 || at < strings.Index(page, `id="details"`) {
t.Fatal("Approve now must sit inside Details")
}
if !strings.Contains(page[at:], `data-confirm="Approve the guest and host sets now, without the usual 24 h and one night?`) {
t.Fatal("Approve now must ask first")
}
}
// Every form posts the CSRF field, and only to the routes the page always had.
// COMPANION RED-PROOF (observed): delete the _csrf input from the card form → "form /os/approve-pve lacks the CSRF or return field".
func TestSystemPage_EveryFormCarriesCSRF(t *testing.T) {
page := renderSystem(t, fixtureInput(true))
forms := regexp.MustCompile(`(?s)<form method="POST" action="([^"]+)".*?</form>`).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, `<form`) {
t.Errorf("empty Waiting for you:\n%s", w)
}
}
// TestSystemPage_WriteFixture writes the fixture page for the screenshots (SYSTEM_PAGE_FIXTURE_OUT=<dir>); 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(`<link rel="stylesheet" href="/style.css\?v=[^"]*">`)
for name, ready := range map[string]bool{"system-fixture.html": false, "system-fixture-card.html": true} {
page := link.ReplaceAllString(renderSystem(t, fixtureInput(ready)), "<style>"+string(css)+"</style>")
if err := os.WriteFile(filepath.Join(dir, name), []byte(page), 0o644); err != nil {
t.Fatal(err)
}
}
}