Files
felhom.eu/hub/internal/web/render_test.go
T
admin 36c72138f1 hub: dead-host roll-up honesty - customer status folds worst expected host
Customer status (dashboard row, /configs list, detail header + strip) is
now worst(controllerDerived, hostStatusOf(each expected host)) via the ONE
staleness definition (Server.hostStatus, hosts.go - shared with the
HostStalenessChecker; no second threshold). Any host down/stale caps the
customer at WARN with a cause chip naming the host ("host down: <id>");
pending (never-reported) hosts worsen only once the customer has reported
(onboarding exclusion). The three previously-inlined controller-status
chains collapse into controllerStatus() (rollup.go). Display + derivation
only - checker alerting untouched.

Live shape pinned (drill-1 / Peti cluster): host down 23h + controller
report minutes old rendered a GREEN row - TestRollup_DeadHostMasking now
fails that exact outcome. Red-proof: short-circuiting foldHostStatus to
controller-only flips C + two D subtests red ("dashboard row is GREEN
over a 23h-dead host").
2026-07-13 14:52:04 +02:00

232 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
data := []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: <secret>).
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:], "</code>")+i]
if !strings.Contains(codeText, "&#x2022;&#x2022;&#x2022;") {
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)
}
}
}