v0.149.0 — F3: the dashboard backup card tells the truth about the last run

The card claimed "Utolsó mentés: Még nem futott" on every box, forever, even
with dumps on disk and db_dump_completed events in the hub. dashboard.html
branches on {{if .BackupStatus}} and reads .Success/.LastRun, but
dashboardHandler never set that key — so the {{if}} arm was unreachable and
the "never ran" else-branch rendered unconditionally. The neighbouring
"Adatbázisok: N mentve" row kept working because it reads DBDumpStatus, which
was passed; that is the contradiction the audit caught on the live box.

Fix is the one-line pass-through the template always expected:
data["BackupStatus"] = fullStatus.LastDBDump. *DBDumpStatus nil/non-nil maps
onto the template's branch, so a fresh box still reads "Még nem futott" and no
zero-value timestamp is fabricated. No template change, no new view-model.

Tests drive the real handler through ServeHTTP so they bite on the handler
wiring, not the template alone. Red-proofed: deleting the assignment fails
TestDashboardBackupCard_ShowsLastRun.

Origin: felhom.eu/documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md (F3)
This commit is contained in:
2026-07-20 09:04:11 +02:00
parent 9d001771ea
commit c059fe4c28
4 changed files with 220 additions and 1 deletions
@@ -0,0 +1,174 @@
package web
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// F3 (AUDIT-vacation-remote-ops-2026-07-20): the dashboard's "Utolsó mentés" row branches on
// .BackupStatus, a key dashboardHandler never set — so the {{if}} arm was unreachable and every box
// reported "Még nem futott" forever, even with dumps on disk and crossdrive_completed events in the
// hub. These tests drive the REAL handler through ServeHTTP so they bite on the handler wiring, not
// just on the template.
// dashDumpProvider lists one deployed stack on a known drive, so the backup manager's per-drive dump
// scan has somewhere to look. Everything else comes from blockProvider (async_restore_test.go).
type dashDumpProvider struct {
blockProvider
stack string
}
func (p *dashDumpProvider) ListDeployedStacks() []backup.StackSummary {
return []backup.StackSummary{{Name: p.stack}}
}
// newDashboardServer builds a Server that renders the real dashboard through ServeHTTP, backed by a
// real backup.Manager. When dumpAt is non-zero a DB dump file is planted on the app's drive with
// that modtime — GetFullStatus then synthesizes LastDBDump from it, which is what the card must show.
func newDashboardServer(t *testing.T, dumpAt time.Time) *Server {
t.Helper()
lg := log.New(io.Discard, "", 0)
dir := t.TempDir()
cfg := &config.Config{}
cfg.Customer.ID = "test-customer"
cfg.Customer.Name = "Teszt Ügyfél"
cfg.Customer.Domain = "example.hu"
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Paths.SystemDataPath = filepath.Join(dir, "system")
cfg.Stacks.ComposeCommand = "docker compose" // skip detection (not needed for page renders)
cfg.Backup.Enabled = true // the card only renders when backup is enabled
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatalf("settings: %v", err)
}
stackMgr, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatalf("stacks manager: %v", err)
}
drive := filepath.Join(dir, "drive")
bm := backup.NewManager(cfg, sett, lg)
bm.SetStackProvider(&dashDumpProvider{blockProvider{hdd: drive}, "bookstack"})
if !dumpAt.IsZero() {
dumpDir := backup.AppDBDumpPath(backup.NamespaceRoot(drive, true), "bookstack")
if err := os.MkdirAll(dumpDir, 0o755); err != nil {
t.Fatal(err)
}
f := filepath.Join(dumpDir, "bookstack-mariadb.sql")
body := "-- MySQL dump\n-- Server version 12.3\n" + strings.Repeat("-- padding to clear the min-size validator\n", 4)
if err := os.WriteFile(f, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(f, dumpAt, dumpAt); err != nil {
t.Fatal(err)
}
}
bm.RefreshCache(time.Now()) // populates the cache the card reads (and the dump-file scan)
s := &Server{cfg: cfg, settings: sett, stackMgr: stackMgr, backupMgr: bm, logger: lg, version: "test"}
s.loadTemplates()
return s
}
// backupCard returns just the dashboard's backup card, so an assertion cannot accidentally match
// the same words elsewhere on the page.
func backupCard(t *testing.T, body string) string {
t.Helper()
i := strings.Index(body, `class="backup-status-card"`)
if i < 0 {
t.Fatalf("no backup card on the dashboard (BackupEnabled false?)")
}
rest := body[i:]
if j := strings.Index(rest, "</div>\n</div>"); j > 0 {
return rest[:j]
}
return rest
}
// Scenario B — a real dump on disk ⇒ the card shows ITS timestamp, never "Még nem futott".
// COMPANION red-proof: delete `data["BackupStatus"] = fullStatus.LastDBDump` from dashboardHandler
// → the card falls back to "Még nem futott" and this test FAILS. Run → fail → restore (in REPORT).
func TestDashboardBackupCard_ShowsLastRun(t *testing.T) {
dumpAt := time.Date(2026, 7, 19, 3, 30, 0, 0, time.Local)
s := newDashboardServer(t, dumpAt)
rec := getPage(t, s, "/")
if rec.Code != 200 {
t.Fatalf("GET / = %d: %s", rec.Code, rec.Body.String())
}
card := backupCard(t, rec.Body.String())
want := dumpAt.Format("2006-01-02 15:04")
if !strings.Contains(card, want) {
t.Errorf("card must show the real last run %q, got:\n%s", want, card)
}
if strings.Contains(card, "Még nem futott") {
t.Errorf("a box WITH a dump must not claim it never ran, got:\n%s", card)
}
}
// Scenario C — no dump anywhere ⇒ the fresh-box branch still renders honestly (nil passes through;
// the fix must not fabricate a zero timestamp like "0001-01-01 00:00").
func TestDashboardBackupCard_FreshBoxStaysHonest(t *testing.T) {
s := newDashboardServer(t, time.Time{})
rec := getPage(t, s, "/")
if rec.Code != 200 {
t.Fatalf("GET / = %d: %s", rec.Code, rec.Body.String())
}
card := backupCard(t, rec.Body.String())
if !strings.Contains(card, "Még nem futott") {
t.Errorf("a box with NO dump must say so, got:\n%s", card)
}
if strings.Contains(card, "0001-01-01") {
t.Errorf("nil dump must not render a zero-value timestamp, got:\n%s", card)
}
}
// Scenario D — a FAILED dump renders "Sikertelen", not a timestamp and not "Még nem futott".
// The failure flag only exists in the manager's in-memory run state (unreachable from this package
// without shelling out to docker), so this one asserts the template branch directly.
func TestDashboardBackupCard_FailedRunShowsSikertelen(t *testing.T) {
data := map[string]interface{}{
"Page": "dashboard", "Title": "Vezérlőpult",
"MissingStorage": map[string]string{},
"NetworkWarnings": map[string]string{},
"NetworkStubs": map[string]string{},
"Subdomains": map[string]string{},
"RunningCount": 0, "StoppedCount": 0, "TotalCount": 0,
"SystemInfo": system.SystemInfo{},
"Domain": "demo-felhom.eu",
"BackupEnabled": true,
"BackupStatus": &backup.DBDumpStatus{
LastRun: time.Date(2026, 7, 19, 3, 30, 0, 0, time.Local),
Success: false,
},
}
card := backupCard(t, renderBackupPage(t, "dashboard", data))
if !strings.Contains(card, "Sikertelen") {
t.Errorf("a failed dump must render Sikertelen, got:\n%s", card)
}
if strings.Contains(card, "2026-07-19 03:30") {
t.Errorf("a failed dump must NOT render as a successful timestamp, got:\n%s", card)
}
if strings.Contains(card, "Még nem futott") {
t.Errorf("a failed dump is not 'never ran', got:\n%s", card)
}
}
+5
View File
@@ -174,6 +174,11 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
data["DBDumpStatus"] = fullStatus.LastDBDump
// F3 (AUDIT-vacation-remote-ops-2026-07-20): the card's "Utolsó mentés" row branches on
// .BackupStatus, which was never passed — so the {{if}} arm was unreachable and EVERY box
// rendered "Még nem futott" regardless of history. *DBDumpStatus nil/non-nil maps exactly
// onto the template's branch, so a fresh box still reads "Még nem futott" honestly.
data["BackupStatus"] = fullStatus.LastDBDump
data["BackupRunning"] = fullStatus.Running
data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours
}