Files
felhom-controller/controller/internal/web/backups_split_test.go
T
admin 3a9d744360 v0.154.0 — R-48: one restore entry per app, and the intent is a described choice
The offsite restore list rendered up to five inline forms per app row. Two of them —
„Helyreállítás az élő adatok közé (csak a hiányzó fájlok)" and „Teljes visszaállítás
(fájlok + adatbázis)" — were sibling buttons whose difference is whether the customer's
data comes back at all. That mis-selection CAUSED the round-2 incident: an operator who
had read the source pressed the missing-only button and /backup/offbox/reconstitute was
never hit (DIAG-immich-restore-round2-2026-07-19, finding 1).

The rule this establishes: two adjacent controls whose difference is "your data comes
back" vs "your data cannot come back" must never be distinguishable only by layout.

Each row now carries ONE „Visszaállítás…" entry linking to a per-app wizard at
GET /backups/restore/app?name=<app>: three intent CARDS with consequence sentences, a
visible phase strip, danger styling plus the R-43 double-confirm carried over verbatim on
the destructive one, and the pair-honesty panel bound to real OffsiteScratchPair facts.

deriveWizardStep is pure over (op running, size-gate flash, scratch ready); the step is
never accepted from the request, and a running op outranks a stale ?full_prep= so no
commit button survives into a restore. While ANY op runs every mutation form is suppressed
server-side instead of being offered and refused.

No new mutation endpoint: every card posts to the pre-existing /backup/offbox/* handler
with the same field names and gates, and the page works with JavaScript disabled.
internal/{backup,appbackup,selfupdate} untouched. R-45's job registry stays its own item.

Fixes a latent bug found on the way: offboxRedirectTo hardcoded "?" when appending its
flash, which against the wizard's ?name=<app> target would have buried the flash inside
the name value.

No new agent coupling — MinAgent stays 0.90.0.
2026-07-21 08:30:42 +02:00

174 lines
7.0 KiB
Go

package web
import (
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// v0.124.0 IA split (§10 nav/state): each backups sub-page renders ONLY its mapped sections —
// a marker string per section, asserted present on its page and absent on every other page.
func splitTestData() 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": []AppBackupRow{
{StackName: "calibre-web", DisplayName: "Calibre-Web", OffboxEnabled: true, Tier3State: "active"},
},
"DBSectionState": "dumps",
"Offbox": &settings.OffboxTarget{
Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "escrowed",
},
"OffboxConfigured": true,
"OffboxApps": []OffboxAppRow{{Name: "calibre-web", DisplayName: "Calibre-Web", Enabled: true}},
"OffboxToggledCount": 1,
"OffboxQuotaPct": 0,
"GuestBackup": map[string]interface{}{"Available": false, "Note": "n/a"},
}
}
func TestBackupsSplit_SectionsOnExactlyOnePage(t *testing.T) {
pages := map[string]string{
"backups": renderBackupPage(t, "backups", splitTestData()),
"backups_remote": renderBackupPage(t, "backups_remote", splitTestData()),
"backups_apps": renderBackupPage(t, "backups_apps", splitTestData()),
"backups_restore": renderBackupPage(t, "backups_restore", splitTestData()),
}
// marker → the ONE page it belongs to
markers := map[string]string{
"Tárhely áttekintés": "backups", // Section 0
"Rendszermentés (teljes mentés)": "backups", // whole-guest
">Adatmentés<": "backups", // stat cards (neutral branch)
`id="offbox-section"`: "backups_remote", // the offbox anchor target
"Távoli mentési cél beállítása": "backups_remote", // manual-target form
"Mely alkalmazások mentődnek": "backups_remote", // toggle list
"<h3>Ütemezés</h3>": "backups_apps", // schedule
"<h3>Adatbázisok</h3>": "backups_apps", // databases
"Alkalmazások mentési állapota": "backups_apps", // per-app rows
`id="restore-app"`: "backups_restore", // restore panel
"Visszaállítás a távoli tárolóból": "backups_restore", // the offsite restore list
`href="/backups/restore/app?name=`: "backups_restore", // R-48: the ONE entry per app
}
for marker, home := range markers {
for page, html := range pages {
has := strings.Contains(html, marker)
if page == home && !has {
t.Errorf("%s: missing its own section marker %q", page, marker)
}
if page != home && has {
t.Errorf("%s: renders %q which belongs to %s only", page, marker, home)
}
}
}
}
// The tier-3 row actions must deep-link cross-page to the offbox section (the pre-split
// #offbox-section anchors would be orphaned on the apps page).
func TestBackupsSplit_CrossPageAnchors(t *testing.T) {
data := splitTestData()
data["AppBackupRows"] = []AppBackupRow{
{StackName: "radarr", DisplayName: "Radarr", Tier3State: "off"},
}
html := renderBackupPage(t, "backups_apps", data)
if !strings.Contains(html, `href="/backups/remote#offbox-section"`) {
t.Error("tier-3 'Bekapcsolás' must link cross-page to /backups/remote#offbox-section")
}
if strings.Contains(html, `href="#offbox-section"`) {
t.Error("orphaned same-page #offbox-section anchor survives on the apps page")
}
}
// Felhom-offsite status card (v0.124.0, decision 2): three honest states, display-only —
// the card itself must never carry a form or button (no state change from the card, ever).
func TestBackupsRemote_OffsiteStatusCardStates(t *testing.T) {
cardOf := func(html string) string {
i := strings.Index(html, `id="felhom-offsite-card"`)
if i < 0 {
return ""
}
j := strings.Index(html[i:], "</div>")
return html[i : i+j]
}
// State 1: no target configured → the opt-in pitch (and no "Aktív" headline).
data := splitTestData()
data["OffboxConfigured"] = false
data["Offbox"] = nil
data["OffboxToggledCount"] = 0
html := renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, "Felhom offsite tárhely — igényelhető szolgáltatás. Titkosított, házon kívüli másolat a mentéseidről. Érdeklődj az üzemeltetőnél.") {
t.Error("state 1: the opt-in pitch is missing")
}
if strings.Contains(html, "Aktív — nincs kijelölt alkalmazás") {
t.Error("state 1 must not render the state-2 headline")
}
if card := cardOf(html); strings.Contains(card, "<form") || strings.Contains(card, "<button") {
t.Error("the status card must be display-only (no form/button)")
}
// State 2: applied, zero apps toggled → the honest active-but-empty headline.
data = splitTestData()
data["OffboxToggledCount"] = 0
data["OffboxApps"] = []OffboxAppRow{{Name: "calibre-web", DisplayName: "Calibre-Web", Enabled: false}}
html = renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, "Aktív — nincs kijelölt alkalmazás") {
t.Error("state 2: 'Aktív — nincs kijelölt alkalmazás' missing")
}
if strings.Contains(html, "igényelhető szolgáltatás") {
t.Error("state 2 must not render the state-1 pitch")
}
if card := cardOf(html); strings.Contains(card, "<form") || strings.Contains(card, "<button") {
t.Error("the status card must be display-only (no form/button)")
}
// State 3: applied + toggled → NO card; the regular status block carries the state.
html = renderBackupPage(t, "backups_remote", splitTestData())
if strings.Contains(html, `id="felhom-offsite-card"`) {
t.Error("state 3 must not render the status card (the status block is the state)")
}
if !strings.Contains(html, "Utolsó távoli mentés") {
t.Error("state 3: the regular status block is missing")
}
}
// Route → handler wiring: each sub-route sets its own .Page id, so the sidebar child renders
// active on the right page (backupMgr nil → the empty state renders, the nav still does).
func TestBackupsSplit_RoutesSetPageIDs(t *testing.T) {
s := testServer(t)
s.loadTemplates()
cases := []struct {
path string
active string
}{
{"/backups/remote", `href="/backups/remote" class="active"`},
{"/backups/apps", `href="/backups/apps" class="active"`},
{"/backups/restore", `href="/backups/restore" class="active"`},
}
for _, c := range cases {
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", c.path, nil)
switch c.path {
case "/backups/remote":
s.backupsRemoteHandler(rr, req)
case "/backups/apps":
s.backupsAppsHandler(rr, req)
case "/backups/restore":
s.backupsRestoreHandler(rr, req)
}
if rr.Code != 200 {
t.Fatalf("%s: status %d", c.path, rr.Code)
}
if !strings.Contains(rr.Body.String(), c.active) {
t.Errorf("%s: sidebar child not active (want %q)", c.path, c.active)
}
}
}