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)) } if hu.Tier1LastStatus != "ok" { t.Errorf("hasunit Tier1LastStatus = %q, want ok", 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 "" } 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") } }