R-356: the off-site restore refused every app that has no data drive
gates / gates (push) Failing after 12s

ReconstituteFromOffsite and PlaceOffsiteRestore both resolved the restore
destination with the RAW HDD_PATH and read an empty answer as "the app is not
installed". For 40 of the 53 catalogue apps that answer is correctly empty and
permanent, so both actions refused forever for a running, healthy app — and told
the customer to reinstall it "in the same place", which those apps never offer.

Separate the two questions. "Installed?" is asked of ListDeployedStacks via a new
Manager.isStackDeployed that fails CLOSED on a nil provider. "Where?" is answered
by GetAppDrivePath — the same resolver CaptureRecoveryUnit wrote the snapshot
with, so the restore aims at the place the backup came from.

The 13 drive apps are unchanged: own drive, mismatch check, ack still required.
A third refusal, with its own sentence, covers installed-but-no-resolvable-root.

Fixtures that marked an app "installed" by giving it an HDD path now state
deployment as its own fact. No assertion weakened.
This commit is contained in:
2026-08-22 13:08:46 +02:00
parent 2da259af38
commit 08eb1a6e3a
9 changed files with 541 additions and 25 deletions
@@ -0,0 +1,94 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// R-356 §10 seam discipline. The restore now decides "is this app installed?" from
// ListDeployedStacks. That answer only means anything if the PRODUCTION adapter is the thing
// answering it, and if that adapter really filters on Deployed — a component that is correct in a
// fake and unreachable in production is the four-times-shipped defect class in this repo.
//
// AST, never strings.Contains: a commented-out call still satisfies a substring match.
// funcBody returns the named top-level func's body from main.go, or fails.
func funcBodyInMain(t *testing.T, name string) *ast.BlockStmt {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatalf("parse main.go: %v", err)
}
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil || fn.Name.Name != name {
continue
}
return fn.Body
}
t.Fatalf("func %s not found in main.go", name)
return nil
}
// TestMainWiresTheStackProviderIntoTheBackupManager: without this call the backup manager's
// stackProvider is nil, isStackDeployed fails closed, and EVERY off-site restore refuses.
func TestMainWiresTheStackProviderIntoTheBackupManager(t *testing.T) {
body := funcBodyInMain(t, "main")
var found bool
ast.Inspect(body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "SetStackProvider" || len(call.Args) != 1 {
return true
}
found = true
return false
})
if !found {
t.Fatal("main() never calls SetStackProvider — the restore's deployment check would fail closed for every app")
}
}
// TestStackAdapterListDeployedFiltersOnDeployed pins the semantics R-356 depends on: the adapter
// returns DEPLOYED stacks, not all known ones. Verified by the presence of the `!s.Deployed`
// continue-guard in the method body — remove it and an app that was never deployed would be told it
// has somewhere to restore to.
func TestStackAdapterListDeployedFiltersOnDeployed(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatalf("parse main.go: %v", err)
}
var body *ast.BlockStmt
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv == nil || fn.Name.Name != "ListDeployedStacks" || fn.Body == nil {
continue
}
body = fn.Body
}
if body == nil {
t.Fatal("stackAdapter.ListDeployedStacks not found in main.go")
}
var guarded bool
ast.Inspect(body, func(n ast.Node) bool {
un, ok := n.(*ast.UnaryExpr)
if !ok || un.Op != token.NOT {
return true
}
sel, ok := un.X.(*ast.SelectorExpr)
if ok && sel.Sel.Name == "Deployed" {
guarded = true
}
return true
})
if !guarded {
t.Fatal("ListDeployedStacks does not test !s.Deployed — an undeployed app would be reported installed")
}
}
+22
View File
@@ -374,6 +374,28 @@ func (m *Manager) knownStackNames() []string {
return names
}
// isStackDeployed reports whether the named app is currently deployed on this box. R-356: the
// restore side used to answer "is it installed?" by asking "does it have a drive?" — true for the 13
// needs_hdd apps and permanently false for the other 40, which are correctly driveless. The two
// questions are now separate, and this one is the one about installation.
//
// Nil provider ⇒ FALSE, deliberately: with no provider we cannot tell whether the app exists, and the
// caller's next act is to WRITE customer data to a destination. "Cannot tell" must not become
// "go ahead" — it fails closed into the refusal, which is recoverable, instead of into a copy, which
// is not.
func (m *Manager) isStackDeployed(stack string) bool {
stack = strings.TrimSpace(stack)
if stack == "" {
return false
}
for _, name := range m.knownStackNames() {
if name == stack {
return true
}
}
return false
}
// groupStacksByDrive groups deployed stacks by their home drive path.
func (m *Manager) groupStacksByDrive() map[string][]StackSummary {
if m.stackProvider == nil {
@@ -15,6 +15,10 @@ func placeFixture(t *testing.T, full bool) (*Manager, *offbox3aProvider, string,
drive := t.TempDir()
m, _, prov := classifiedOffboxManager(t, drive)
prov.hdd["immich"] = drive
// R-356: "installed" is now asked directly (ListDeployedStacks), not inferred from owning a
// drive. The fixture must say so — before, the HDD path alone stood in for both facts, which is
// exactly the conflation this change removes.
prov.deployed = map[string]bool{"immich": true}
scratch, liveNs, err := m.offboxRestoreScratchDir("immich")
if err != nil {
@@ -61,7 +65,10 @@ func placeFixture(t *testing.T, full bool) (*Manager, *offbox3aProvider, string,
// A (F-3a-1a): undeployed placement refused with ZERO copies (never merges onto the SSD namespace).
func TestPlace_UndeployedRefused(t *testing.T) {
m, prov, scratch, copies := placeFixture(t, true)
prov.hdd["immich"] = "" // undeployed
// R-356: undeployed means ABSENT FROM ListDeployedStacks. Clearing only the HDD path no longer
// makes an app look uninstalled — a driveless app is the normal case for 40 of the 53 apps.
prov.deployed = map[string]bool{}
prov.hdd["immich"] = ""
err := m.PlaceOffsiteRestore(context.Background(), "immich")
if err == nil || !strings.Contains(err.Error(), "nincs telepítve") {
t.Fatalf("undeployed must refuse with 'nincs telepítve', got %v", err)
@@ -54,10 +54,13 @@ func TestReconstitute_NotInstalled_RefusalNamesTheRecordedDrive(t *testing.T) {
t.Fatal(err)
}
// The app is not installed: no live HDD path. This is the rebuilt-machine shape.
// The app is not installed: absent from the deployed set. This is the rebuilt-machine shape.
// R-356: the HDD path is cleared too (a rebuilt box has neither), but the DEPLOYED set is what
// the refusal now consults — an empty HDD path on its own is the normal shape of 40 apps.
prov.deployed = map[string]bool{}
prov.hdd = map[string]string{}
if got := prov.GetStackHDDPath("immich"); got != "" {
t.Fatalf("fixture: the app must look uninstalled, got hdd=%q", got)
if m.isStackDeployed("immich") {
t.Fatal("fixture: the app must look uninstalled")
}
callsBefore := len(prov.calls)
@@ -211,19 +211,24 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
}
res.SnapshotID = id
hdd := strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack))
if hdd == "" {
// 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.
// R-351: the refusal now NAMES the place the backup recorded, when it can read it. The
// prepared scratch already contains the unit, so this is a local file read — no network call,
// nothing restored, and it happens on a path that was going to refuse anyway. Telling
// somebody to reinstall without telling them where the data belongs is what forced the
// 2026-08-21 operator to remember two values the backup already held.
// 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.
// R-351: the refusal now NAMES the place the backup recorded, when it can read it. The
// prepared scratch already contains the unit, so this is a local file read — no network call,
// nothing restored, and it happens on a path that was going to refuse anyway. Telling
// somebody to reinstall without telling them where the data belongs is what forced the
// 2026-08-21 operator to remember two values the backup already held.
// R-356: this refusal used to be reached by `GetStackHDDPath(stack) == ""` — one predicate
// answering two questions. It now covers ONLY "the app is not deployed", and it stopped
// covering "the app has no drive". The reason the two came apart: the drive choice is the
// CUSTOMER's, and 40 of the 53 catalog apps were never offered one — they have no choice to
// leave with them, and their data lives on the system data path by design. The R-253 decision
// above is untouched for the 13 apps that DO have a drive to get wrong.
if !m.isStackDeployed(stack) {
if rec := m.recordedPlacementFromScratch(scratch); rec.Known() {
return res, fmt.Errorf("a(z) %s nincs telepítve, ezért nincs hová visszaállítani az adatait. "+
"A mentése szerint az adatai itt voltak: %s. Telepítsd újra az alkalmazást (Alkalmazások) "+
@@ -232,6 +237,19 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
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)
}
// The destination is resolved by the SAME rule the capture side used to write this snapshot
// (CaptureRecoveryUnit → GetAppDrivePath): the app's drive if it has one, the system data path
// otherwise. Anything else and the restore would aim at a different place than the backup came
// from, which is the mismatch prompt firing on a box where nothing actually moved.
hdd := strings.TrimSpace(m.GetAppDrivePath(stack))
if hdd == "" {
// A DIFFERENT failure from the one above, so it gets a different sentence: the app IS
// installed, but the box cannot name its own data root (systemDataPath unset). Saying
// "nincs telepítve" here would send the customer to reinstall an app that is already
// running, and the real fault would stay invisible.
return res, fmt.Errorf("a(z) %s telepítve van, de a vezérlő nem tudja megállapítani, hová tartoznak az adatai "+
"(nincs beállítva rendszer-adatterület). Ellenőrizd a tárhely beállításait (Tárhely), utána indítsd újra a visszaállítást", stack)
}
liveNs := m.namespaceRoot(hdd)
placements, err := mapOffsiteRestorePaths(paths, stack, scratch, liveNs)
@@ -93,6 +93,9 @@ func reconFixture(t *testing.T, runID, dumpsAt string, dumpBody string) (*Manage
m, sett := newOffboxManager(t)
prov := &recordingProvider{offbox3aProvider: offbox3aProvider{
hdd: map[string]string{"immich": drive}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{},
// R-356: the app is DEPLOYED, stated as its own fact. It used to be implied by owning a
// drive; the restore now asks the two questions separately.
deployed: map[string]bool{"immich": true},
}}
m.SetStackProvider(prov)
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "USB", Schedulable: true}); err != nil {
+13 -8
View File
@@ -407,16 +407,21 @@ func (m *Manager) PlaceOffsiteRestore(ctx context.Context, stack string) error {
return err
}
_ = id
// F-3a-1a: the live target uses the RAW HDD path (mirrors offboxCaptureSet). NOT AppNamespaceRoot —
// its systemDataPath fallback would merge userdata onto the SSD system namespace. Empty HDD ⇒
// undeployed ⇒ refuse: the app must be restored first, then its data placed under its live drive.
hdd := ""
if m.stackProvider != nil {
hdd = strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack))
}
if hdd == "" {
// F-3a-1a said: the live target uses the RAW HDD path, and an empty HDD means undeployed.
// R-356 split that: "undeployed" is now asked directly, and the destination is resolved by the
// SAME rule the capture side wrote this snapshot with (GetAppDrivePath — drive if the app has
// one, system data path otherwise). For the 13 needs_hdd apps nothing changes; for the 40 that
// were never offered a drive the old test was permanently true and this merge was unreachable.
if !m.isStackDeployed(stack) {
return fmt.Errorf("a(z) %s nincs telepítve — előbb állítsd helyre az alkalmazást, utána az adatokat", stack)
}
hdd := strings.TrimSpace(m.GetAppDrivePath(stack))
if hdd == "" {
// Installed, but the box cannot name its own data root. Distinct reason ⇒ distinct sentence:
// telling the customer to reinstall a running app would hide the real fault.
return fmt.Errorf("a(z) %s telepítve van, de a vezérlő nem tudja megállapítani, hová tartoznak az adatai "+
"(nincs beállítva rendszer-adatterület). Ellenőrizd a tárhely beállításait (Tárhely), utána indítsd újra a visszaállítást", stack)
}
liveNs := m.namespaceRoot(hdd)
// F-3a-1b: headroom gate — a missing-only merge copies at most the scratch size; refuse before any
// copy if the live drive lacks that (conservative — scratch and live often share a drive).
@@ -0,0 +1,314 @@
package backup
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// R-356. Forty of the fifty-three catalogue apps declare no data drive. The off-site restore resolved
// its destination with the RAW HDD_PATH and treated an empty answer as "the app is not installed", so
// those forty were refused permanently — while running — with a message telling the customer to
// reinstall them "in the same place", a place those apps never offer. One predicate was answering two
// questions. This file pins the two questions apart.
//
// Measured in app-catalog-felhom.eu @ 459766cb1639: 53 templates, 13 `needs_hdd: true`, 40 `false`.
// hotOnlyFixture is reconFixture with the app made DRIVELESS — the shape of those forty apps. The
// prepared scratch still resolves to the registered storage path (offboxRestoreScratchDir step 2), so
// the srcs the fixture laid down are still where the code looks for them; only the DESTINATION moves,
// which is precisely what this change is about.
//
// The manifest is rewritten to record the system data path, because that is what the capture side
// actually writes for a driveless app (CaptureRecoveryUnit → GetAppDrivePath → systemDataPath).
func hotOnlyFixture(t *testing.T) (*Manager, *recordingProvider, string) {
t.Helper()
m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
sysPath := m.systemDataPath
if strings.TrimSpace(sysPath) == "" {
t.Fatal("fixture: systemDataPath must be set — the whole scenario is about falling back to it")
}
manPath := scratchManifestPath(t, m, "immich")
if err := writeManifest(manPath, &RecoveryManifest{
SchemaVersion: 2, AppName: "immich", OffsiteRunID: "20260719T060000Z",
DumpsAt: "2026-07-19T06:00:00Z",
Drive: sysPath, NamespaceRoot: NamespaceRootFor(sysPath, sysPath),
}); err != nil {
t.Fatal(err)
}
// Driveless: the app has no HDD_PATH. It is still DEPLOYED — that is the whole point.
prov.hdd = map[string]string{}
if !m.isStackDeployed("immich") {
t.Fatal("fixture: the app must be deployed — a driveless app is not an absent app")
}
return m, prov, sysPath
}
// SCENARIO A — a deployed app with NO drive restores, and lands on the system data path.
//
// WRONG OUTCOMES THIS PINS: (1) any error saying `nincs telepitve` for a running app; (2) a placement
// MISMATCH prompt, which would mean the restore resolver and the capture resolver disagree — the
// defect moving rather than closing.
func TestR356_ScenarioA_DrivelessAppRestoresToTheSystemDataPath(t *testing.T) {
m, prov, sysPath := hotOnlyFixture(t)
res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err != nil {
t.Fatalf("a deployed driveless app must restore, got refusal: %v", err)
}
// THE STORED EFFECT, not the absence of an error: the destination the run actually resolved.
if got, want := filepath.Clean(res.Placement.LiveDrive), filepath.Clean(sysPath); got != want {
t.Errorf("destination drive = %q, want the system data path %q", got, want)
}
wantNs := NamespaceRootFor(sysPath, sysPath)
if got := filepath.Clean(res.Placement.LiveNamespaceRoot); got != filepath.Clean(wantNs) {
t.Errorf("destination namespace = %q, want %q", got, wantNs)
}
if !strings.HasSuffix(filepath.Clean(wantNs), string(filepath.Separator)+"felhom-data") {
t.Errorf("the system-data fallback must append the felhom-data namespace segment, got %q", wantNs)
}
// The capture and the restore must agree. A mismatch here is the same defect at a new address.
if res.Placement.Mismatch {
t.Errorf("recorded %q vs live %q reported as a MISMATCH — capture and restore disagree",
res.Placement.Recorded.Drive, res.Placement.LiveDrive)
}
if !res.Placement.Known {
t.Error("the fixture records a drive; the run must have read it back")
}
// The run reached the app: stopped, replayed, started. A "success" that touched nothing is the
// R-354 shape wearing a new hat.
joined := strings.Join(prov.calls, ",")
if !strings.Contains(joined, "stop") || !strings.Contains(joined, "start") {
t.Errorf("the restore must have driven the stack; calls: %v", prov.calls)
}
}
// SCENARIO B — a deployed app WITH a drive is byte-for-byte unchanged.
//
// WRONG OUTCOME THIS PINS: the system-data fallback engaging for a drive app under any condition —
// that would restore a drive app's data onto the SSD, silently, which is the hazard the capture-side
// comment warns about.
func TestR356_ScenarioB_DriveAppDestinationIsStillItsDrive(t *testing.T) {
m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
drive := prov.GetStackHDDPath("immich")
if drive == "" {
t.Fatal("fixture: the drive app must have a drive")
}
manPath := scratchManifestPath(t, m, "immich")
if err := writeManifest(manPath, &RecoveryManifest{
SchemaVersion: 2, AppName: "immich", OffsiteRunID: "20260719T060000Z",
DumpsAt: "2026-07-19T06:00:00Z", Drive: drive, NamespaceRoot: drive,
}); err != nil {
t.Fatal(err)
}
res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err != nil {
t.Fatalf("a drive app recording its own drive must restore: %v", err)
}
if got := filepath.Clean(res.Placement.LiveDrive); got != filepath.Clean(drive) {
t.Fatalf("destination = %q, want the app's own drive %q", got, drive)
}
if got := filepath.Clean(res.Placement.LiveDrive); got == filepath.Clean(m.systemDataPath) {
t.Fatalf("a drive app resolved to the SYSTEM data path %q — silent misplacement", got)
}
if res.Placement.Mismatch {
t.Error("same drive recorded and live must not report a mismatch")
}
}
// SCENARIO B, second half — the mismatch refusal that protects a drive app still fires, and still
// needs the customer's explicit acknowledgement.
func TestR356_ScenarioB_DriveAppMismatchStillRefusesWithoutAck(t *testing.T) {
const recordedDrive = "/mnt/felhom-drives/hdd_9"
m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
live := prov.GetStackHDDPath("immich")
manPath := scratchManifestPath(t, m, "immich")
if err := writeManifest(manPath, &RecoveryManifest{
SchemaVersion: 2, AppName: "immich", Drive: recordedDrive, NamespaceRoot: recordedDrive,
}); err != nil {
t.Fatal(err)
}
callsBefore := len(prov.calls)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a drive app whose recorded drive differs must still be REFUSED without an acknowledgement")
}
for _, must := range []string{recordedDrive, live} {
if !strings.Contains(err.Error(), must) {
t.Errorf("the refusal must name %q; got: %v", must, err)
}
}
if len(prov.calls) != callsBefore {
t.Errorf("the refusal must not touch the app; calls: %v", prov.calls[callsBefore:])
}
if _, err := m.ReconstituteFromOffsite(context.Background(), "immich", true); err != nil {
t.Fatalf("an acknowledged placement change must still proceed: %v", err)
}
}
// SCENARIO C — an app that is genuinely NOT installed is still refused, with the R-351 sentence and
// the recorded drive.
//
// WRONG OUTCOME THIS PINS: proceeding to a destination for an app that does not exist, placing data
// with nothing to read it and no way for the customer to see that happened.
func TestR356_ScenarioC_UndeployedAppIsStillRefused(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)
}
prov.deployed = map[string]bool{} // genuinely absent
callsBefore := len(prov.calls)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("an app that is not installed must be refused")
}
if !strings.Contains(err.Error(), "nincs telep") {
t.Errorf("the not-installed refusal must keep its sentence; got: %v", err)
}
if !strings.Contains(err.Error(), recordedDrive) {
t.Errorf("the refusal must name the recorded drive; got: %v", err)
}
if len(prov.calls) != callsBefore {
t.Errorf("a refused restore must not touch the app; calls: %v", prov.calls[callsBefore:])
}
}
// SCENARIO C, control — the predicate must not be satisfied by an app of a merely SIMILAR name, and
// an empty name is never deployed.
func TestR356_IsStackDeployedIsExactAndFailsClosed(t *testing.T) {
m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
if !m.isStackDeployed("immich") { // POSITIVE control
t.Error("a deployed app must be reported deployed")
}
if m.isStackDeployed("immich-2") { // NEGATIVE control
t.Error("an app that is not in the deployed set must not be reported deployed")
}
if m.isStackDeployed("") {
t.Error("an empty stack name must never be reported deployed")
}
prov.deployed = map[string]bool{}
if m.isStackDeployed("immich") {
t.Error("clearing the deployed set must be visible to the predicate")
}
// Nil provider ⇒ we cannot tell ⇒ FALSE. The caller's next act is a WRITE; "cannot tell" must
// fail into the recoverable refusal, never into a copy.
bare := &Manager{}
if bare.isStackDeployed("immich") {
t.Error("a Manager with no stack provider must fail CLOSED")
}
}
// SCENARIO D — deployed, driveless, and the box cannot name its own data root. Fails closed with a
// DIFFERENT sentence: the app is installed, so telling the customer to reinstall it would hide the
// real fault.
func TestR356_ScenarioD_NoDataRootRefusesWithItsOwnReason(t *testing.T) {
m, _, _ := hotOnlyFixture(t)
m.systemDataPath = "" // the box cannot resolve its own data root
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("an unresolvable destination must be REFUSED, never guessed")
}
if strings.Contains(err.Error(), "nincs telep") {
t.Errorf("a running app must not be told it is not installed; got: %v", err)
}
// The reason AND a route. „Tarhely" is the ASCII stem of „Tárhely" — accented bytes stay out of
// the comparison.
if !strings.Contains(err.Error(), "rhely") {
t.Errorf("the refusal must name a route the customer can take; got: %v", err)
}
}
// SCENARIO E — PlaceOffsiteRestore is a SEPARATE wizard intent with its own copy of the same
// refusal. Fixing one entry point and leaving the other is the wrong outcome: the customer reaches
// both from the same page.
func TestR356_ScenarioE_PlaceDrivelessAppMergesOntoTheSystemNamespace(t *testing.T) {
m, prov, scratch, copies := placeFixture(t, true)
drive := prov.hdd["immich"]
prov.hdd["immich"] = "" // driveless, still deployed
// The scratch was laid out against the drive namespace, which is where offboxRestoreScratchDir
// still resolves (the drive stays a registered storage path). Only the DESTINATION moves.
if _, err := os.Stat(scratch); err != nil {
t.Fatalf("fixture: scratch must exist: %v", err)
}
var dsts []string
m.SetOffboxPlaceCopier(func(_, dst string) (int, error) { dsts = append(dsts, dst); return 1, nil })
if err := m.PlaceOffsiteRestore(context.Background(), "immich"); err != nil {
t.Fatalf("a deployed driveless app must be placeable, got: %v", err)
}
if *copies != 0 {
t.Fatalf("fixture: the counter copier was replaced, got %d", *copies)
}
if len(dsts) == 0 {
t.Fatal("nothing was placed — a merge that copies nothing is the failure this task exists to end")
}
wantNs := filepath.Clean(NamespaceRootFor(m.systemDataPath, m.systemDataPath))
for _, d := range dsts {
if !strings.HasPrefix(filepath.Clean(d), wantNs) {
t.Errorf("placement %q is not under the system namespace %q", d, wantNs)
}
if strings.HasPrefix(filepath.Clean(d), filepath.Clean(drive)+string(filepath.Separator)) {
t.Errorf("placement %q landed on the old drive — the destination did not move", d)
}
}
}
// SCENARIO E — the drive app's placement destination is unchanged.
func TestR356_ScenarioE_PlaceDriveAppStillTargetsItsDrive(t *testing.T) {
m, prov, _, copies := placeFixture(t, true)
drive := prov.hdd["immich"]
var dsts []string
m.SetOffboxPlaceCopier(func(_, dst string) (int, error) { dsts = append(dsts, dst); return 1, nil })
if err := m.PlaceOffsiteRestore(context.Background(), "immich"); err != nil {
t.Fatalf("a drive app must still be placeable: %v", err)
}
_ = copies
if len(dsts) == 0 {
t.Fatal("the drive app placed nothing")
}
for _, d := range dsts {
if !strings.HasPrefix(filepath.Clean(d), filepath.Clean(drive)) {
t.Errorf("placement %q left the app's own drive %q", d, drive)
}
}
}
// SCENARIO E — the not-installed refusal on the place path, unchanged.
func TestR356_ScenarioE_PlaceUndeployedStillRefused(t *testing.T) {
m, prov, _, copies := placeFixture(t, true)
prov.deployed = map[string]bool{}
prov.hdd["immich"] = ""
err := m.PlaceOffsiteRestore(context.Background(), "immich")
if err == nil || !strings.Contains(err.Error(), "nincs telep") {
t.Fatalf("an undeployed app must still refuse with its own sentence, got %v", err)
}
if *copies != 0 {
t.Errorf("the copier must not run for an undeployed app, got %d", *copies)
}
}
// SCENARIO D on the place path — installed but no resolvable data root.
func TestR356_ScenarioD_PlaceNoDataRootRefusesWithItsOwnReason(t *testing.T) {
m, prov, _, copies := placeFixture(t, true)
prov.hdd["immich"] = ""
m.systemDataPath = ""
err := m.PlaceOffsiteRestore(context.Background(), "immich")
if err == nil {
t.Fatal("an unresolvable destination must refuse")
}
if strings.Contains(err.Error(), "nincs telep") {
t.Errorf("a running app must not be told it is not installed; got: %v", err)
}
if *copies != 0 {
t.Errorf("nothing may be copied when the destination is unknown, got %d", *copies)
}
}