08eb1a6e3a
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.
171 lines
6.9 KiB
Go
171 lines
6.9 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: 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 m.isStackDeployed("immich") {
|
|
t.Fatal("fixture: the app must look uninstalled")
|
|
}
|
|
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)
|
|
}
|
|
}
|