hub v0.132.0: the System page (versions + OS updates with the ring/switch/approve buttons, R-852), the Hosts Proxmox/kernel column, the operator-approved Docker engine release (2 healthy ring-0 nights), the crash-guard events (R-851); evidence audits/os-docker-crash-2026-10-04
gates / gates (push) Successful in 30s
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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)
|
||||
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)])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user