c732fe1283
gates / gates (push) Successful in 18s
Both are one shape: something the box already knows, drawn as its opposite. R-259 — A DISK WE FAILED TO READ WAS DRAWN AS A HEALTHY EMPTY DISK. readDiskUsage (internal/system/info_linux.go) logged a statfs failure at DEBUG and returned, leaving the caller's TotalGB/UsedGB/AvailGB/Percent at zero — and usageColor(0) is "nominal". The dashboard's most-looked-at meter therefore rendered "0.0 GB / 0.0 GB (0%)" with a 0%-wide bar in the healthy colour. "We could not look" and "there is plenty of room" were the same picture. readDiskUsage now returns whether the measurement succeeded; SystemInfo gains DiskKnown and HDDKnown (HDDConfigured is not a substitute: it says a path was configured, not that reading it worked); and the template draws NO figure, NO percentage and NO meter fill when unknown, saying "A tarhely merete most nem olvashato ki." instead. A healthy box is byte-identical, colour band included. This session rules the convention (felhom.eu CONTEXT.md S-39): an explicit `...Known bool` companion beside the figures, checked in the template — the shape Offbox.StatsKnown already uses, whose own comment says "a 0%-wide bar over an unread store is a picture of emptiness, and a picture is a claim". Pointers and separate error fields are both legitimate Go, but a codebase with three dialects cannot be gated (ROADMAP G-3 was blocked on exactly this). Existing call sites NOT converted. R-258 — THE PER-APP BACKUP TICK WAS GREEN ON PRESENCE, AND RED ONLY ON A GLOBAL CONDITION. buildAppBackupRows set Tier1LastStatus from status.LastDBDump.Success, which is the box's single most recent dump RUN, whichever app it belonged to. An app whose own dump failed showed a tick as long as some other app dumped successfully afterwards; an app with no database took the nil branch and went green on the mere existence of a restore point. appDumpVerdict now reads THIS app's own entries in DBDumpStatus.Results (matched on DumpResult.DB.StackName, failure = non-nil Error). Three states: any failing database -> error; all clean -> ok; no result recorded -> NO verdict and no icon, titled "Errol a mentesrol nincs eredmenyunk." The recovery unit carries no per-run outcome of its own, so green cannot honestly be derived from presence. The global tier1DBStatus label is untouched — it is correct as a global. RECENCY IS DELIBERATELY NOT ADDED. A tick over a three-week-old restore point is a real weakness, but an age threshold means inventing a number and the time is already printed beside the icon. Recorded as an observation, not changed. AN EXISTING TEST WAS ASSERTING THE DEFECT AND WAS CORRECTED, NOT DELETED: TestBuildAppBackupRows_Tier1FromRestorePoints expected "ok" for a status with no LastDBDump at all — green from nothing but a file's existence. It now expects no verdict; its real subject, the Tier1LastRun time, is unchanged. The dashboard test EXTRACTS the meter block from the shipped template rather than copying it: a copied block drifts, and a drifted copy passes while the page it claims to cover has changed — the fixture-is-not-the-wire mistake this project has now hit twice. Six red-proofs across both parts, each with the mutation asserted applied. No new tag on any declared wire — report/builder.go maps into its own types and is untouched; wire_contract_gate.py confirmed green. go build / go vet / go test ./... green (28 packages), controller_gates --fast all OK, both run separately from this commit.
354 lines
15 KiB
Go
354 lines
15 KiB
Go
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)
|
||
|
||
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"},
|
||
}})
|
||
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 := 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")
|
||
}
|
||
}
|