7f11cfb36c
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.
237 lines
9.6 KiB
Go
237 lines
9.6 KiB
Go
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, "•••") {
|
||
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)
|
||
}
|
||
}
|
||
}
|