Files
felhom.eu/hub/internal/web/render_test.go
T
admin 844fbfa749 GL-7 Part 2: install-command generator on the customer page
Replaces the static Option-1/2 Setup Command blocks with an interactive,
client-side builder: mode (required radio), cores/memory (required for byo,
optional for appliance), vmid, node, acl-storages (quote-wrapped),
operator-pubkey-file, preserve-state-from, and --dry-run/--preflight-only/
--skip-provision/--allow-new-leaf checkboxes. genFlags()/genUpdate() assemble
a live-updating download-then-run command (never curl|bash) + a local-run
variant, enforcing the script's own rules client-side (mode required; byo
requires caps → shows a warning + no runnable command; appliance hides the
caps requirement; allow-new-leaf shows its leaf-regen warning). Emits ONLY
real host-install v1.12.0 flags; the dangerous/operator-only set
(--force/--rotate-recovery/--enable-oob/--remove-golden/--uninstall/
--adopt-pool/--rescope-acl) is never offered. Graceful static fallback:
the server-rendered Option-1/2 commands keep --customer-id + a --mode
placeholder when JS is off. No framework/CDN/network; ScriptVersion (const,
in sync with SCRIPT_VERSION) drives the header. Render/structure test covers
the control ids, version, fallback, and the excluded-flag absence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-09 08:29:15 +02:00

231 lines
9.4 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
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)
}
}
}