Files
felhom.eu/hub/internal/web/system_test.go
T

156 lines
6.9 KiB
Go

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)])
}
}