v0.207.0 — R-249: the retrieval passphrase leaves the page body; R-252/R-253: two refusals learn to say what to do
gates / gates (push) Successful in 18s

R-249. settings_security.html rendered the passphrase into a display:none
span behind a Megjelenit button. That toggle stops a browser DRAWING the value
and nothing else — the plaintext was in the response body of every render, so a
curl of the page returned it. Found by exactly that: it landed in a session
transcript while driving the documented rebuild path.

The codebase already stated this rule for the recovery code and this page did not
follow it (escrow_handlers.go: 'reveal (claim XHR only — R is NEVER templated
server-side into HTML)'). The page now carries only HasRetrievalPassword; the
value comes from POST /settings/retrieval-password/reveal — CSRF-covered because
POST, no-store, and LOGGED as an act, which reading it off the markup never was.

The tests assert the RAW RESPONSE BODY. Every test that asked what the customer
sees passed while the bytes carried the secret; that is why this survived.

Census: the render-then-hide pattern appears twice more — app_info.html (a real
per-install app password in a hidden span) and deploy.html. Filed as R-254, NOT
fixed here.

R-252. A rebuilt box keeps its drives but loses their REGISTRATION. The restore
page now states that before the customer presses anything, says the backups and
drives are both still there, and links to Tarhely > Meghajtok. Page and resolver
ask ONE question — HasRestoreDestination() reads the same
GetSchedulableStoragePaths() the scratch resolver reads.

R-253. The list promised 'a visszaallitas elobb ujratelepiti' three lines above a
refusal that fired BECAUSE the app was not installed. The promise was the wrong
half: reconstitution writes to the app's own GetStackHDDPath, which exists only
once the CUSTOMER has chosen a drive at deploy time. Auto-reinstalling would mean
the product making that choice for them. Copy now says to install first and routes
to /stacks/<app>/deploy.

Both notices are conditional — a healthy box renders as before, pinned by a test
that fails if either becomes unconditional.
This commit is contained in:
2026-08-07 18:04:26 +02:00
parent 3d3b4496f3
commit 8dbbc98ff2
14 changed files with 554 additions and 15 deletions
@@ -197,7 +197,14 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string) (Of
hdd := strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack))
if hdd == "" {
return res, fmt.Errorf("a(z) %s nincs telepítve — előbb állítsd helyre az alkalmazást, utána az adatokat", stack)
// R-253: the same sentence the restore page now shows, so the page and the refusal cannot
// drift apart again. It is a REFUSAL, not a failure — the data is untouched and the customer
// has one step to take. The restore deliberately does NOT deploy the app itself: the
// destination is the app's own HDD path, which is a drive the CUSTOMER chooses at deploy
// time, and picking it for them is the decision this whole recovery path exists to leave
// with them.
return res, fmt.Errorf("a(z) %s nincs telepítve, ezért nincs hová visszaállítani az adatait — "+
"telepítsd újra az alkalmazást (Alkalmazások), utána ez a visszaállítás működni fog", stack)
}
liveNs := m.namespaceRoot(hdd)
+30 -1
View File
@@ -171,7 +171,36 @@ func (m *Manager) offboxRestoreScratchDir(stack string) (scratch, nsRoot string,
}
}
}
return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz")
// R-252: name the reason AND the way to act on it. This refusal is what a rebuilt box hits — the
// drives are physically fine and still mounted, it is their REGISTRATION that the destroyed guest
// took with it — and until v0.207.0 it said only that a drive was missing, which reads like data
// loss and offers nothing to do.
return "", "", fmt.Errorf("nincs regisztrált adatmeghajtó, ezért nincs hová visszaállítani — " +
"a meghajtók megvannak, csak újra kell csatolni őket a Tárhely → Meghajtók oldalon, utána " +
"ez a visszaállítás működni fog")
}
// HasRestoreDestination reports whether an offsite restore has anywhere on this box to write.
//
// R-252: the restore PAGE asks this question through the same helper the resolver answers it with,
// so the notice cannot appear on a box that would restore fine (Scenario E) nor stay hidden on one
// that would refuse. A second copy of the predicate is exactly how a page ends up promising what the
// handler then refuses — which is the neighbouring defect, R-253.
//
// It mirrors the resolver's BOX-level branches (2) and (3) — the schedulable storage paths. Branch
// (1), the app's own HDD path, is deliberately not consulted: an installed app's HDD path IS a
// registered storage path, so the two cannot disagree in practice, and where they could, erring
// toward showing the notice is erring toward telling the customer something true.
func (m *Manager) HasRestoreDestination() bool {
if m.settings == nil {
return false
}
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if strings.TrimSpace(sp.Path) != "" {
return true
}
}
return false
}
// RestoreOffboxScratch restores an app's latest offsite snapshot to an on-data-drive scratch dir
@@ -0,0 +1,46 @@
package backup
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-252 — THE PAGE'S QUESTION AND THE RESOLVER'S ANSWER ARE THE SAME QUESTION.
//
// The restore page renders its "re-attach your drive" notice from HasRestoreDestination(); every
// restore refuses from offboxRestoreScratchDir(). If those two ever disagree the page either warns a
// box that would restore fine, or stays silent on one that cannot — and the second is R-252 exactly
// as the fifth walk met it. So this asserts the CONSEQUENCE (does the resolver refuse?) against the
// predicate the page renders from, on one fixture.
//
// RED-PROOF: make HasRestoreDestination return true unconditionally and the first block fails — the
// predicate claims a destination while the resolver refuses in the very next assertion.
func TestHasRestoreDestination_AgreesWithTheResolver(t *testing.T) {
m, sett := bareManager(t)
// No registered storage path: the resolver refuses, and the predicate must say so.
if m.HasRestoreDestination() {
t.Error("HasRestoreDestination() is true with no registered storage path — the restore page " +
"would stay silent while every restore refuses")
}
if _, _, err := m.offboxRestoreScratchDir("calibre-web"); err == nil {
t.Fatal("the resolver found a destination with no registered storage path — fixture is wrong, " +
"so the agreement below would prove nothing")
} else if !strings.Contains(err.Error(), "Tárhely") {
t.Errorf("the refusal names no route: %v", err)
}
// Re-attach a drive, exactly as the customer does from Tárhely → Meghajtók.
if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/adatok", Label: "Adatok", Schedulable: true}); err != nil {
t.Fatalf("register storage path: %v", err)
}
if !m.HasRestoreDestination() {
t.Error("HasRestoreDestination() is still false after the drive was re-attached — the notice " +
"would stay on screen after the customer fixed the thing it asked them to fix")
}
if _, _, err := m.offboxRestoreScratchDir("calibre-web"); err != nil {
t.Errorf("the resolver still refuses after a drive was registered: %v", err)
}
}