b5d78d1e0f
The systemic complaint, twice in one evening: you press a button and nothing happens. No progress, no ETA, no named result. Three worst offenders, fixed on the two patterns already here (deploy 3-step panel, storage-init status poll). No new framework — that is a ROADMAP item; three targeted cards ship tonight. 4a — a verification restore names its result. The flash said the app had been restored "to a verification folder on the drive"; which folder, on which drive, was invisible, so the customer could not go and look at what they had just asked for. Full path now. The restore page gained a listing of existing verification copies (app, size, date, path) — nothing anywhere showed these, so they piled up and the only way to find them was SSH — each with a double-confirmed delete. That delete is the only one this release adds, so it names a STACK, never a path: the Manager resolves the name inside a backups/offsite-restore root it computed itself and refuses anything landing outside. Red-proofed — neutralise the name guard and stack:"" resolves to the offsite-restore ROOT and takes every copy with it. Refusals are asserted as non-effects. 4b — Megosztás enable shows what it is waiting for. Enabling ran ReconcileSamba synchronously inside the POST handler; on a golden without felhom-samba baked that is compose pulling ~100MB, i.e. minutes of an apparently-hung form post followed by "Beállítás mentve." whether or not anything came up. Detached + polled now, distinguishing "képfájl letöltése" from "indítás" — decided BEFORE the work starts, since afterwards the image is always present. Success is probed, not inferred (compose up -d exits 0 on a crash-loop). The password form starts the same job: with UserSet false reconcile deploys nothing, so on a fresh box that is where the pull actually happens. 4c — "Távoli mentés most" streams real progress. restic was already reporting bytes and percent; the runner seam used CombinedOutput() and discarded them. The manual run now passes --json and scans stdout line-by-line: total bytes, percent, current app. Manual only — the nightly stays silent, pinned by a test that fails if it ever passes --json. The poll now arms unconditionally, closing a race the manual trigger always ran: the redirect rendered before the goroutine wrote LastStatus=running, so the poll never armed and the page sat static during the very run just started. Red-proofed twice. Also closes the golden/controller infra-image drift at the source: infra.Images() derives from the existing pins and --print-infra-images exposes it, so the golden bake can stop carrying its own copy. That copy had already drifted — felhom-samba was never added, so the golden baked 3 of 4, which is why enabling Megosztás pulled at runtime in the first place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
182 lines
6.6 KiB
Go
182 lines
6.6 KiB
Go
package backup
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// v0.147.0 slice 4a — the verification-copy listing/delete surface.
|
|
//
|
|
// DeleteOffsiteRestoreCopy is the ONLY delete this slice adds, so these tests are about what it must
|
|
// REFUSE as much as what it must do. Every refusal is asserted as a NON-EFFECT: the neighbouring copy
|
|
// and the customer's live data must still be on disk afterwards. A guard that returns an error but
|
|
// deletes anyway passes a naive test and loses data.
|
|
//
|
|
// RED-PROOF (run manually, confirmed): neutralise the isSafeStackName check in
|
|
// DeleteOffsiteRestoreCopy and TestDeleteVerifyCopyRefusesUnsafeNames fails hard — `stack: ""`
|
|
// resolves to the offsite-restore ROOT and os.RemoveAll takes every verification copy with it. That
|
|
// is the failure this guard exists to prevent, and it is data loss, not a bad error message.
|
|
//
|
|
// The HasPrefix containment check inside DeleteOffsiteRestoreCopy could NOT be red-proofed
|
|
// independently: with isSafeStackName in front of it, no input this API accepts can reach it with an
|
|
// escaping path, so removing it leaves every test green (and the variable unused). It is deliberate
|
|
// defence-in-depth against a future caller or a refactor that loosens the name check — kept, but
|
|
// honestly labelled here as unproven-by-test rather than pretending to a red-proof it does not have.
|
|
|
|
// verifyCopyEnv wires a manager with one drive and materialised verification copies.
|
|
type verifyCopyEnv struct {
|
|
m *Manager
|
|
drive string
|
|
root string // <nsRoot>/backups/offsite-restore
|
|
}
|
|
|
|
func newVerifyCopyEnv(t *testing.T, copies ...string) *verifyCopyEnv {
|
|
t.Helper()
|
|
drive := t.TempDir()
|
|
m, _, _ := classifiedOffboxManager(t, drive)
|
|
root := m.offsiteRestoreRootFor(drive)
|
|
for _, c := range copies {
|
|
p := filepath.Join(root, c)
|
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(p, "payload.bin"), []byte("restored bytes"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return &verifyCopyEnv{m: m, drive: drive, root: root}
|
|
}
|
|
|
|
func TestListOffsiteRestoreCopiesReportsPathAndSize(t *testing.T) {
|
|
e := newVerifyCopyEnv(t, "immich", "nextcloud")
|
|
|
|
got := e.m.ListOffsiteRestoreCopies()
|
|
if len(got) != 2 {
|
|
t.Fatalf("listed %d copies, want 2: %+v", len(got), got)
|
|
}
|
|
byStack := map[string]OffsiteRestoreCopy{}
|
|
for _, c := range got {
|
|
byStack[c.Stack] = c
|
|
}
|
|
for _, name := range []string{"immich", "nextcloud"} {
|
|
c, ok := byStack[name]
|
|
if !ok {
|
|
t.Fatalf("%s missing from the listing", name)
|
|
}
|
|
// THE POINT of the listing: the customer could not previously see WHERE the copy was.
|
|
want := filepath.Join(e.root, name)
|
|
if c.Path != want {
|
|
t.Errorf("%s path = %q, want %q", name, c.Path, want)
|
|
}
|
|
if c.SizeHuman == "" {
|
|
t.Errorf("%s has no humanized size", name)
|
|
}
|
|
if c.Created.IsZero() {
|
|
t.Errorf("%s has no creation time", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListOffsiteRestoreCopiesEmptyIsNotAnError(t *testing.T) {
|
|
// No offsite-restore directory at all — the normal state on a box that never ran a verification
|
|
// restore. Must be an empty list, not a crash and not a phantom entry.
|
|
e := newVerifyCopyEnv(t)
|
|
if got := e.m.ListOffsiteRestoreCopies(); len(got) != 0 {
|
|
t.Errorf("listed %d copies on a clean box, want 0: %+v", len(got), got)
|
|
}
|
|
}
|
|
|
|
func TestDeleteVerifyCopyRemovesOnlyTheNamedOne(t *testing.T) {
|
|
e := newVerifyCopyEnv(t, "immich", "nextcloud")
|
|
// Live customer data next to the sandbox — must be untouched by any delete.
|
|
live := filepath.Join(e.drive, "immich-live")
|
|
if err := os.MkdirAll(live, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(live, "photo.jpg"), []byte("irreplaceable"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := e.m.DeleteOffsiteRestoreCopy("immich"); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(e.root, "immich")); !os.IsNotExist(err) {
|
|
t.Error("the named copy survived the delete")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(e.root, "nextcloud", "payload.bin")); err != nil {
|
|
t.Errorf("a NEIGHBOURING copy was destroyed: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(live, "photo.jpg")); err != nil {
|
|
t.Errorf("LIVE CUSTOMER DATA was destroyed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteVerifyCopyRefusesUnsafeNames(t *testing.T) {
|
|
e := newVerifyCopyEnv(t, "immich")
|
|
// Something outside the sandbox that a traversal would reach.
|
|
outside := filepath.Join(e.drive, "backups", "primary")
|
|
if err := os.MkdirAll(outside, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(outside, "unit.tar"), []byte("recovery unit"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, bad := range []string{
|
|
"../primary",
|
|
"../../..",
|
|
"..",
|
|
"immich/../../primary",
|
|
"/etc",
|
|
"",
|
|
} {
|
|
if err := e.m.DeleteOffsiteRestoreCopy(bad); err == nil {
|
|
t.Errorf("delete(%q) was ACCEPTED — it must be refused", bad)
|
|
}
|
|
// The refusal must also be a NON-EFFECT.
|
|
if _, err := os.Stat(filepath.Join(outside, "unit.tar")); err != nil {
|
|
t.Fatalf("delete(%q) destroyed data outside the sandbox: %v", bad, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(e.root, "immich", "payload.bin")); err != nil {
|
|
t.Fatalf("delete(%q) destroyed an unrelated copy: %v", bad, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeleteVerifyCopyStaysInsideTheSandbox(t *testing.T) {
|
|
e := newVerifyCopyEnv(t, "immich")
|
|
// A well-formed name that simply does not exist must be an error, never a silent success that
|
|
// could mask a path-resolution bug.
|
|
if err := e.m.DeleteOffsiteRestoreCopy("no-such-app"); err == nil {
|
|
t.Error("deleting a non-existent copy reported success")
|
|
}
|
|
// Everything under the sandbox root must resolve strictly inside it.
|
|
for _, c := range e.m.ListOffsiteRestoreCopies() {
|
|
rel, err := filepath.Rel(e.root, c.Path)
|
|
if err != nil || rel == ".." || filepath.IsAbs(rel) || len(rel) > 2 && rel[:2] == ".." {
|
|
t.Errorf("listed copy %q resolves outside %q", c.Path, e.root)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOffsiteRestoreScratchPathMatchesTheListing pins the two halves together: the path the UI names
|
|
// in the completion flash must be the same path the listing (and therefore the delete button) uses.
|
|
// If these ever diverge, the customer is told about a directory the page cannot show or remove.
|
|
func TestOffsiteRestoreScratchPathMatchesTheListing(t *testing.T) {
|
|
e := newVerifyCopyEnv(t, "immich")
|
|
named := e.m.OffsiteRestoreScratchPath("immich")
|
|
if named == "" {
|
|
t.Fatal("OffsiteRestoreScratchPath returned empty — the flash would fall back to the vague wording")
|
|
}
|
|
var listed string
|
|
for _, c := range e.m.ListOffsiteRestoreCopies() {
|
|
if c.Stack == "immich" {
|
|
listed = c.Path
|
|
}
|
|
}
|
|
if named != listed {
|
|
t.Errorf("flash names %q but the listing shows %q", named, listed)
|
|
}
|
|
}
|