Files
felhom-controller/controller/internal/backup/offbox_verify_copies_test.go
T
admin b5d78d1e0f v0.147.0 — feedback slice 1: pressing a button says something
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
2026-07-19 09:30:30 +02:00

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