53e9bf0224
gates / gates (push) Successful in 26s
R-237: /backups/restore listed apps that are CURRENTLY DEPLOYED and CURRENTLY TOGGLED ON for future off-site backups. A rebuilt box has neither, so a household that had just lost everything was shown nothing to restore while the repository held their snapshots — measured live on the R-201 re-walk. To restore an app you had to select it, to select it you had to have installed it, and to know what to install you had to see the backup you could not see. The store is now the source of the list (offsite_restore_list.go), built on the existing R-193 OffsiteInventoryList. Installed-ness became a property OF a row, never a filter on it. Every case is answered rather than hidden: a snapshot for an app that is not installed is offered and says it will reinstall first; an installed app with no snapshot is shown as having nothing; an unreadable store renders as UNKNOWN (R-225's rule, one screen over) AND keeps the action, because "we could not look" is not "there is nothing"; no-target is its own state. The felhom-offbox and _shares marker tags are excluded from the app list. R-238 classified as a HARNESS ARTIFACT: mode=full without confirm=1 is step 1 of a deliberate two-step — it starts no job by design and redirects carrying &full_prep=<app>, which deriveWizardStep requires to reveal the commit. A driver that did not carry it forward landed back on the intent step. The operator's browser run completed the same restore. The wizard's precedence rules were NOT re-keyed: a stale ?full_prep= must never resurrect a commit button mid-restore. The residue WAS real and is fixed: neither branch of that step wrote anything to the log, so a refusal — including by the headroom gate — left no trace on the box. Both branches now log, and so does the concurrent-op refusal. resolveWizardApp is removed: it was dead once the gate moved, and its test pinned the defect's behaviour (an untoggled app refused), which would have read as policy. 28 packages ok, 9/9 gates OK. Three red-proofs, each asserted to have applied.
180 lines
7.3 KiB
Go
180 lines
7.3 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,
|
|
// R-237: the restore list is driven by the STORE. The happy fixture is "the app is in the
|
|
// repository AND installed here" — the pre-rebuild shape.
|
|
"OffsiteRestoreRows": []OffsiteRestoreRow{
|
|
{App: "calibre-web", DisplayName: "Calibre-Web", InStore: true, Installed: true, Enabled: true},
|
|
},
|
|
"OffsiteStoreState": string(offsiteStoreKnown),
|
|
"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)
|
|
}
|
|
}
|
|
}
|