Files
felhom-controller/controller/internal/web/backup_page_state_test.go
T
admin c732fe1283
gates / gates (push) Successful in 18s
v0.210.0 — R-259 and R-258: two pictures that were not true
Both are one shape: something the box already knows, drawn as its opposite.

R-259 — A DISK WE FAILED TO READ WAS DRAWN AS A HEALTHY EMPTY DISK. readDiskUsage
(internal/system/info_linux.go) logged a statfs failure at DEBUG and returned, leaving the caller's
TotalGB/UsedGB/AvailGB/Percent at zero — and usageColor(0) is "nominal". The dashboard's
most-looked-at meter therefore rendered "0.0 GB / 0.0 GB (0%)" with a 0%-wide bar in the healthy
colour. "We could not look" and "there is plenty of room" were the same picture.

readDiskUsage now returns whether the measurement succeeded; SystemInfo gains DiskKnown and
HDDKnown (HDDConfigured is not a substitute: it says a path was configured, not that reading it
worked); and the template draws NO figure, NO percentage and NO meter fill when unknown, saying
"A tarhely merete most nem olvashato ki." instead. A healthy box is byte-identical, colour band
included.

This session rules the convention (felhom.eu CONTEXT.md S-39): an explicit `...Known bool` companion
beside the figures, checked in the template — the shape Offbox.StatsKnown already uses, whose own
comment says "a 0%-wide bar over an unread store is a picture of emptiness, and a picture is a
claim". Pointers and separate error fields are both legitimate Go, but a codebase with three
dialects cannot be gated (ROADMAP G-3 was blocked on exactly this). Existing call sites NOT
converted.

R-258 — THE PER-APP BACKUP TICK WAS GREEN ON PRESENCE, AND RED ONLY ON A GLOBAL CONDITION.
buildAppBackupRows set Tier1LastStatus from status.LastDBDump.Success, which is the box's single
most recent dump RUN, whichever app it belonged to. An app whose own dump failed showed a tick as
long as some other app dumped successfully afterwards; an app with no database took the nil branch
and went green on the mere existence of a restore point.

appDumpVerdict now reads THIS app's own entries in DBDumpStatus.Results (matched on
DumpResult.DB.StackName, failure = non-nil Error). Three states: any failing database -> error; all
clean -> ok; no result recorded -> NO verdict and no icon, titled "Errol a mentesrol nincs
eredmenyunk." The recovery unit carries no per-run outcome of its own, so green cannot honestly be
derived from presence. The global tier1DBStatus label is untouched — it is correct as a global.

RECENCY IS DELIBERATELY NOT ADDED. A tick over a three-week-old restore point is a real weakness,
but an age threshold means inventing a number and the time is already printed beside the icon.
Recorded as an observation, not changed.

AN EXISTING TEST WAS ASSERTING THE DEFECT AND WAS CORRECTED, NOT DELETED:
TestBuildAppBackupRows_Tier1FromRestorePoints expected "ok" for a status with no LastDBDump at all —
green from nothing but a file's existence. It now expects no verdict; its real subject, the
Tier1LastRun time, is unchanged.

The dashboard test EXTRACTS the meter block from the shipped template rather than copying it: a
copied block drifts, and a drifted copy passes while the page it claims to cover has changed — the
fixture-is-not-the-wire mistake this project has now hit twice.

Six red-proofs across both parts, each with the mutation asserted applied.

No new tag on any declared wire — report/builder.go maps into its own types and is untouched;
wire_contract_gate.py confirmed green.

go build / go vet / go test ./... green (28 packages), controller_gates --fast all OK, both run
separately from this commit.
2026-08-08 16:29:52 +02:00

354 lines
15 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"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ---------------------------------------------------------------------------
// Pure state-pick helpers (Group C logic + tier3State precedence for Group B)
// ---------------------------------------------------------------------------
// TestDBSectionState — table-driven truth table for the embedded-DB-honest messaging pick.
// COMPANION red-proof: make dbSectionState ignore `discovered` (return "embedded" when dumps==0)
// → the (2,0)="pending" case below FAILS. Run → fail → revert (recorded in REPORT).
func TestDBSectionState(t *testing.T) {
cases := []struct {
discovered, dumps int
want string
}{
{0, 0, "embedded"}, // Scenario C: SQLite-only box — neither dump nor discovery
{2, 0, "pending"}, // discovered but no dump yet → "first run tonight" (NOT embedded)
{2, 3, "dumps"}, // real dumps present → show the table
{0, 3, "dumps"}, // stale dumps from a removed DB app still show (dumps wins)
}
for _, c := range cases {
if got := dbSectionState(c.discovered, c.dumps); got != c.want {
t.Errorf("dbSectionState(%d,%d) = %q, want %q", c.discovered, c.dumps, got, c.want)
}
}
}
// TestTier3State — the per-app off-box row state, with strict configured→toggle→escrow precedence.
func TestTier3State(t *testing.T) {
cases := []struct {
name string
configured bool
toggled bool
escrow string
want string
}{
{"unconfigured wins over stale toggle", false, true, "escrowed", "unconfigured"},
{"configured but app off", true, false, "escrowed", "off"},
{"toggled + escrowed → active", true, true, "escrowed", "active"},
{"toggled + pending escrow → escrow_pending", true, true, "pending", "escrow_pending"},
{"toggled + empty escrow → escrow_pending", true, true, "", "escrow_pending"},
}
for _, c := range cases {
if got := tier3State(c.configured, c.toggled, c.escrow); got != c.want {
t.Errorf("%s: tier3State(%v,%v,%q) = %q, want %q", c.name, c.configured, c.toggled, c.escrow, got, c.want)
}
}
}
// ---------------------------------------------------------------------------
// Handler wiring: buildAppBackupRows maps OffboxEnabled + Tier3State (Scenario A/B)
// ---------------------------------------------------------------------------
// configureOffbox sets up a real, configured off-box target (secrets + settings) so
// OffboxConfigured() reports true, with the given escrow state.
func configureOffbox(t *testing.T, sett *settings.Settings, m *backup.Manager, escrow string) {
t.Helper()
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
t.Fatal(err)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "u629488-sub1.your-storagebox.de", Port: 22, User: "felhom",
RepoPath: "/home/repo", Schedule: "daily", EscrowState: escrow,
LastStatus: "ok", LastRun: "2026-07-11T02:00:00Z",
}); err != nil {
t.Fatal(err)
}
if !m.OffboxConfigured() {
t.Fatal("target should be configured")
}
}
func findRow(rows []AppBackupRow, stack string) *AppBackupRow {
for i := range rows {
if rows[i].StackName == stack {
return &rows[i]
}
}
return nil
}
// TestBuildAppBackupRows_OffboxMapping (Scenario A + B wiring): the per-app Tier-3 state is
// (global configured) × (global escrow) × (per-app toggle). calibre toggled ON → active; radarr
// OFF → off. COMPANION red-proof: hardcode row.OffboxEnabled=false in buildAppBackupRows → the
// calibre "active"/OffboxEnabled assertions FAIL. Run → fail → revert (recorded in REPORT).
func TestBuildAppBackupRows_OffboxMapping(t *testing.T) {
s, sett, m := newOffboxWebServer(t)
configureOffbox(t, sett, m, "escrowed")
if err := sett.SetAppOffbox("calibre-web", true); err != nil {
t.Fatal(err)
}
status := &backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
{StackName: "calibre-web", DisplayName: "Calibre-Web"},
{StackName: "radarr", DisplayName: "Radarr"},
}}
rows := s.buildAppBackupRows(status)
cal := findRow(rows, "calibre-web")
if cal == nil || !cal.OffboxEnabled || cal.Tier3State != "active" {
t.Fatalf("calibre-web: OffboxEnabled/Tier3State wrong: %+v", cal)
}
rad := findRow(rows, "radarr")
if rad == nil || rad.OffboxEnabled || rad.Tier3State != "off" {
t.Fatalf("radarr: OffboxEnabled/Tier3State wrong: %+v", rad)
}
}
// TestBuildAppBackupRows_EscrowPendingPrecedence (Scenario B): a toggled app while escrow is
// pending is escrow_pending, never active — even though LastStatus is "ok" from a prior run.
func TestBuildAppBackupRows_EscrowPendingPrecedence(t *testing.T) {
s, sett, m := newOffboxWebServer(t)
configureOffbox(t, sett, m, "pending")
if err := sett.SetAppOffbox("calibre-web", true); err != nil {
t.Fatal(err)
}
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
{StackName: "calibre-web", DisplayName: "Calibre-Web"},
}})
cal := findRow(rows, "calibre-web")
if cal == nil || cal.Tier3State != "escrow_pending" {
t.Fatalf("escrow-pending must win over a prior ok run: %+v", cal)
}
}
// TestBuildAppBackupRows_Tier1FromRestorePoints (Scenario D): a recovery unit on disk → Tier1LastRun
// is its newest-artifact RFC3339 time; an app with no unit → Tier1LastRun stays "" (no fabrication).
// COMPANION red-proof: drop the ListRestorePoints assignment (leave Tier1LastRun unset) → the
// "with-unit" assertion FAILS. Run → fail → revert (recorded in REPORT).
func TestBuildAppBackupRows_Tier1FromRestorePoints(t *testing.T) {
s, _, m := newOffboxWebServer(t)
drive := filepath.Join(t.TempDir(), "drive")
m.SetStackProvider(&unitProvider{blockProvider{hdd: drive}})
// Write a recovery-unit manifest for "hasunit" with a known, decisively-past mtime.
manifest := backup.RecoveryUnitManifestPath(drive, "hasunit")
if err := os.MkdirAll(filepath.Dir(manifest), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(manifest, []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
mtime := time.Date(2026, 7, 10, 3, 30, 0, 0, time.UTC)
if err := os.Chtimes(manifest, mtime, mtime); err != nil {
t.Fatal(err)
}
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
{StackName: "hasunit", DisplayName: "Has Unit"},
{StackName: "nounit", DisplayName: "No Unit"},
}})
hu := findRow(rows, "hasunit")
if hu == nil || hu.Tier1LastRun != mtime.Format(time.RFC3339) {
t.Fatalf("hasunit Tier1LastRun = %q, want %q", huTier1(hu), mtime.Format(time.RFC3339))
}
// ⚠ CHANGED 2026-08-08 (R-258), and the change is the finding. This asserted `== "ok"` for a
// FullBackupStatus with NO LastDBDump at all — i.e. it pinned the defect: a green tick derived
// from nothing but the presence of a recovery-unit file. The old code took the `nil` branch and
// returned "ok"; the verdict now comes from THIS app's own dump result, and there is none here,
// so the honest answer is no verdict and the template renders no icon.
//
// The row's real subject — Tier1LastRun, the time — is unchanged and still asserted above.
if hu.Tier1LastStatus != "" {
t.Errorf("hasunit Tier1LastStatus = %q, want \"\" (no dump result for this app ⇒ no verdict; "+
"a tick standing for \"a file exists\" is R-258)", hu.Tier1LastStatus)
}
nu := findRow(rows, "nounit")
if nu == nil || nu.Tier1LastRun != "" {
t.Fatalf("nounit must have NO fabricated Tier1LastRun, got %q", huTier1(nu))
}
}
func huTier1(r *AppBackupRow) string {
if r == nil {
return "<nil row>"
}
return r.Tier1LastRun
}
// unitProvider makes every stack resolvable (GetStackComposePath ok) with the embedded hdd drive,
// so ListRestorePoints resolves the namespace to that drive.
type unitProvider struct{ blockProvider }
func (u *unitProvider) GetStackComposePath(string) (string, bool) { return "compose", true }
// ---------------------------------------------------------------------------
// Template rendering (Group A/B/C/D): the real "backups" template tree
// ---------------------------------------------------------------------------
func renderBackups(t *testing.T, data map[string]interface{}) string {
return renderBackupPage(t, "backups", data)
}
// renderBackupPage renders one of the four backups sub-page templates (v0.124.0 IA split)
// through the PRODUCTION template tree.
func renderBackupPage(t *testing.T, page string, data map[string]interface{}) string {
t.Helper()
s := testServer(t)
s.loadTemplates()
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, page, data); err != nil {
t.Fatalf("render %s: %v", page, err)
}
return buf.String()
}
// baseBackupData is the minimal data map that makes the "backups" body render (Backup truthy +
// one AppDataInfo so Section 4/7 appear). Callers overlay AppBackupRows / Offbox / DBSectionState.
func baseBackupData(rows []AppBackupRow) map[string]interface{} {
return map[string]interface{}{
"Page": "backups", "Title": "Biztonsági mentés",
"Backup": &backup.FullBackupStatus{
AppDataInfo: []backup.AppBackupInfo{{StackName: "calibre-web", DisplayName: "Calibre-Web"}},
},
"AppBackupRows": rows,
"DBSectionState": "dumps",
}
}
// TestBackupsTemplate_Tier3Live (Scenario A) — active + off rows render truthfully, and every
// removed marker is gone. COMPANION red-proof: re-insert "hamarosan elérhető" into the tier-3 block
// → the no-"hamarosan" assertion FAILS. Run → fail → revert (recorded in REPORT).
func TestBackupsTemplate_Tier3Live(t *testing.T) {
data := baseBackupData([]AppBackupRow{
{StackName: "calibre-web", DisplayName: "Calibre-Web", OffboxEnabled: true, Tier3State: "active"},
{StackName: "radarr", DisplayName: "Radarr", OffboxEnabled: false, Tier3State: "off"},
})
data["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "u629488-sub1.your-storagebox.de", LastStatus: "ok",
LastRun: "2026-07-11T02:00:00Z", EscrowState: "escrowed",
}
data["OffboxConfigured"] = true
html := renderBackupPage(t, "backups_apps", data)
for _, want := range []string{
"Sikeres", // active badge (LastStatus ok)
"restic → u629488-sub1.your-storagebox.de", // the real off-box host
"Utolsó:", // a relative last-run time is shown
"Kikapcsolva", // radarr off row
"Bekapcsolás", // radarr enable link
`href="/backups/remote#offbox-section"`, // the cross-page anchor jump target (IA split)
} {
if !strings.Contains(html, want) {
t.Errorf("rendered apps page missing %q", want)
}
}
remoteHTML := renderBackupPage(t, "backups_remote", data)
for _, want := range []string{
"Távoli mentés (3. mentés)", // the renamed section header
`id="offbox-section"`, // the anchor target the apps-page links jump to
} {
if !strings.Contains(remoteHTML, want) {
t.Errorf("rendered remote page missing %q", want)
}
}
for _, banned := range []string{
"hamarosan", // the dead placeholder copy
"Tier2DriveGroups", // dead template field
"ResticPassword", // dead template field
"restic-pw", // dead element id
"toggleTier", // dead JS fn
"details-tier", // dead Részletek markup
"Részletek", // the removed card title
"2026-07-11T02:00:00Z", // raw RFC3339 must never leak (timeAgoStr wraps it)
} {
if strings.Contains(html, banned) {
t.Errorf("rendered page still contains removed/raw marker %q", banned)
}
}
}
// TestBackupsTemplate_Tier3EscrowPending (Scenario B) — the escrow-pending row shows the wait
// state and NOT a false success. The single row carries no Tier-2, so "Sikeres" would only come
// from a (wrong) tier-3 active badge.
func TestBackupsTemplate_Tier3EscrowPending(t *testing.T) {
data := baseBackupData([]AppBackupRow{
{StackName: "calibre-web", DisplayName: "Calibre-Web", OffboxEnabled: true, Tier3State: "escrow_pending"},
})
data["Offbox"] = &settings.OffboxTarget{Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "pending"}
data["OffboxConfigured"] = true
html := renderBackupPage(t, "backups_apps", data)
if !strings.Contains(html, "Kulcsletétre vár") {
t.Error("escrow-pending row must show 'Kulcsletétre vár'")
}
// The active-state marker must be absent — no false success. (Bare "Sikeres" would
// false-match the "Sikeresen törölve" JS toast, so assert the active contents string.)
if strings.Contains(html, "Helyreállítási egység, titkosítva") {
t.Error("escrow-pending must NOT render the active tier-3 row (false success)")
}
}
// TestBackupsTemplate_DBMessaging (Scenario C) — embedded vs pending messaging in the stat card
// and the Adatbázisok empty state.
func TestBackupsTemplate_DBMessaging(t *testing.T) {
t.Run("embedded", func(t *testing.T) {
data := baseBackupData(nil)
data["DBSectionState"] = "embedded"
overviewHTML := renderBackups(t, data)
if !strings.Contains(overviewHTML, "beágyazott DB-k a kötetmentésben") { // stat-card sublabel
t.Error("embedded overview page missing the stat-card sublabel")
}
appsHTML := renderBackupPage(t, "backups_apps", data)
if !strings.Contains(appsHTML, "beágyazott adatbázist használnak") { // Adatbázisok empty state
t.Error("embedded apps page missing the Adatbázisok explanation")
}
if strings.Contains(appsHTML, "Nem található adatbázis mentés.") {
t.Error("embedded box must NOT show the old bare 'not found' message")
}
})
t.Run("pending", func(t *testing.T) {
data := baseBackupData(nil)
data["DBSectionState"] = "pending"
html := renderBackupPage(t, "backups_apps", data)
if !strings.Contains(html, "az első ütemezett mentés éjjel fut le") {
t.Error("pending page missing the 'first run tonight' message")
}
if strings.Contains(html, "beágyazott adatbázist használnak") {
t.Error("pending box must NOT show the embedded-DB message")
}
})
}
// TestBackupsTemplate_Tier2RelativeTime (Scenario D) — a Tier-2 last-run renders as relative time,
// never the raw RFC3339 literal.
func TestBackupsTemplate_Tier2RelativeTime(t *testing.T) {
data := baseBackupData([]AppBackupRow{{
StackName: "calibre-web", DisplayName: "Calibre-Web",
Tier2Configured: true, Tier2Dest: "USB", Tier2Schedule: "Naponta",
Tier2LastRun: "2026-07-10T03:30:00Z", Tier2LastStatus: "ok", Tier2StatusBadge: "Sikeres",
Tier3State: "unconfigured",
}})
html := renderBackupPage(t, "backups_apps", data)
// The visible "Utolsó:" label must be a relative time, never the raw RFC3339. (The raw
// timestamp legitimately survives in the restore confirm() dialog — precise there by design.)
if strings.Contains(html, "Utolsó: 2026-07-10T03:30:00Z") {
t.Error("Tier-2 last-run label must render via timeAgoStr, not the raw RFC3339 literal")
}
if !strings.Contains(html, "Utolsó:") {
t.Error("Tier-2 row should still show an 'Utolsó:' label")
}
}