package backup import ( "encoding/json" "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-7b Part 4 — shares restore. Scenario D's contract: files come back byte-identical into the LIVE // share folder, a deleted definition reappears in the registry, a definition that still exists is // left alone, and the place step never writes outside a registered live root. // seedSharesScratch lays down a completed restore scratch: the payload (manifest + credential) plus // a restored file tree for each named share, mirroring what restic's absolute-path restore produces. func seedSharesScratch(t *testing.T, env *sharesEnv, mf SharesManifest, files map[string]string) string { t.Helper() scratch, _, err := env.m.sharesRestoreScratchDir() if err != nil { t.Fatal(err) } payload := scratchJoin(scratch, env.m.sharesPayloadDir()) if err := os.MkdirAll(payload, 0o755); err != nil { t.Fatal(err) } blob := mustJSON(t, mf) if err := os.WriteFile(filepath.Join(payload, sharesManifestName), blob, 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(payload, sharesPassdbName), []byte("FAKE-PASSDB-TAR"), 0o600); err != nil { t.Fatal(err) } for livePath, content := range files { p := scratchJoin(scratch, livePath) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(content), 0o644); err != nil { t.Fatal(err) } } return scratch } func mustJSON(t *testing.T, v any) []byte { t.Helper() b, err := jsonMarshalIndent(v) if err != nil { t.Fatal(err) } return b } // wirePlaceSeams installs a real (missing-only) copier and a fake passdb restorer. func wirePlaceSeams(env *sharesEnv, passdbCalls *int) { env.m.offboxPlaceCopier = func(src, dst string) (int, error) { n := 0 err := filepath.Walk(src, func(p string, fi os.FileInfo, err error) error { if err != nil || fi.IsDir() { return err } rel, rErr := filepath.Rel(src, p) if rErr != nil { return rErr } target := filepath.Join(dst, rel) if _, sErr := os.Stat(target); sErr == nil { return nil // missing-only: never overwrite } b, rErr := os.ReadFile(p) if rErr != nil { return rErr } if mErr := os.MkdirAll(filepath.Dir(target), 0o755); mErr != nil { return mErr } n++ return os.WriteFile(target, b, 0o644) }) return n, err } env.m.sharesPassdbRestore = func([]byte) error { *passdbCalls++; return nil } } // Scenario D: the round trip. A deleted file returns byte-identical; a deleted definition reappears // and triggers the samba re-render; a definition that still exists is KEPT (a restore must never // silently flip a live share's settings). func TestSharesRestoreRoundTrip(t *testing.T) { env := newSharesEnv(t, "hdd_1", "hdd_2") keptPath := env.addShare(t, "hdd_1", "marad", true) deletedPath := filepath.Join(env.drives["hdd_1"], "torolt") mf := SharesManifest{ Version: sharesManifestVersion, ServerName: "FELHOM", Shares: []settings.SMBShare{ // Still live — its definition must be kept, not overwritten (note the flipped ReadOnly: // if the merge preferred the snapshot, this would silently change a live share). {Name: "marad", Path: keptPath, ReadOnly: true, Offsite: false, CreatedAt: "2020-01-01T00:00:00Z"}, // Deleted from the registry — must come back. {Name: "torolt", Path: deletedPath, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}, }, } seedSharesScratch(t, env, mf, map[string]string{ filepath.Join(deletedPath, "fontos.txt"): "EREDETI-TARTALOM", filepath.Join(keptPath, "marad.txt"): "SNAPSHOT-VERZIO", }) var reconciled, passdbCalls int env.m.SetSharesReconciler(func() error { reconciled++; return nil }) wirePlaceSeams(env, &passdbCalls) res, err := env.m.PlaceSharesRestore(t.Context()) if err != nil { t.Fatalf("PlaceSharesRestore: %v", err) } // 1. File back, byte-identical, in the LIVE folder. b, rErr := os.ReadFile(filepath.Join(deletedPath, "fontos.txt")) if rErr != nil || string(b) != "EREDETI-TARTALOM" { t.Errorf("restored file wrong/missing: %q, %v", b, rErr) } // 2. Missing-only: the live file must NOT be clobbered by the snapshot version. b, _ = os.ReadFile(filepath.Join(keptPath, "marad.txt")) if string(b) != "content-of-marad" { t.Errorf("a live file was overwritten by the restore: %q", b) } // 3. Deleted definition reappears; live definition kept unchanged. if !containsStr(res.DefinitionsAdded, "torolt") { t.Errorf("deleted definition did not reappear: %+v", res) } if !containsStr(res.DefinitionsKept, "marad") { t.Errorf("live definition should be reported as kept: %+v", res) } for _, sh := range env.sett.GetSMBShares() { if sh.Name == "marad" && sh.ReadOnly { t.Error("a restore silently flipped a LIVE share's ReadOnly setting") } } // 4. smb.conf re-rendered so the restored share is actually exported. if reconciled != 1 { t.Errorf("ReconcileSamba should run exactly once after adding definitions, ran %d", reconciled) } // 5. Credential restored best-effort. if passdbCalls != 1 || !res.PasswordRestored { t.Errorf("credential restore did not run: calls=%d result=%v", passdbCalls, res.PasswordRestored) } } // THE PLACE-GUARD RED-PROOF TARGET. A manifest whose share path escapes every registered live root // must be REFUSED with zero writes. Red-proof: make liveShareRootOK return true unconditionally and // this test fails (the traversal destination gets created). func TestSharesRestoreRefusesDestinationOutsideLiveRoots(t *testing.T) { env := newSharesEnv(t, "hdd_1", "hdd_2") outside := filepath.Join(env.tmp, "kivul", "gonosz") traversal := filepath.Join(env.drives["hdd_1"], "..", "eszkeipel") mf := SharesManifest{ Version: sharesManifestVersion, ServerName: "FELHOM", Shares: []settings.SMBShare{ {Name: "kivul", Path: outside, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}, {Name: "traverz", Path: traversal, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}, }, } seedSharesScratch(t, env, mf, map[string]string{ filepath.Join(outside, "x.txt"): "SHOULD-NEVER-LAND", filepath.Join(traversal, "y.txt"): "SHOULD-NEVER-LAND", }) var passdbCalls int wirePlaceSeams(env, &passdbCalls) res, err := env.m.PlaceSharesRestore(t.Context()) if err != nil { t.Fatalf("a refused destination must be reported, not error out: %v", err) } if len(res.Refused) != 2 { t.Errorf("both out-of-root destinations must be refused, got %+v", res) } if res.FilesRestored != 0 { t.Errorf("zero files must be written when every destination is refused, got %d", res.FilesRestored) } if _, sErr := os.Stat(filepath.Join(outside, "x.txt")); !os.IsNotExist(sErr) { t.Error("PLACE GUARD BREACHED — a file landed outside every registered live root") } if _, sErr := os.Stat(filepath.Join(env.tmp, "eszkeipel", "y.txt")); !os.IsNotExist(sErr) { t.Error("PLACE GUARD BREACHED — a traversal destination was written") } // The definitions must not be registered either — a share pointing outside is not restorable. if len(env.sett.GetSMBShares()) != 0 { t.Errorf("refused shares must not enter the registry: %+v", env.sett.GetSMBShares()) } } // The prefix assert's boundary cases, stated directly. func TestLiveShareRootOKBoundaries(t *testing.T) { env := newSharesEnv(t, "hdd_1") root := env.drives["hdd_1"] if !env.m.liveShareRootOK(filepath.Join(root, "dokumentumok")) { t.Error("a strict descendant of a live root must be allowed") } if env.m.liveShareRootOK(root) { t.Error("the drive root ITSELF must be refused (a share is never the whole drive)") } if env.m.liveShareRootOK(filepath.Join(root, "..", "elsewhere")) { t.Error("a `..` segment must be refused") } if env.m.liveShareRootOK("") { t.Error("an empty destination must be refused") } // A drive that is away is not a LIVE root. if err := env.sett.SetDisconnected(root, true, nil); err != nil { t.Fatal(err) } if env.m.liveShareRootOK(filepath.Join(root, "dokumentumok")) { t.Error("a disconnected drive must not count as a live root") } } // A definitions-only snapshot (the quota-degraded shape) restores the CONFIGURATION without error // even though no file tree exists — that is the whole point of the manifest-only floor. func TestSharesRestoreDefinitionsOnlySnapshot(t *testing.T) { env := newSharesEnv(t, "hdd_1", "hdd_2") sharePath := filepath.Join(env.drives["hdd_1"], "dokumentumok") mf := SharesManifest{ Version: sharesManifestVersion, ServerName: "FELHOM", Shares: []settings.SMBShare{{Name: "dokumentumok", Path: sharePath, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}}, } seedSharesScratch(t, env, mf, nil) // no file tree at all var passdbCalls int env.m.SetSharesReconciler(func() error { return nil }) wirePlaceSeams(env, &passdbCalls) res, err := env.m.PlaceSharesRestore(t.Context()) if err != nil { t.Fatalf("a definitions-only snapshot must restore cleanly: %v", err) } if !containsStr(res.DefinitionsAdded, "dokumentumok") { t.Errorf("the definition should be restored: %+v", res) } if res.FilesRestored != 0 { t.Errorf("no files exist in a definitions-only snapshot, got %d", res.FilesRestored) } } // The flash message must speak Hungarian and never leak the reserved key. func TestSharesRestoreFlashMessage(t *testing.T) { res := SharesRestoreResult{FilesRestored: 3, DefinitionsAdded: []string{"a"}, DefinitionsKept: []string{"marad"}} msg := res.FlashMessage() if strings.Contains(msg, SharesPseudoStack) { t.Errorf("the reserved key leaked into the flash message: %q", msg) } if !strings.Contains(msg, SharesDisplayName) { t.Errorf("the flash message should name %q: %q", SharesDisplayName, msg) } if !strings.Contains(msg, "A(z) marad megosztás beállítása már létezik — a meglévő maradt.") { t.Errorf("the kept-definition sentence is missing: %q", msg) } } // jsonMarshalIndent is a tiny indirection so the test file needs no direct encoding/json import // beyond this one helper. func jsonMarshalIndent(v any) ([]byte, error) { return json.MarshalIndent(v, "", " ") }