Files
felhom-controller/controller/internal/backup/shares_restore_test.go
T
admin 900c870212 feat(shares): R-7b Parts 4-6 — shares restore, samba liveness, UI truth-up
Part 4 — restore: RestoreSharesScratch + PlaceSharesRestore as SIBLINGS of the
per-app scratch/place pair. Files merged missing-only (never overwriting), each
destination PREFIX-ASSERTED against registered LIVE storage roots; definitions
merged with existing-wins; ReconcileSamba via a seam (backup must not import
stacks); credential restored best-effort into the samba named volume.
New routes POST /backup/shares/{restore,place} + a restore-page entry that renders
'Megosztasok', never the raw reserved key.
Also adds scratchJoin: reconstructing an absolute captured path under a scratch
must strip the volume name rather than rely on filepath.Join.

Part 5 — liveness: EffectiveProtected gains a settings-backed dynamic extra so the
samba CONTAINER (not the stack name — they differ) is watched exactly while sharing
is on. FINDING: the issue -> health 'fail' -> existing health_critical event ->
alert -> Hungarian degradation e-mail path needs NO further change, and introduces
no new event type, so the allowlist gotcha does not apply.

Part 6 — UI: per-tier backup status lines on the Megosztas page (amber only on
deviation). Verified the two warning-prose sites (offbox_capture/tier2_capture)
only ever receive per-app stack names, so no mapping is needed there.

RED-PROOFS RUN AND REVERTED (both fired):
  4. prefix-assert removed        -> place-guard traversal test FAILS
  5. dynamic samba extra removed  -> Scenario E enabled-case FAILS
2026-07-18 13:02:41 +02:00

269 lines
9.9 KiB
Go

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