package web import ( "bytes" "io" "log" "net/http/httptest" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // Catches template SYNTAX errors (template.Must in New panics) and that the per-customer floor renders // on the Customers list. Field-level execution errors surface as a non-nil ExecuteTemplate error. func TestTemplates_FloorRender(t *testing.T) { st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("store.New: %v", err) } t.Cleanup(func() { st.Close() }) s := New(st, "", "", "test", time.Hour, log.New(io.Discard, "", 0)) // template.Must runs here // configs.html — the list page data shape used by handleConfigList (global floor/artifacts moved // to the Configuration tab; the per-customer effective floor still renders here). cfgData := struct { Customers []customerListEntry ActiveNav string Flash string }{ Customers: []customerListEntry{{ CustomerID: "c", EffectiveFloor: "0.87.0", FloorOverride: "0.87.0", BelowFloor: true, ControllerVersion: "0.86.0", HasConfig: true, }}, ActiveNav: "configs", } var buf bytes.Buffer if err := s.templates.ExecuteTemplate(&buf, "configs.html", cfgData); err != nil { t.Fatalf("render configs.html: %v", err) } if !bytes.Contains(buf.Bytes(), []byte("0.87.0")) { t.Errorf("configs.html missing per-customer floor content") } // The global floor + artifact cards must NOT be on the Customers page anymore. if bytes.Contains(buf.Bytes(), []byte("global floor")) || bytes.Contains(buf.Bytes(), []byte("Day-0 artifacts")) { t.Errorf("configs.html still renders global settings that moved to Configuration") } // configuration.html — the Configuration tab now carries the global floor + artifact settings. confData := map[string]interface{}{ "CSRFToken": "tok", "CSRFField": s.csrfField(httptest.NewRequest("GET", "/", nil)), "AssetCount": 0, "AssetLastSync": "", "GlobalFloor": "0.86.0", "Artifacts": store.ArtifactManifest{AgentVersion: "0.43.0", GoldenVersion: "0.85.1"}, "Flash": "artifacts_set", } buf.Reset() if err := s.templates.ExecuteTemplate(&buf, "configuration.html", confData); err != nil { t.Fatalf("render configuration.html: %v", err) } body := buf.String() for _, want := range []string{"global floor", "Day-0 artifacts", "0.86.0", "0.43.0", "/configuration/global-floor", "/configuration/artifacts", "Artifact manifest saved"} { if !strings.Contains(body, want) { t.Errorf("configuration.html missing %q", want) } } } // Scenario D of the v0.31.0 critical-severity fix: a customer with critical events must render a // distinct severity-critical count badge on the dashboard, ordered BEFORE the error badge. // (Pre-fix consumer gap: criticals were accepted but invisible in the 24h summary counts.) func TestTemplates_DashboardCriticalBadge(t *testing.T) { st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("store.New: %v", err) } t.Cleanup(func() { st.Close() }) s := New(st, "", "", "test", time.Hour, log.New(io.Discard, "", 0)) // Mirrors the anonymous dashboardCustomer struct in handleDashboard. type dashboardCustomer struct { store.CustomerSummary OverallStatus string HostCause string BackupAge string EventCriticals int EventErrors int EventWarnings int } // v0.65.0: dashboard.html takes {Customers, OffsiteTile, PBSTile}; nil tiles → no gauges rendered. data := struct { Customers []dashboardCustomer OffsiteTile any PBSTile any }{Customers: []dashboardCustomer{{ CustomerSummary: store.CustomerSummary{CustomerID: "c1", CustomerName: "Acme", ReceivedAt: time.Now()}, OverallStatus: "ok", BackupAge: "–", EventCriticals: 2, EventErrors: 1, EventWarnings: 0, }}} var buf bytes.Buffer if err := s.templates.ExecuteTemplate(&buf, "dashboard.html", data); err != nil { t.Fatalf("render dashboard.html: %v", err) } body := buf.String() critIdx := strings.Index(body, `severity-badge severity-critical">2<`) errIdx := strings.Index(body, `severity-badge severity-error">1<`) if critIdx < 0 { t.Fatalf("dashboard.html missing the severity-critical count badge") } if errIdx < 0 { t.Fatalf("dashboard.html missing the severity-error count badge") } if critIdx > errIdx { t.Errorf("critical badge renders AFTER the error badge (crit@%d, err@%d) — must be first", critIdx, errIdx) } } // GL-7 Part 1: the retrieval passphrase must be MASKED by default and NEVER baked into a copyable // command block. The value still ships in data-secret (the reveal/copy mechanism — the existing // model), but the Option-3 debug curl must carry a placeholder, not the secret. // RED-PROOF: revert the Option-3 block to `X-Retrieval-Password: {{.Config.RetrievalPassword}}` // (or the #retrieval-pw code back to the raw value) → the secret appears in a command / unmasked → // the "not in the -H command" / masked assertions FAIL. func TestTemplates_PassphraseHardened(t *testing.T) { st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("store.New: %v", err) } t.Cleanup(func() { st.Close() }) const secret = "correct-horse-battery-staple-9f2a" if err := st.SaveCustomerConfig(&store.CustomerConfig{ CustomerID: "peti-felhom", CustomerName: "Peti", Domain: "sajatfelhom.hu", RetrievalPassword: secret, APIKey: "apikey-xyz", Status: "active", }); err != nil { t.Fatalf("SaveCustomerConfig: %v", err) } s := New(st, "", "", "test", time.Hour, log.New(io.Discard, "", 0)) rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/configs/peti-felhom", nil) s.handleCustomerUnified(rr, req, "peti-felhom") if rr.Code != 200 { t.Fatalf("customer page status = %d", rr.Code) } html := rr.Body.String() // (a) the secret must NOT appear inside the Option-3 curl command (no X-Retrieval-Password: ). if strings.Contains(html, "X-Retrieval-Password: "+secret) { t.Errorf("passphrase is baked into the Option-3 command (must be a placeholder)") } // The Option-3 command carries the placeholder instead. if !strings.Contains(html, "YOUR-RETRIEVAL-PASSWORD") { t.Errorf("Option-3 command missing the passphrase placeholder") } // (b) the visible retrieval-pw node is masked by default (bullets), not the cleartext value. if !strings.Contains(html, `id="retrieval-pw"`) { t.Fatalf("retrieval-pw node missing") } // the reveal control + copy-secret wiring must be present (the value lives in data-secret). if !strings.Contains(html, `onclick="toggleSecret('retrieval-pw')"`) || !strings.Contains(html, `onclick="copySecret('retrieval-pw')"`) { t.Errorf("reveal/copy-secret controls missing") } if !strings.Contains(html, `data-secret="`+secret+`"`) { t.Errorf("data-secret not populated for the reveal control") } // the DEFAULT visible masked text is a run of bullet entities; the raw secret is only in // data-secret, never the code node's visible text content. i := strings.Index(html, `id="retrieval-pw"`) codeText := html[i : strings.Index(html[i:], "")+i] if !strings.Contains(codeText, "•••") { t.Errorf("retrieval-pw is not masked by default (no bullet-entity run)") } if strings.Contains(codeText[strings.Index(codeText, ">")+1:], secret) { t.Errorf("raw secret is the retrieval-pw node's visible default text (must be masked)") } } // GL-7 Part 2/3: the install-command generator renders its control surface, targets the right // script version, keeps a JS-off static fallback command, and NEVER offers the dangerous/operator- // only flags as controls. func TestTemplates_InstallGenerator(t *testing.T) { st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("store.New: %v", err) } t.Cleanup(func() { st.Close() }) if err := st.SaveCustomerConfig(&store.CustomerConfig{ CustomerID: "peti-felhom", CustomerName: "Peti", Domain: "sajatfelhom.hu", RetrievalPassword: "pw", APIKey: "k", Status: "active", }); err != nil { t.Fatalf("SaveCustomerConfig: %v", err) } s := New(st, "", "", "test", time.Hour, log.New(io.Discard, "", 0)) rr := httptest.NewRecorder() s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/configs/peti-felhom", nil), "peti-felhom") if rr.Code != 200 { t.Fatalf("status = %d", rr.Code) } html := rr.Body.String() // control surface present (curated subset — all real v1.12.0 flags) for _, id := range []string{ `name="gen-mode" value="appliance"`, `name="gen-mode" value="byo"`, `id="gen-cores"`, `id="gen-memory"`, `id="gen-vmid"`, `id="gen-node"`, `id="gen-acl"`, `id="gen-pubkey"`, `id="gen-preserve"`, `id="gen-dry"`, `id="gen-preflight"`, `id="gen-skip"`, `id="gen-leaf"`, } { if !strings.Contains(html, id) { t.Errorf("generator control missing: %s", id) } } // targets the right script version + carries the client-side customer id if !strings.Contains(html, hostInstallVersion) { t.Errorf("ScriptVersion %s not rendered", hostInstallVersion) } if !strings.Contains(html, `data-customer-id="peti-felhom"`) { t.Errorf("generator missing data-customer-id") } // JS-off static fallback: the Option-1/2 commands still show --customer-id + a mode placeholder if !strings.Contains(html, "--customer-id peti-felhom --mode") { t.Errorf("static fallback command missing customer-id + mode") } // the dangerous/operator-only flags are NEVER offered as generator controls for _, f := range []string{"--force", "--rotate-recovery", "--enable-oob", "--remove-golden", "--uninstall", "--adopt-pool", "--rescope-acl"} { if strings.Contains(html, f) { t.Errorf("excluded flag %s must not appear on the customer page", f) } } }