4c8f0d2919
gates / gates (push) Successful in 12s
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').
104 lines
4.8 KiB
Go
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")
|
|
}
|
|
}
|