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, "\n"); 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, "/dashboard") // v0.170.0: the Vezérlőpult moved from "/" to "/dashboard" if rec.Code != 200 { t.Fatalf("GET /dashboard = %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, "/dashboard") // v0.170.0: the Vezérlőpult moved from "/" to "/dashboard" if rec.Code != 200 { t.Fatalf("GET /dashboard = %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) } }