Files
felhom-controller/controller/internal/web/backup_page_state_test.go
T
admin 5270bad76e
gates / gates (push) Successful in 23s
v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still
Hungarian literals in Go, so an English household clicked an English button and was
answered in Hungarian. 226 of them move into the bundle here.

A flash was the hard part: it travels inside the redirect URL and is rendered by a
DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by
an older controller carries prose and is shown verbatim — never a raw key, never dropped.

Also converted: page data and view-model text, the internal/api JSON answers, the alert
banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at
display, and the four page titles built around an app name (R-566 closed).

Hungarian is byte-identical, and that is measured rather than read:
scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses
a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict.
Its own first version filtered the capture through an ASCII-Hungarian word list and missed
seven real literals — the R-565 class. The filter is gone.

Nothing on the wire moved, and wire goldens now hold it there: the report's health
warnings and every notify event message stay Hungarian, because the hub MAILS the
controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 10:15:56 +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, "hu")
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"},
}}, "hu")
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")
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")
}
}