diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 2e5375d..7b09b0e 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -496,6 +496,7 @@ func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTa // "never succeeded" every time the hub re-pushes the descriptor. tgt.LastSuccess = cur.LastSuccess tgt.RepoSizeHuman, tgt.RepoSizeBytes, tgt.SnapshotCount = cur.RepoSizeHuman, cur.RepoSizeBytes, cur.SnapshotCount + tgt.StatsKnown = cur.StatsKnown // R-225: carry the KNOWN-ness with the numbers } if tgt.EscrowState != "escrowed" { tgt.EscrowState = "pending" @@ -883,6 +884,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error } o.LastError = "" o.SnapshotCount = snapshots + o.StatsKnown = true // R-225: measured, even if the answer is zero o.EnlargedBlocked = blockedNames // replace each run (sorted); empty slice clears it var warns []string // Zero-toggle honesty (take-two obs.): a configured target with NOTHING selected reports @@ -1419,6 +1421,7 @@ func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) int _ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.RepoSizeHuman = human o.RepoSizeBytes = st.TotalSize + o.StatsKnown = true // R-225 }) } } diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index e697278..846a5fe 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -225,6 +225,22 @@ type OffboxTarget struct { // gate's input (last-known value; a failed stats call keeps the previous one — stale-but-safe). RepoSizeBytes int64 `json:"repo_size_bytes,omitempty"` SnapshotCount int `json:"snapshot_count,omitempty"` + // StatsKnown — R-225. Whether SnapshotCount / RepoSizeBytes above were ever actually READ from the + // repository, as opposed to never having been measured. + // + // ⚠ IT IS NAMED RATHER THAN INFERRED FROM THE COUNTS, for the same reason `OffsiteInventory.Empty` + // is: **zero is what an unread store and a genuinely empty one both look like**, and the two are + // opposite news. Both fields are `omitempty` ints, so on the wire and on disk "absent" and "0" are + // the same bytes — the distinction cannot be recovered downstream and has to be carried. + // + // Measured live on 2026-08-05 (CAMPAIGN-11, R-225): after a rebuild the box rendered + // „Tároló méret · 0 pillanatkép" and „Tárhelykeret: 0 / 50 GB (0%)" — directly above a card saying + // the store held backups made under another key. An SFTP listing found snapshot `f3d9cd67` and + // 12 535 KB really there. The keys were simply ABSENT from settings.json and the zero value spoke + // for them. + // + // Set true by the run that reads the stats, whatever it reads — a true zero is knowledge too. + StatsKnown bool `json:"stats_known,omitempty"` // LastWarning is a customer-visible notice set on an otherwise-OK run when SOME toggled apps had // no discoverable recovery unit (partial run). Empty on a fully-successful or failed run. LastWarning string `json:"last_warning,omitempty"` @@ -262,7 +278,19 @@ type OffboxTarget struct { // OrphanedAt is the RFC3339 stamp of the orphan detection (drives the card copy). OrphanedAt string `json:"orphaned_at,omitempty"` // OrphanedRenamedTo records the move-aside path of the last reset (e.g. .orphaned-20260717), - // so the card/log can name where the old (recovery-code-recoverable) history was set aside. + // so the card/log can name where the old history was set aside. + // + // ⚠ R-228 (2026-08-06) — THIS WAS WRITTEN AND READ BY NOBODY, and this comment used to call the + // set-aside history "recovery-code-recoverable", which is **not true today**: serving a superseded + // blob is an unbuilt link (R-199's inventory), so the old store cannot be opened by the customer, + // the operator, or anyone else. Measured on 2026-08-05 (CAMPAIGN-11 F7): after a customer chose to + // set their old backups aside, 12 535 KB sat at this exact path and `/backups/remote` mentioned it + // nowhere — a census found zero references to this field in any template or handler. The screen + // promised the data was kept and then showed nothing. + // + // It is surfaced now, as a plain statement that an earlier history is KEPT and NOT DELETED. It must + // NOT promise the history can be reopened — that is the R-202 lesson, and a conditional promise + // that turns out false is worse on this surface than saying less. OrphanedRenamedTo string `json:"orphaned_renamed_to,omitempty"` } diff --git a/controller/internal/web/backups_remote_statsknown_test.go b/controller/internal/web/backups_remote_statsknown_test.go new file mode 100644 index 0000000..45d6534 --- /dev/null +++ b/controller/internal/web/backups_remote_statsknown_test.go @@ -0,0 +1,144 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// ── SCENARIO F (R-225) — AN UNREAD STORE SAYS "UNKNOWN", NEVER "ZERO" ─────────────────────────── +// +// Measured live on 2026-08-05 (CAMPAIGN-11): after a rebuild this page rendered +// +// Tároló méret · 0 pillanatkép Tárhelykeret: 0 / 50 GB (0%) +// +// directly above a card stating the store contains backups made under another key. An SFTP listing +// of the remote — read-only, no decryption — found snapshot `f3d9cd67` and **12 535 KB** really +// there. `snapshot_count` and `repo_size_bytes` were simply ABSENT from settings.json, and the zero +// value spoke for them. +// +// This is R-217's defect class one card over: a field whose zero is indistinguishable from a real +// measurement, defaulted past on an unknown path. `StatsKnown` is the named state, for the same +// reason `OffsiteInventory.Empty` is named rather than inferred from `len(Apps)==0`. +// +// Render tests per branch of the gate, because a template gate without one is the v0.70.1 lesson. + +func remoteStatsData(known bool, snaps int, human string) map[string]interface{} { + d := splitTestData() + d["Offbox"] = &settings.OffboxTarget{ + Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo", + LastStatus: "error", EscrowState: "escrowed", QuotaGB: 50, + SnapshotCount: snaps, RepoSizeHuman: human, StatsKnown: known, + } + d["OffboxQuotaPct"] = 0 + return d +} + +// The branch the finding was measured on: nothing has ever been read. +func TestBackupsRemote_UnknownStoreStatsSayUnknownNotZero(t *testing.T) { + html := renderBackupPage(t, "backups_remote", remoteStatsData(false, 0, "")) + + // RED-PROOF: set StatsKnown true in this fixture (or drop the template guard) → the page renders + // "0 pillanatkép" and "0 / 50 GB (0%)" again → this FAILS. Demonstrated failing before it was kept. + if strings.Contains(html, "0 pillanatkép") { + t.Fatalf("R-225 RETURNED: an unread store reports a snapshot COUNT of zero") + } + if !strings.Contains(html, "a pillanatképek száma még ismeretlen") { + t.Fatalf("an unread store must say the count is not known") + } + if strings.Contains(html, "0 / 50 GB") { + t.Fatalf("R-225 RETURNED: an unread store reports a used figure of zero") + } + if !strings.Contains(html, "még nem tudjuk, mennyi van a tárolóban") { + t.Fatalf("an unread store must say the used figure is not known") + } + // A 0%-wide fill bar is a PICTURE of emptiness, and a picture is a claim. Scoped to the quota + // bar's own container — the page has other zero-width elements and a bare "width:0%" search + // would pass or fail for unrelated reasons. + if bar := between(html, `id="offbox-quota-bar"`, "\n "); strings.Contains(bar, "background:var(--border") { + t.Fatalf("the fill bar rendered over an unread store — a 0%% bar asserts emptiness") + } +} + +// The other branch: a store that WAS read and is genuinely empty must still say zero. Without this, +// the fix could be "never show a number", which loses real information. +func TestBackupsRemote_KnownEmptyStoreStillSaysZero(t *testing.T) { + html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 0, "")) + + if !strings.Contains(html, "0 pillanatkép") { + t.Fatalf("a store that was READ and holds nothing must say zero — that is knowledge") + } + if strings.Contains(html, "még ismeretlen") { + t.Fatalf("a measured zero must not be dressed up as unknown") + } +} + +// And a store with real content renders it unchanged. +func TestBackupsRemote_KnownNonEmptyStoreRendersTheNumbers(t *testing.T) { + html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 2, "12.0 MB")) + + if !strings.Contains(html, "2 pillanatkép") { + t.Fatalf("a measured count must render") + } + if !strings.Contains(html, "12.0 MB / 50 GB") { + t.Fatalf("a measured size must render against the quota") + } + if strings.Contains(html, "még ismeretlen") || strings.Contains(html, "még nem tudjuk") { + t.Fatalf("measured stats must not read as unknown") + } +} + +// between returns the slice of s after the first `from` and before the next `to` (empty when either +// marker is missing) — so an assertion can be scoped to one card instead of the whole page. +func between(s, from, to string) string { + i := strings.Index(s, from) + if i < 0 { + return "" + } + rest := s[i+len(from):] + if j := strings.Index(rest, to); j >= 0 { + return rest[:j] + } + return rest +} + +// ── SCENARIO H (R-228) — THE SET-ASIDE HISTORY IS VISIBLE AND HONESTLY DESCRIBED ──────────────── +// +// Measured 2026-08-05 (CAMPAIGN-11 F7): a customer chose "I do not want the old data", was told the +// backups would be KEPT and not deleted, and the move-aside did exactly that — 12 535 KB, byte-exact, +// at `/home/felhom-repo.orphaned-20260805`. The box recorded the path in `orphaned_renamed_to` and a +// census found **zero** references to it in any template or handler. The promise was kept and shown +// to nobody. +func TestBackupsRemote_SetAsideHistoryIsSurfaced(t *testing.T) { + d := splitTestData() + d["Offbox"] = &settings.OffboxTarget{ + Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo", + LastStatus: "ok", EscrowState: "escrowed", StatsKnown: true, + OrphanedRenamedTo: "/home/felhom-repo.orphaned-20260805", + } + html := renderBackupPage(t, "backups_remote", d) + + // RED-PROOF: delete the {{if .Offbox.OrphanedRenamedTo}} block → this FAILS, and the set-aside + // history is invisible again. Demonstrated failing before this test was kept. + if !strings.Contains(html, "félre vannak téve") { + t.Fatalf("R-228 RETURNED: the set-aside history is not mentioned at all") + } + if !strings.Contains(html, "nem töröltük") { + t.Fatalf("the customer must be told it was NOT deleted — that is the promise being kept") + } + // §7.6 — it must NOT promise the history can be reopened. It cannot be, by anyone, today. + for _, forbidden := range []string{"visszaállítható lehet", "vissza tudod állítani", "megnyithatod", "kóddal később"} { + if strings.Contains(html, forbidden) { + t.Errorf("the set-aside notice promises the history can be reopened (%q) — the read path does not exist", forbidden) + } + } +} + +// And a box that never set anything aside must not claim it did. +func TestBackupsRemote_NoSetAsideNoClaim(t *testing.T) { + html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 1, "12.0 MB")) + if strings.Contains(html, "félre vannak téve") { + t.Fatal("a box with no set-aside history must not claim one exists") + } +} diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go index a423748..3717aa3 100644 --- a/controller/internal/web/offbox_handlers.go +++ b/controller/internal/web/offbox_handlers.go @@ -101,6 +101,7 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) { tgt.LastWarning = prev.LastWarning tgt.EscrowState = prev.EscrowState tgt.RepoSizeBytes = prev.RepoSizeBytes + tgt.StatsKnown = prev.StatsKnown // R-225: preserved with the numbers it qualifies tgt.EnlargedBlocked = prev.EnlargedBlocked } // fork-4: enabling offsite stages the repo password to the agent for the R-escrow ceremony and marks diff --git a/controller/internal/web/recovery_class_test.go b/controller/internal/web/recovery_class_test.go index aba8fbd..4891553 100644 --- a/controller/internal/web/recovery_class_test.go +++ b/controller/internal/web/recovery_class_test.go @@ -242,3 +242,38 @@ func TestClassifyRecoveryFailure_MapsFromTheValueNotTheText(t *testing.T) { t.Fatal("classification followed the TEXT — it must follow the status") } } + +// ── SCENARIO G (R-227) — A RESTART MID-UNLOCK IS ANSWERED IN HUNGARIAN ────────────────────────── +// +// Measured 2026-08-05 (CAMPAIGN-11 F8): the controller was restarted 0.7 s into an unlock and the +// customer got traefik's raw English `Bad Gateway`. The state was clean; the page was not. +// +// The layer that answers is traefik, whose config this repo generates — but traefik v3 serves no +// static files, so a branded proxy page would need a new always-up container for every 502 on the +// box. What ships is the second sanctioned option: the unlock posts via fetch and answers a gateway +// failure in the page. This asserts the handling is PRESENT and says the right thing; with no JS the +// plain POST is unchanged and still shows the proxy's error, which the report states plainly. +func TestRecoveryClass_G_GatewayErrorIsAnsweredInHungarian(t *testing.T) { + f := newRecoveryFixture(t) + body := getRecoveryPage(t, f.s).Body.String() + + // RED-PROOF: delete the fetch handler from recovery.html → this FAILS, and a restart mid-unlock + // shows `Bad Gateway` again. + if !strings.Contains(body, "unlock-gateway-error") { + t.Fatal("R-227 RETURNED: the page carries no handling for a gateway failure") + } + if !strings.Contains(body, "A gép éppen újraindul") { + t.Fatal("the gateway message must say, in Hungarian, that the machine is restarting") + } + if !strings.Contains(body, "resp.status >= 500") { + t.Fatal("a 5xx from the proxy must be caught, not rendered") + } + // It must claim NOTHING about the code — whether it was used is unknown at that point. + if namesTyping(body) && !strings.Contains(body, "Helyreállítási kód (tíz szó)") { + t.Fatal("the gateway path must not blame the code") + } + // Progressive enhancement: the plain form must survive for a JS-less browser. + if !strings.Contains(body, `method="POST" action="/recovery/unlock"`) { + t.Fatal("the plain POST form must remain for browsers without JS") + } +} diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html index 98257c3..8b5b538 100644 --- a/controller/internal/web/templates/backups_remote.html +++ b/controller/internal/web/templates/backups_remote.html @@ -49,8 +49,8 @@
Utolsó távoli mentés{{if .Offbox.LastRun}}
{{timeAgoStr .Offbox.LastRun}}{{end}}
-
{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}–{{end}}
-
Tároló méret · {{.Offbox.SnapshotCount}} pillanatkép
+
{{if and .Offbox.StatsKnown .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}–{{end}}
+
Tároló méret · {{if .Offbox.StatsKnown}}{{.Offbox.SnapshotCount}} pillanatkép{{else}}a pillanatképek száma még ismeretlen{{end}}
{{if .Offbox.Enabled}}{{.Offbox.User}}@{{.Offbox.Host}}{{else}}Kikapcsolva{{end}}
@@ -58,12 +58,31 @@
{{if and .Offbox.Enabled (gt .Offbox.QuotaGB 0)}} +
-
Tárhelykeret: {{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%)
-
+
Tárhelykeret: {{if .Offbox.StatsKnown}}{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%){{else}}még nem tudjuk, mennyi van a tárolóban — legfeljebb {{.Offbox.QuotaGB}} GB{{end}}
+ {{if .Offbox.StatsKnown}}
-
+
{{end}} +
+ {{end}} + {{/* R-228 — THE SET-ASIDE HISTORY IS SAID OUT LOUD. + The customer chose "I do not want the old data", was told it would be KEPT and not deleted, + and then it vanished from every screen: the box recorded exactly where it went + (OrphanedRenamedTo) and showed that to nobody. Measured 2026-08-05 (CAMPAIGN-11 F7) — + 12 535 KB at a path with zero references in any template or handler. + + NOTE — IT STATES TWO FACTS AND STOPS. It does NOT promise the history can be reopened, because it + cannot be: serving a superseded package is an unbuilt link (R-199's inventory). A conditional + promise that turns out false is worse here than saying less — the R-202 lesson. */}} + {{if .Offbox.OrphanedRenamedTo}} +
+

A korábbi mentéseid félre vannak téve — nem töröltük őket.

+

Amikor új mentési kulcsot kapott a géped, a régebbi előzményt átmozgattuk + a távoli tárhelyen, és ott is maradt. Megnyitni innen egyelőre nem lehet, és + ez nem a kódodon múlik. Ha szükséged van rá, keresd a Felhom ügyfélszolgálatát.

{{end}} {{if .Offbox.LastError}}

Utolsó hiba: {{.Offbox.LastError}}

{{end}} diff --git a/controller/internal/web/templates/recovery.html b/controller/internal/web/templates/recovery.html index 4286c81..a4d6911 100644 --- a/controller/internal/web/templates/recovery.html +++ b/controller/internal/web/templates/recovery.html @@ -89,7 +89,9 @@ semmi nem változik. A visszaállítást utána, alkalmazásonként külön választhatod.

-
+ + + {{.CSRFField}}
+ {{/* R-227 — A RESTART MID-UNLOCK MUST NOT SHOW A RAW ENGLISH GATEWAY ERROR. + + Measured 2026-08-05 (CAMPAIGN-11 F8): the controller was restarted 0.7 s into an unlock and + the customer got traefik's `Bad Gateway` — a raw upstream error, in English, naming no reason + and saying nothing about whether the key was installed. The state was clean; only the page + was not. It breaches I3 (every refusal names a reason a person can act on, in Hungarian, with + no raw error). + + WHICH LAYER ANSWERS: traefik, and its config IS generated by this repo + (internal/infra/templates/traefik*.tmpl). A fully branded proxy error page is therefore + possible here — but traefik v3 serves no static files itself, so it would need a new + always-up container purely to hold an error page, for every 502 on the box. That is out of + proportion to this finding and is scoped in the report rather than built. + + What ships instead is the second sanctioned option: the unlock posts via fetch, so a gateway + error or a dropped connection is caught in the page and answered in Hungarian, without + leaving it. PROGRESSIVE ENHANCEMENT — with no JS the plain POST is unchanged, and that path + still shows the proxy's own error. Said plainly rather than implied. */}} + +

A „Most nem” csak azt jelenti, hogy nem zavarunk vele többet a kezdőlapon. A mentéseid ettől megmaradnak, és ez az oldal a Biztonsági mentés → Távoli mentés oldalról @@ -118,7 +171,7 @@

Ha megerősíted: