Files
felhom.eu/hub/internal/web/r331_backup_card_test.go
T
admin f5c9411e5e R-331 (hub half): the Backup card reads offsite, not the dead backup fields (v0.109.0)
The customer page's Backup card read `Snapshots 0 / Repo Size 0 MB / Integrity
Unknown` for EVERY customer, indefinitely. Measured on demo-hp 2026-08-30 while
that night's controller log said `[offbox] backup OK: 8 app(s) backed up, 67
snapshot(s), 2m14s` and the box held snapshot_count:67, repo_size_bytes:
140829678, stats_known:true.

A card reading "no backups" over a working backup is worse than no card -- the
R-88 direction of failure (degrade to NO BACKUP rather than to UNKNOWN) on the
one screen that answers "is this customer protected?".

The data was never missing. The card rendered the report's `backup` object,
whose snapshot/size/integrity fields have had no producer since slice 8C. The
live numbers are in the `offsite` object, which THIS PACKAGE already reads for
the Offsite page and which monitor.OffsiteChecker already alarms from. Proof the
bytes were arriving: the Offsite page rendered demo-hp's usage as 0.1 GB from
that very object while the Backup card said 0 MB. So this is a render fix over
an existing feed, not a new pipeline.

Not a one-line swap, because snapshot_count:0 means two opposite things --
"holds nothing" and "never measured". R-225 measured that confusion one layer
down. backup_card.go resolves a three-way ruling in Go (a {{if}} chain over
map[string]interface{} float64s cannot keep the absent/zero distinction the card
is entirely about):
  no offsite object   -> "No off-site data reported", and says explicitly that
                         this is NOT the same as "no backups"
  disabled + state    -> names the blocker (needs_credential)
  stats_known:false   -> em-dash + "never been measured". NEVER 0
  stats_known:true    -> the real numbers, INCLUDING a real 0

A pre-v0.225.0 controller sends no stats_known -> false -> "unknown". That
direction is pinned: upgrading the hub ahead of the fleet must not report every
un-upgraded customer as having zero backups.

The Integrity row is DELETED, not re-sourced: nothing produces it, the
controller runs no integrity check, and NotifyIntegrityOK/Failed are called from
nowhere.

RED-PROOF: restore the pre-fix card markup -> all four tests fail, reporting 67
and 134.3 MB absent from the rendered page and the Integrity row present. The
tests drive handleCustomerUnified and grep the HTML on purpose: the defect was
the template's choice of source object, so a test one layer below it would have
been green against the shipped bug.

Green gate clean: 18 packages, rc 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LB8FmJaGd2cyjvy6dbEjpM
2026-08-30 18:39:15 +02:00

163 lines
7.7 KiB
Go

package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// ── R-331 — the Backup card said "Snapshots 0" over a working backup ─────────────────────────────
//
// Measured on `demo-hp` 2026-08-30: the card read `Snapshots 0 · Repo Size 0 MB · Integrity Unknown`
// while the box's own settings held `snapshot_count: 67, repo_size_bytes: 140829678, stats_known:
// true` and that night's log said `[offbox] backup OK: 8 app(s) backed up, 67 snapshot(s), 2m14s`.
// The card rendered the report's `backup` object, whose snapshot/size/integrity fields have had no
// producer since disk-tier restic moved to the host agent; the live numbers were in the `offsite`
// object all along.
//
// These tests use demo-hp's REAL reported values, so a regression fails against the same numbers the
// defect was measured against rather than against invented ones.
// demoHPReportJSON is the shape demo-hp actually sends (controller 0.225.0), trimmed to the objects
// the Backup card reads.
const demoHPReportJSON = `{
"backup": {"enabled": true, "last_db_dump": "2026-08-30T02:15:01Z"},
"offsite": {"enabled": true, "escrow_state": "escrowed", "last_status": "ok",
"last_success": "2026-08-30T02:17:19Z", "snapshot_count": 67,
"repo_size_bytes": 140829678, "quota_gb": 50, "stats_known": true}
}`
func renderWithReport(t *testing.T, reportJSON string) string {
t.Helper()
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "demo-hp", CustomerName: "Demo HP", Domain: "enkisfelhom.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active",
}); err != nil {
t.Fatal(err)
}
if err := st.SaveReport("demo-hp", []byte(reportJSON)); err != nil {
t.Fatal(err)
}
return renderCustomerPage(t, s, "demo-hp")
}
// THE CONSEQUENCE TEST. Not "does reportBackupCard return 67" — does the page an operator actually
// looks at contain 67. The defect lived in the template's choice of source object, so a test one
// layer below it would have been green against the shipped bug.
//
// RED-PROOF (run 2026-08-30, recorded in REPORT.md): point the card back at `.Report.backup` →
// this fails with the rendered page carrying `Snapshots 0` and no `67`.
func TestBackupCard_RendersTheRealSnapshotCount(t *testing.T) {
html := renderWithReport(t, demoHPReportJSON)
if !strings.Contains(html, ">67<") {
t.Error("the rendered Backup card does not contain demo-hp's real snapshot count (67) — " +
"this is the defect: an operator reading this page concludes the customer has no backups")
}
if !strings.Contains(html, "134.3 MB") {
t.Error("the card does not carry the real repository size (134.3 MB from 140829678 bytes)")
}
if strings.Contains(html, "Repo Size 0 MB") || strings.Contains(html, ">0 MB<") {
t.Error("the card still renders the dead `repo_size_mb` field as 0 MB")
}
// `integrity_ok` has no producer anywhere in the controller — NotifyIntegrityOK and
// NotifyIntegrityFailed exist and are called from nowhere. A row that always reads "Unknown" is
// not information, and one that could read "OK" from an unwritten field would be a lie.
if strings.Contains(html, "Integrity") {
t.Error("the card still shows an Integrity row — nothing in the controller produces it")
}
}
// The three-way ruling, which is the reason this card needed Go and not a template {{if}}.
func TestBackupCard_ThreeWayRuling(t *testing.T) {
t.Run("never measured shows a dash, never 0", func(t *testing.T) {
// A box with an off-site tier that has never read its repository. Reporting 0 here would
// claim the repository is EMPTY, which is not what is known — R-225's exact defect, which
// was measured live over a store that really held snapshot f3d9cd67.
html := renderWithReport(t, `{"backup":{"enabled":true},
"offsite":{"enabled":true,"last_status":"ok","snapshot_count":0,"repo_size_bytes":0}}`)
if !strings.Contains(html, "never been") {
t.Error("an unmeasured repository is not called out as unmeasured — the operator reads " +
"the dash as a formatting quirk rather than as missing knowledge")
}
if strings.Contains(html, ">0<") {
t.Error("an unmeasured repository rendered a literal 0 snapshot count — zero is a claim " +
"about the repository's contents and nothing here justifies making it")
}
})
t.Run("measured empty is stated, not hidden", func(t *testing.T) {
// The opposite news, and it must be sayable: the repository was read and really holds
// nothing. If this rendered a dash too, the field would be pointless.
html := renderWithReport(t, `{"backup":{"enabled":true},
"offsite":{"enabled":true,"last_status":"ok","snapshot_count":0,"repo_size_bytes":0,
"stats_known":true}}`)
if strings.Contains(html, "never been") {
t.Error("a MEASURED empty repository was reported as unmeasured — the operator never " +
"learns that a customer genuinely has zero off-site snapshots, which is an alarm")
}
if !strings.Contains(html, ">0<") {
t.Error("a measured-empty repository did not render its 0 — measured zero is knowledge " +
"and must be shown")
}
})
t.Run("no offsite object says so instead of showing numbers", func(t *testing.T) {
html := renderWithReport(t, `{"backup":{"enabled":true}}`)
if !strings.Contains(html, "No off-site data reported") {
t.Error("a report with no `offsite` object did not say so — the same rule offsiteBoxTile " +
"already follows: absent, never a zeroed tile")
}
if strings.Contains(html, "Off-site snapshots") {
t.Error("numbers were rendered for a customer whose controller has never reported any")
}
})
t.Run("a disabled tier with a declared state names the blocker", func(t *testing.T) {
html := renderWithReport(t, `{"backup":{"enabled":true},
"offsite":{"enabled":false,"state":"needs_credential"}}`)
if !strings.Contains(html, "needs_credential") {
t.Error("the declared state was dropped — 'off-site not enabled' and 'off-site blocked " +
"waiting for a credential' are different operator actions and must read differently")
}
})
}
// A pre-v0.225.0 controller sends no `stats_known` key. Absence must degrade to UNKNOWN, never to
// "empty" — otherwise upgrading the hub before the fleet would tell the operator that every
// un-upgraded customer has zero backups.
func TestBackupCard_OldControllerDegradesToUnknownNotEmpty(t *testing.T) {
html := renderWithReport(t, `{"backup":{"enabled":true},
"offsite":{"enabled":true,"last_status":"ok","last_success":"2026-08-30T02:17:19Z",
"snapshot_count":67,"repo_size_bytes":140829678,"quota_gb":50}}`)
if !strings.Contains(html, "never been") {
t.Error("a report without stats_known was treated as authoritative — the hub cannot know " +
"whether an old controller's counts were ever read, and must say so")
}
// The rest of the card must still work: last successful run is independent of stats_known.
if !strings.Contains(html, "Last successful run") {
t.Error("the whole off-site section vanished for an old controller")
}
}
func TestFmtBytesAuto_ScalesInsteadOfCollapsingToZero(t *testing.T) {
// The Offsite page's fmtBytesGB renders demo-hp's real size as "0.1 GB". On a card whose entire
// defect was under-reporting a real backup, that reads as "nearly nothing" — which is why this
// formatter exists rather than reusing that one.
for _, tc := range []struct{ in int64; want string }{
{140829678, "134.3 MB"}, // demo-hp, measured
{0, "0 B"},
{1 << 10, "1.0 KB"},
{1 << 20, "1.0 MB"},
{1 << 30, "1.0 GB"},
{1 << 40, "1.00 TB"},
} {
if got := fmtBytesAuto(tc.in); got != tc.want {
t.Errorf("fmtBytesAuto(%d) = %q, want %q", tc.in, got, tc.want)
}
}
}