Files
felhom.eu/hub/internal/web/render_test.go
T
admin 7f11cfb36c hub v0.65.0 — PBS DR storage visibility (ep0 usage op) + Offsite tab split + dual dashboard gauges (R-5)
Makes PBS DR storage visible like the restic pool box (v0.64.0), differentiated. Scoping
correction: restic = subaccounts on the shared Hetzner Storage Box (Hetzner API); PBS DR =
the felhom-offsite PBS datastore on the ep0 endpoint VM (NO Hetzner API). Option A
(Viktor-ruled): a read-only `usage` op on the felhom-tenantsync ep0 forced command (twin of
fingerprint), polled by a new hub checker on the 15-min throttle. READ-ONLY throughout.

Phase-0 (gate PASSED): on ep0 (PBS 4.2.3), df -B1 --output=size,used,avail <datastore path>
yields bytes (39990112256/7627939840/... ~19%), read-only, existing sudo context, no admin token.

- scripts/felhom-tenantsync.sh -> v1.2.0: read-only `usage` short-circuit (df on the datastore
  path), no customer_id, no admin token, NO mutation. + a bash harness proving zero mutation.
- tenantsync.Client.Usage() + BoxUsage; unknown-op -> typed ErrUsageUnsupported (graceful).
- monitor.PBSDRBoxChecker: OffsiteBoxChecker clone over a usageReader seam; 15-min throttle,
  cached PBSBoxSnapshot, escalation-only pbsdr_box_fill on the "pbsdr-box" scope (operator only,
  no SaveEvent), recovery re-arm. Fill only. THREE states: ok / unavailable (ep0 <=v1.1.0,
  neutral no-alert) / degraded (exec failed, keep last).
- config: Alerting.PBSDRBoxFill{Warn,Crit}Percent (80/90); built with the tenantsync client,
  60s sweep, SetPBSDRBox. Hub deploy INDEPENDENT of the ep0 update (graceful degradation).
- web: /offsite splits into Restic + PBS DR hash tabs (endpoint cards under PBS DR); PBS panel;
  the single dashboard tile becomes two gauges (RESTIC pct.ratio, PBS DR pct / n/a).
- runbook offsite-endpoint.md 10: v1.2.0 update steps (no sudoers/authorized_keys change).

Tests: 10 Go + the harness; 3 red-proofs (usage mutation, escalation-only, unavailable-drives-band)
confirmed red then restored. go build/vet/test + bash -n + hub confirm gate all pass.
2026-07-17 21:13:30 +02:00

237 lines
9.6 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
}
// 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: <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)
}
}
}