Files
felhom-controller/controller/internal/web/recovery_wiring_test.go
T
admin 636c51e542 R-193: the recovery screen — unlocking, and only unlocking (v0.200.0)
A customer whose machine was rebuilt had everything needed to get their data
back and no way to find out: the only route was a command line. This is the
screen that closes that.

IT UNLOCKS, AND ONLY UNLOCKS (operator ruling). It explains, takes the recovery
code, opens the repository and shows what is in there — apps, dates, sizes. It
restores nothing: restore is already per-app and lives in the backups area, and
a screen that unlocks and then offers to overwrite is two decisions wearing one
button.

ONE CORE, TWO CALLERS. RecoverInstallCore is split out of RecoverAndInstall; the
CLI wrapper keeps its exit codes and printed lines byte-identical, and the
handler drives the same function. Two implementations of the one operation that
can permanently lose a customer's data would drift, and only one would be
tested. Asserted from source on both sides by AST.

THREE WAYS OUT, none a dismiss button: recover; 'most nem' (the full page stops
interrupting, the backups-area entry point stays PERMANENTLY, bound to the offer
and never to the postpone flag); and 'I do not want the old data' — confirmed
TWICE and reaching the SHIPPED move-aside, which sets aside and never deletes.

THE CODE IS HANDLED NO MORE LOOSELY THAN ON THE COMMAND LINE: POST body only,
never logged, never persisted, never echoed, cleared on every path, no-store,
autocomplete off. No lockout — the code is a ten-word phrase, and locking a
customer out of their own data for a typo is worse than anything it prevents.

TWO DEFECTS THE TESTS CAUGHT, both fixed: an UNCLAIMED (legacy-open) box would
have been shown the page, because RequireAuth passes such a box through; and the
inventory nil-dereferenced when no off-site target was configured, which is
exactly the pristine rebuilt shape.
2026-08-05 12:45:48 +02:00

147 lines
4.6 KiB
Go

package web
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// SCENARIO I / §8.5 — the seam-discipline tests. Both walk the AST rather than grepping, because a
// commented-out call still contains the string, and both parse with comments DROPPED so a commented
// line cannot satisfy them. This project's built-but-never-wired count is six.
// The handler must drive the SHARED core. If it ever grows its own fetch→compare→install, the CLI and
// the page can diverge and only one of them will be tested — on the one operation that can
// permanently lose a customer's data.
//
// RED-PROOF: replace the RecoverInstallCore call in recoveryUnlockHandler with an inline copy →
// this fails.
func TestRecoveryHandlerDrivesTheSharedCore(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "recovery_handlers.go", nil, 0)
if err != nil {
t.Fatalf("parse recovery_handlers.go: %v", err)
}
var fn *ast.FuncDecl
ast.Inspect(f, func(n ast.Node) bool {
if d, ok := n.(*ast.FuncDecl); ok && d.Name.Name == "recoveryUnlockHandler" {
fn = d
return false
}
return true
})
if fn == nil {
t.Fatal("recoveryUnlockHandler not found — did it move? the shared-core wiring is now unasserted")
}
callsCore := false
// Any DIRECT use of the agent's unseal from the handler would be a second implementation.
callsRecoverDirectly := false
ast.Inspect(fn, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
switch sel.Sel.Name {
case "RecoverInstallCore":
callsCore = true
case "RecoverOffsiteRepoPassword", "InjectOffboxPassword":
callsRecoverDirectly = true
}
return true
})
if !callsCore {
t.Fatal("recoveryUnlockHandler does NOT call backup.RecoverInstallCore — the page and the command line would be two implementations of one irreversible operation")
}
if callsRecoverDirectly {
t.Fatal("recoveryUnlockHandler reaches the agent/injection DIRECTLY — that is a second recovery implementation, which is exactly what the shared core exists to prevent")
}
}
// And the CLI wrapper must drive the same core, from the other side.
//
// RED-PROOF: restore the inline fetch→compare→install inside RecoverAndInstall → this fails.
func TestCLIWrapperDrivesTheSharedCore(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "../backup/offbox_recovery_cli.go", nil, 0)
if err != nil {
t.Fatalf("parse offbox_recovery_cli.go: %v", err)
}
var fn *ast.FuncDecl
ast.Inspect(f, func(n ast.Node) bool {
if d, ok := n.(*ast.FuncDecl); ok && d.Name.Name == "RecoverAndInstall" {
fn = d
return false
}
return true
})
if fn == nil {
t.Fatal("RecoverAndInstall not found — the shared-core wiring is now unasserted on the CLI side")
}
callsCore, callsDirect := false, false
ast.Inspect(fn, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "RecoverInstallCore" {
callsCore = true
}
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
switch sel.Sel.Name {
case "RecoverOffsiteRepoPassword", "InjectOffboxPassword":
callsDirect = true
}
}
return true
})
if !callsCore {
t.Fatal("RecoverAndInstall no longer calls RecoverInstallCore — the CLI has its own copy again")
}
if callsDirect {
t.Fatal("RecoverAndInstall reaches the agent/injection directly — the two callers have diverged")
}
}
// The page must be REACHABLE: routed in ServeHTTP, and the landing-page interception present.
//
// RED-PROOF: comment out the interception block → this fails, and a rebuilt box's owner would never
// meet the screen unless they guessed the URL.
func TestRecoveryRoutesAreWired(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "server.go", nil, 0)
if err != nil {
t.Fatalf("parse server.go: %v", err)
}
var handlersSeen, interceptSeen bool
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
switch sel.Sel.Name {
case "recoveryPageHandler", "recoveryUnlockHandler", "recoveryPostponeHandler":
handlersSeen = true
case "recoveryInterrupts":
interceptSeen = true
}
return true
})
if !handlersSeen {
t.Fatal("no recovery handler is routed in ServeHTTP — the page exists and is unreachable")
}
if !interceptSeen {
t.Fatal("ServeHTTP never consults recoveryInterrupts — the full page would never take over the landing pages, so a customer would have to guess the URL")
}
}