Files
felhom-controller/controller/internal/backup/offbox_placement_refusal_test.go
T
admin 985388c6e9
gates / gates (push) Successful in 10s
R-351: the restore compares where the backup says the data lived; second press cannot start a second run
Part 3 (not droppable) and the engine half of Part 2. No version bump yet - one bump and
one bake at the end of the session.

PART 3a - a second press really did start a second run. Established with a test BEFORE any
change: both offboxReconstituteHandler and offboxPlaceHandler answered "...elindult" and
overwrote the first restore's op/stack. Cause: every restore handler gated on
backupMgr.IsRunning() - the CONCURRENCY flag, which the restore goroutine acquires AFTER the
handler returns (offbox_reconstitute.go:180, offbox_restore.go:393). Seven sites. The wizard
had read the correct flag since v0.154.0 and said so in a comment; the handlers never moved.
New Server.restoreOpBlocked() reads BOTH flags - the display flag covers the whole off-box
restore, the concurrency flag is the only one the nightly backup holds - and the refusal now
names the running app and a route.

PART 3b - the page DOES refresh; the defect was the RESULT. backups_shared.html gated the
terminal result on a page-local sawRunning flag, so a restore that finished before the page
was opened, or inside one 3s poll, was shown to nobody. The 2026-08-21 OpenGist restore took
8.666s and no screen ever said it completed - the answer existed only in docker logs.
RestoreOpStatus.LastRecent now carries the server's verdict. The 10-minute window moved to
internal/backup as RestoreResultWindow and internal/web's constant is an alias: one
expression, two surfaces. Also removed the wizard's self-contradiction, which said the state
refreshes automatically AND that you must refresh the page.

PART 2 (engine) - every recovery unit manifest has carried drive and namespace_root since
schema 1, and NO non-test code read either back. The reconstitution opened the manifest and
took only the coherence stamp, then resolved its destination from the live app. A restore
into a different destination succeeded silently under a green message. New
backup/offbox_placement.go: CheckPlacement (pure, total), PlacementMismatchMessage,
recordedPlacementFromScratch. Compared before the safety dump and before the first byte.
A mismatch is NAMED and refused; ackPlacementChange lets the customer proceed deliberately -
a separate field from confirm=1, because one click must not carry two decisions. An UNKNOWN
recording is never a mismatch: refusing on an absence would strand every pre-field unit.
The not-installed refusal (R-253) now names the drive the backup recorded.

RED-PROOFS, each mutation asserted applied and reverted to 0:
  B  both guards removed (count asserted 2) -> the restore WAS seen starting with no drive
     attached: no error, full 3.00s run, wrote into /tmp/mutant-destination
  C  Mismatch forced false -> the silent divergent restore returned
  E  Known() forced true  -> the fabricated empty prefill appeared
  D  Mismatch forced true -> 8 ordinary reconstitute tests broke, proving reachability both ways
Note on D: the existing fixtures write a schema-1 manifest with NO drive, so they are
scenario-E shaped. The matching case is covered in the scenario table, not by them.

Gates 11/11 OK. Suite 28 packages ok. Hungarian verified as hex, no BOM, no mojibake sentinels.

NOT in this commit, still open: Part 2's scenario-A prefill UI, Part 1's deploy-page
visibility line, Part 1's specification document, Part 4's measurement.
2026-08-21 21:04:16 +02:00

168 lines
6.7 KiB
Go

package backup
import (
"context"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
// scratchManifestPath finds the unit manifest the fixture wrote into the prepared scratch, so a test
// can rewrite it. Fails loudly rather than returning "": a silently-missing manifest would make the
// tests below pass for the wrong reason.
func scratchManifestPath(t *testing.T, m *Manager, stack string) string {
t.Helper()
scratch, _, err := m.offboxRestoreScratchDir(stack)
if err != nil {
t.Fatalf("fixture: scratch dir: %v", err)
}
var found string
_ = filepath.WalkDir(scratch, func(p string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() && d.Name() == "manifest.json" {
found = p
return fs.SkipAll
}
return nil
})
if found == "" {
t.Fatal("fixture: no unit manifest in the prepared scratch — the test would prove nothing")
}
return found
}
// R-351 SCENARIO B — THE APP IS NOT INSTALLED AND THE BACKUP NAMES WHERE IT LIVED.
//
// The refusal already existed (R-253) and correctly stopped the restore. What it did NOT do was say
// where the data belonged, so the person on 2026-08-21 had to remember the drive and the address
// themselves — both of which the backup was holding the whole time.
//
// WRONG OUTCOME THIS PINS: "it starts, and writes somewhere else." The assertions below are that the
// restore is refused AND that the app was never touched — not merely that an error came back.
func TestReconstitute_NotInstalled_RefusalNamesTheRecordedDrive(t *testing.T) {
const recordedDrive = "/mnt/felhom-drives/hdd_1"
m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
// The backup records a drive — the one this box no longer has attached.
manPath := scratchManifestPath(t, m, "immich")
if err := writeManifest(manPath, &RecoveryManifest{
SchemaVersion: 2, AppName: "immich",
Drive: recordedDrive, NamespaceRoot: recordedDrive,
}); err != nil {
t.Fatal(err)
}
// The app is not installed: no live HDD path. This is the rebuilt-machine shape.
prov.hdd = map[string]string{}
if got := prov.GetStackHDDPath("immich"); got != "" {
t.Fatalf("fixture: the app must look uninstalled, got hdd=%q", got)
}
callsBefore := len(prov.calls)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a restore with no destination must be REFUSED, not started")
}
if !strings.Contains(err.Error(), recordedDrive) {
t.Errorf("the refusal must name the drive the backup recorded (%s); got: %v", recordedDrive, err)
}
// The route, not just the reason. „Alkalmaz" is the ASCII stem of „Alkalmazások" — matching the
// stem keeps accented bytes out of the comparison (strict rule 7).
if !strings.Contains(err.Error(), "Alkalmaz") {
t.Errorf("the refusal must name a route the person can take; got: %v", err)
}
// THE OBSERVABLE THAT MATTERS: nothing was stopped, placed or started. A refusal that still
// touched the stack would be the defect wearing an error message.
if len(prov.calls) != callsBefore {
t.Errorf("a refused restore must not touch the app; provider calls: %v", prov.calls[callsBefore:])
}
}
// R-351 SCENARIO C — THE DESTINATION DIFFERS FROM THE ONE THE BACKUP RECORDED.
//
// Not blocked outright (a drive can legitimately change) and not silently accepted (which is how a
// restore lands in the wrong place under a green message). Named, and then the customer's own
// deliberate acknowledgement carries it.
func TestReconstitute_MismatchedDestination_RefusesThenProceedsOnAcknowledgement(t *testing.T) {
const recordedDrive = "/mnt/felhom-drives/hdd_1"
m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
manPath := scratchManifestPath(t, m, "immich")
if err := writeManifest(manPath, &RecoveryManifest{
SchemaVersion: 2, AppName: "immich",
Drive: recordedDrive, NamespaceRoot: recordedDrive,
}); err != nil {
t.Fatal(err)
}
// The app IS installed — on a different drive from the recorded one (the fixture's temp dir).
live := prov.GetStackHDDPath("immich")
if live == "" || live == recordedDrive {
t.Fatalf("fixture: the live drive must exist and differ from the recorded one; got %q", live)
}
callsBefore := len(prov.calls)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a divergent destination must be NAMED and refused, never silently accepted")
}
for _, must := range []string{recordedDrive, live} {
if !strings.Contains(err.Error(), must) {
t.Errorf("the refusal must name %q so the customer can decide; got: %v", must, err)
}
}
if len(prov.calls) != callsBefore {
t.Errorf("the refusal must not touch the app; provider calls: %v", prov.calls[callsBefore:])
}
// ...and the customer may go ahead deliberately. Blocking outright is the other wrong outcome.
res, err := m.ReconstituteFromOffsite(context.Background(), "immich", true)
if err != nil {
t.Fatalf("an acknowledged placement change must proceed: %v", err)
}
if !res.Placement.Mismatch {
t.Error("the outcome must still record that the destination differed — a success that forgets the difference reads as a match")
}
if res.Placement.Recorded.Drive != recordedDrive {
t.Errorf("the outcome must carry the recorded drive, got %q", res.Placement.Recorded.Drive)
}
}
// recordedPlacementFromScratch must be TOTAL: an absent scratch, an unreadable one and a manifest
// with no drive all yield the zero value, never a fabricated empty path.
func TestRecordedPlacementFromScratch_UnknownStaysUnknown(t *testing.T) {
m := &Manager{}
if got := m.recordedPlacementFromScratch(filepath.Join(t.TempDir(), "does-not-exist")); got.Known() {
t.Errorf("an absent scratch must not yield a recorded placement, got %+v", got)
}
empty := t.TempDir()
if got := m.recordedPlacementFromScratch(empty); got.Known() {
t.Errorf("an empty scratch must not yield a recorded placement, got %+v", got)
}
noDrive := t.TempDir()
if err := writeManifest(filepath.Join(noDrive, "manifest.json"),
&RecoveryManifest{SchemaVersion: 1, AppName: "immich"}); err != nil {
t.Fatal(err)
}
if got := m.recordedPlacementFromScratch(noDrive); got.Known() {
t.Errorf("a manifest with no drive is an UNKNOWN, not a value; got %+v", got)
}
withDrive := t.TempDir()
deep := filepath.Join(withDrive, "a", "b", "c", "backups", "primary", "immich")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
if err := writeManifest(filepath.Join(deep, "manifest.json"),
&RecoveryManifest{SchemaVersion: 2, AppName: "immich", Drive: "/mnt/sys_drive"}); err != nil {
t.Fatal(err)
}
got := m.recordedPlacementFromScratch(withDrive)
if !got.Known() || got.Drive != "/mnt/sys_drive" {
t.Errorf("a nested unit manifest must be found, got %+v", got)
}
}