R-225/R-227/R-228 Parts 2-4: unknown is not zero, the gateway speaks Hungarian, the set-aside is visible

R-225 — an unread store said '0 pillanatkép / 0 / 50 GB' above a card stating
it held backups under another key. An SFTP listing found snapshot f3d9cd67 and
12 535 KB really there; snapshot_count and repo_size_bytes were simply ABSENT
and the zero value spoke for them. StatsKnown is now NAMED, for the same reason
OffsiteInventory.Empty is: zero is what an unread store and an empty one both
look like, and on the wire 'absent' and '0' are the same bytes. The fill bar
renders only when the fill is known — a 0%-wide bar is a picture of emptiness,
and a picture is a claim. A measured zero still says zero.

R-227 — WHICH LAYER ANSWERS: traefik, and this repo generates its config. But
traefik v3 serves no static files, so a branded proxy page needs a new always-up
container for every 502 on the box — out of proportion, and scoped in the report
rather than built. Shipped instead: the unlock posts via fetch and answers a
gateway failure in Hungarian without leaving the page. Progressive enhancement —
with no JS the plain POST is unchanged and still shows the proxy's error, which
the report says plainly rather than implying otherwise.

R-228 — the set-aside history was recorded in orphaned_renamed_to and read by
nobody: a census found zero references in any template or handler, while 12 535
KB sat at that path. It is surfaced as two facts and stops. It does NOT promise
the history can be reopened, because it cannot be by anyone today (R-199's
inventory is unbuilt) — and the set-aside CONFIRMATION copy was corrected for
the same reason: 'a helyreállítási kód nélkül többé nem lesznek megnyithatók'
implied that WITH the code they could be. The field's own comment called it
'recovery-code-recoverable', which was the same over-promise in the code.

Tests: scenarios F, G, H as render tests per branch of each gate. Red-proofs,
each demonstrated failing then restored: remove the StatsKnown guards (F,
'R-225 RETURNED: an unread store reports a snapshot COUNT of zero'), delete the
set-aside block (H). The F assertion on the fill bar is scoped to the bar's own
container — a bare width:0% search matched unrelated elements and would have
passed for the wrong reason.

28 packages ok, vet clean, all controller gates OK (the emoji gate caught a
warning sign in a template comment).
This commit is contained in:
2026-08-06 08:17:48 +02:00
parent 1e759a16ec
commit c7446f2d6a
7 changed files with 291 additions and 8 deletions
@@ -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"`, "</div>\n </div>"); 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")
}
}
@@ -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
@@ -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")
}
}
@@ -49,8 +49,8 @@
<div class="stat-label">Utolsó távoli mentés{{if .Offbox.LastRun}}<br><span class="relative-time">{{timeAgoStr .Offbox.LastRun}}</span>{{end}}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size:1.15rem">{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}{{end}}</div>
<div class="stat-label">Tároló méret · {{.Offbox.SnapshotCount}} pillanatkép</div>
<div class="stat-value" style="font-size:1.15rem">{{if and .Offbox.StatsKnown .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}{{end}}</div>
<div class="stat-label">Tároló méret · {{if .Offbox.StatsKnown}}{{.Offbox.SnapshotCount}} pillanatkép{{else}}a pillanatképek száma még ismeretlen{{end}}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size:1.05rem">{{if .Offbox.Enabled}}{{.Offbox.User}}@{{.Offbox.Host}}{{else}}Kikapcsolva{{end}}</div>
@@ -58,12 +58,31 @@
</div>
</div>
{{if and .Offbox.Enabled (gt .Offbox.QuotaGB 0)}}
<!-- R-225: the BAR renders only when the fill is known. A 0%-wide bar over an unread store is a
picture of emptiness, and a picture is a claim. -->
<!-- SLICE 4: soft-quota usage bar (shared model; quota_gb from the hub descriptor). -->
<div id="offbox-quota-bar" style="max-width:560px;margin:.5rem 0">
<div class="stat-label" style="margin-bottom:.25rem">Tárhelykeret: {{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%)</div>
<div style="background:var(--border,#334);border-radius:4px;height:8px;overflow:hidden">
<div class="stat-label" style="margin-bottom:.25rem">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}}</div>
{{if .Offbox.StatsKnown}}<div style="background:var(--border,#334);border-radius:4px;height:8px;overflow:hidden">
<div style="height:8px;border-radius:4px;width:{{.OffboxQuotaPct}}%;background:{{if ge .OffboxQuotaPct 100}}var(--crit,#e5484d){{else if ge .OffboxQuotaPct 80}}var(--warn,#f5a524){{else}}var(--ok,#30a46c){{end}}"></div>
</div>
</div>{{end}}
</div>
{{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}}
<div class="alert alert-info" style="margin-top:.75rem">
<p><strong>A korábbi mentéseid félre vannak téve — nem töröltük őket.</strong></p>
<p class="form-hint">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. <strong>Megnyitni innen egyelőre nem lehet</strong>, és
ez nem a kódodon múlik. Ha szükséged van rá, keresd a Felhom ügyfélszolgálatát.</p>
</div>
{{end}}
{{if .Offbox.LastError}}<p class="form-hint" style="color:var(--crit)">Utolsó hiba: {{.Offbox.LastError}}</p>{{end}}
@@ -89,7 +89,9 @@
semmi nem változik.</strong> A visszaállítást utána, alkalmazásonként külön választhatod.
</p>
<form method="POST" action="/recovery/unlock" autocomplete="off">
<div id="unlock-gateway-error" class="alert alert-error" style="display:none" role="alert"></div>
<form id="unlock-form" method="POST" action="/recovery/unlock" autocomplete="off">
{{.CSRFField}}
<label for="recovery_code">Helyreállítási kód (tíz szó)</label>
<input type="password" id="recovery_code" name="recovery_code"
@@ -104,6 +106,57 @@
</div>
</form>
{{/* 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. */}}
<script>
(function () {
var form = document.getElementById('unlock-form');
var box = document.getElementById('unlock-gateway-error');
if (!form || !box || !window.fetch) { return; }
form.addEventListener('submit', function (ev) {
ev.preventDefault();
box.style.display = 'none';
var btn = form.querySelector('button[type=submit]');
if (btn) { btn.disabled = true; btn.textContent = 'Feloldás folyamatban…'; }
fetch(form.action, {
method: 'POST',
body: new FormData(form),
credentials: 'same-origin',
redirect: 'follow'
}).then(function (resp) {
if (resp.status >= 500) { throw new Error('gateway'); }
return resp.text().then(function (html) {
document.open(); document.write(html); document.close();
});
}).catch(function () {
// A 5xx from the proxy, or no response at all: the machine is very likely restarting.
// NOTHING is claimed about the code — we do not know whether it was used.
if (btn) { btn.disabled = false; btn.textContent = 'Mentések feloldása'; }
box.textContent = 'A gép éppen újraindul, ezért most nem tudtuk befejezni a műveletet. '
+ 'Semmi nem változott. Várj néhány másodpercet, és próbáld újra — a kódodra továbbra is szükséged lesz, '
+ 'úgyhogy tartsd kéznél.';
box.style.display = '';
});
});
})();
</script>
<p class="form-hint">
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 <strong>Biztonsági mentés → Távoli mentés</strong> oldalról
@@ -118,7 +171,7 @@
<p>Ha megerősíted:</p>
<ul>
<li>a korábbi mentéseket <strong>félretesszük — nem töröljük</strong>;</li>
<li>a helyreállítási kód nélkül <strong>többé nem lesznek megnyithatók</strong>;</li>
<li>a félretett mentések <strong>innen többé nem nyithatók meg</strong> — sem kóddal, sem anélkül;</li>
<li>a gép <strong>új, üres mentési tárolót kezd</strong>, és mostantól oda ment;</li>
<li>ez az oldal <strong>többé nem jelenik meg</strong>.</li>
</ul>