166 lines
7.7 KiB
Go
166 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)
|
|
}
|
|
}
|
|
}
|