Files
felhom-controller/controller/internal/web/backup_page_state_test.go
T

346 lines
14 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))
}
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 "<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")
}
}