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) } }