Files
felhom-controller/controller/internal/backup/r103_file_restore_untouched_test.go
admin 4c8f0d2919
gates / gates (push) Successful in 12s
R-103: the Tier-2 refusal becomes an action
An app whose Tier-2 copy holds no file legs but a full recovery-unit mirror - 45 of the 53 catalog
templates - was told to press a button on a DIFFERENT page. Since R-102 the data it is asking for is
restorable from the copy it is looking at.

New POST /backup/tier2/unit-restore and backupTier2UnitRestoreHandler: same guards, same
restoreOpBlocked() refusal (R-351b), same async shape as the file restore beside it, plus a
fail-closed pre-flight so the app is never stopped for a mirror that could not be opened. The
outcome reuses unitRestoreOutcomeMsg and adds which copy overwrote the live data.

The row offers the action where the refusal was, in a danger style, as a SEPARATE button. The two
are not merged: one adds what is missing, the other overwrites. The confirm carries that difference
in words and names the copy's date - and says so differently when that date is only an ATTEMPT
(R-101). It is built from named Go constants rather than assembled inside an HTML attribute, so a
test can assert it verbatim; fmtTimeStr now delegates to a package-level fmtRFC3339Local so the
confirm and the outcome cannot render the same date two ways.

tier2NoCoverageMsg is NARROWED to the case that remains - no legs and no openable unit - and still
names the route that works. tier2UnitNotCoveredMsg is NOT deleted: it is appended where the FILE
restore ran and is still exactly true of it.

Tests C1-C2 and D1-D6 plus four more. Red-proofs: C1 (widen CanRestore to include HasUnit -> the
unit-only cases fail), D6 (drop EndRestoreOp from the handler goroutine -> 'the restore never
published a result').
2026-08-31 11:42:03 +02:00

104 lines
4.8 KiB
Go

package backup
import (
"os"
"path/filepath"
"testing"
)
// R-103 Group C — the ADDITIVE file restore is untouched.
//
// R-102 gave the Tier-2 copy a second, DESTRUCTIVE action. The file restore beside it is proven live
// (Campaign 9 A1 and A3, 39 s and 46 s, byte-identical returns) and its promises are load-bearing:
// nothing live is overwritten and nothing is deleted. This group is the non-regression assertion, and
// it is written first-class rather than as an afterthought, because a silent widening here would turn
// „restore my deleted files" into „overwrite everything with last night's copy".
// C1 — TestR103_CanRestoreStillAnswersLegsOnly.
//
// CanRestore() gates the additive restore and must keep answering exactly one question. Widening it
// to include the unit is R-356 arriving a second time: there, ONE predicate meant both "has this app
// a drive?" and "is this app installed?", and it refused 40 running apps for months.
//
// Red-proof (recorded in REPORT.md): make CanRestore return `len(c.Legs) > 0 || c.HasUnit` → the
// unit-only case below fails.
func TestR103_CanRestoreStillAnswersLegsOnly(t *testing.T) {
cases := []struct {
name string
cov Tier2Coverage
want bool
}{
{"no legs, no unit", Tier2Coverage{}, false},
{"no legs, unit present", Tier2Coverage{HasUnit: true}, false},
{"no legs, unit RESTORABLE", Tier2Coverage{HasUnit: true, UnitRestorable: true}, false},
{"legs present", Tier2Coverage{Legs: []string{"hdd"}}, true},
{"legs and unit", Tier2Coverage{Legs: []string{"hdd", "userdata"}, HasUnit: true, UnitRestorable: true}, true},
}
for _, c := range cases {
if got := c.cov.CanRestore(); got != c.want {
t.Errorf("%s: CanRestore() = %v, want %v", c.name, got, c.want)
}
}
// And the second predicate answers its own question, independently of the first.
if (Tier2Coverage{Legs: []string{"hdd"}}).CanRestoreUnit() {
t.Error("CanRestoreUnit() went true on file legs alone — the two predicates are entangled")
}
if !(Tier2Coverage{UnitRestorable: true}).CanRestoreUnit() {
t.Error("CanRestoreUnit() went false on a restorable unit")
}
}
// C2 — TestR103_FileRestoreBehaviourUnchanged. The additive restore's COUNTS and its two
// non-destruction promises, over a copy that also holds a restorable unit — the case R-102 created
// and the one where a leak between the paths would show.
func TestR103_FileRestoreBehaviourUnchanged(t *testing.T) {
m, fake, liveDrive, destDrive := newT2RManager(t)
destBase := filepath.Join(destDrive, "backups", "secondary", "app")
// The copy holds BOTH: a file leg and a full, openable recovery unit.
mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "photos", "gone.jpg"), "RESTORED")
mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "photos", "kept.jpg"), "FROM-THE-BACKUP")
unit := tier2UnitDir(destBase)
mustWrite(t, UnitManifestFile(unit), `{"schema_version":1,"app_name":"app"}`)
mustWrite(t, filepath.Join(UnitVolumeDumpDir(unit), "vol_a.tar"), "tar")
// Live: one of the two files exists with DIFFERENT content, and a third file exists only live.
liveDir := filepath.Join(liveDrive, "appdata", "photos")
mustWrite(t, filepath.Join(liveDir, "kept.jpg"), "THE-CUSTOMERS-NEWER-EDIT")
mustWrite(t, filepath.Join(liveDir, "only-live.jpg"), "NOT-IN-THE-BACKUP")
cov, err := m.Tier2RestoreCoverage("app")
if err != nil {
t.Fatalf("coverage: %v", err)
}
if !cov.CanRestoreUnit() {
t.Fatal("fixture is wrong: the unit must be restorable, or this proves nothing")
}
n, err := m.RestoreTier2Files("app")
if err != nil {
t.Fatalf("file restore: %v", err)
}
// Two: `gone.jpg` above and `a.jpg` from the shared fixture. NOT three — `kept.jpg` exists live
// and is skipped — and NOT more, which is what a leak from the unit's volume tars would look like.
if n != 2 {
t.Errorf("filesRestored = %d, want 2 (the two MISSING files only) — the file restore's reach changed", n)
}
if got, _ := os.ReadFile(filepath.Join(liveDir, "gone.jpg")); string(got) != "RESTORED" {
t.Errorf("the missing file did not come back: %q", got)
}
if got, _ := os.ReadFile(filepath.Join(liveDir, "kept.jpg")); string(got) != "THE-CUSTOMERS-NEWER-EDIT" {
t.Errorf("an EXISTING live file was overwritten: %q — the additive promise is broken", got)
}
if _, sErr := os.Stat(filepath.Join(liveDir, "only-live.jpg")); sErr != nil {
t.Error("a live file absent from the backup was DELETED — the second non-destruction promise is broken")
}
if len(fake.stopped) != 1 || len(fake.started) != 1 {
t.Errorf("lifecycle changed: stopped=%v started=%v", fake.stopped, fake.started)
}
// The unit the file restore does not read is untouched by it.
if got, _ := os.ReadFile(filepath.Join(UnitVolumeDumpDir(unit), "vol_a.tar")); string(got) != "tar" {
t.Error("the file restore wrote into the copy's recovery unit")
}
}