175ecfcdd2
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
156 lines
6.9 KiB
Go
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)])
|
|
}
|
|
}
|