Files
felhom-controller/controller/internal/backup/tier2_v2_test.go
T
admin 9f436c8a3b v0.150.0 — green gate restored + the export link stops leaking the CSRF token
F7/R-53: app_export.html built the app's public URL as '<sub>.{{$.CSRFToken}}',
so the "Megnyitás" link was wrong for every app with a subdomain and a session
CSRF token was written into a URL. Template now uses {{$.Domain}}, and
exportPageHandler supplies the key — it builds its own data map instead of
going through baseData, which is where every other page gets it. The page's
real CSRF path (csrfH() reading the meta tag) is correct and untouched.

The 7 red internal/backup tests are green again, with no behaviour change.
TestTier2V2_* / TestSharesTier2* all failed for one environmental reason:
Tier-2's off-drive guard asks system.SamePhysicalDevice (st_dev equality)
whether a target is really a second disk, and every t.TempDir() here shares one
filesystem — so the guard correctly refused the fixture's "two drives" and the
tests never reached their subject ("nincs másik fizikai meghajtó").

Seam in the package's existing style: a nil-defaulted Manager.samePhysicalDevice
field + sameDevice wrapper, seven call sites routed through it. Nil resolves to
system.SamePhysicalDevice, so production is byte-for-byte unchanged; only the two
fixtures inject a fake modelling one drive per directory subtree. No assertion
weakened, nothing skipped/renamed/deleted; all 7 mutation-proved.

Also: the ssh->pct-exec ASCII-grep and heredoc-credential traps are now in
CLAUDE.md's live-validation section.
2026-07-20 09:42:24 +02:00

345 lines
16 KiB
Go

package backup
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// t2v2Provider is a configurable StackDataProvider for the v2 tier-2 tests (per-stack hdd/mounts/binds).
type t2v2Provider struct {
hdd string
mounts map[string][]string
binds map[string][]ClassifiedBind
has map[string]bool
}
func (p *t2v2Provider) GetStackComposePath(string) (string, bool) { return "", false }
func (p *t2v2Provider) ListDeployedStacks() []StackSummary { return nil }
func (p *t2v2Provider) GetStackHDDMounts(n string) []string { return p.mounts[n] }
func (p *t2v2Provider) GetStackHDDPath(string) string { return p.hdd }
func (p *t2v2Provider) GetDockerVolumes(string) []string { return nil }
func (p *t2v2Provider) StopStack(string) error { return nil }
func (p *t2v2Provider) StartStack(string) error { return nil }
func (p *t2v2Provider) RefreshAndIsRunning(string) bool { return true }
func (p *t2v2Provider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) { return RecoveryInfo{}, false }
func (p *t2v2Provider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (p *t2v2Provider) RecreateStackFromUnit(string, string, map[string]string) error { return nil }
func (p *t2v2Provider) GetStackClassifiedBinds(n string) ([]ClassifiedBind, bool) {
return p.binds[n], p.has[n]
}
// newTier2V2 builds a Manager with a source drive (holding a recovery unit) + a registered off-drive
// target, a real (copyTree) mirror seam so the v2 dest tree forms on disk, and the SSD headroom seam
// forced to "fits" so the state-only branch is testable on any platform.
func newTier2V2(t *testing.T, stack string) (m *Manager, src, target string, prov *t2v2Provider) {
t.Helper()
tmp := t.TempDir()
src = filepath.Join(tmp, "usb")
target = filepath.Join(tmp, "flash")
sys := filepath.Join(tmp, "sys")
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := sett.AddStoragePath(settings.StoragePath{Path: target, Label: "flash", Schedulable: true}); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(RecoveryUnitPath(src, stack), "manifest.json"), "{}")
prov = &t2v2Provider{hdd: src, mounts: map[string][]string{}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}}
cfg := &config.Config{}
cfg.Paths.SystemDataPath = sys
m = NewManager(cfg, sett, log.New(io.Discard, "", 0))
m.stackProvider = prov
m.systemDataPath = sys
m.tier2Mirror = copyTree
m.tier2SSDFits = func(string, int64) bool { return true }
m.samePhysicalDevice = oneDrivePerSubtree // src/target/sys are separate "drives" on one filesystem
return m, src, target, prov
}
func mHDD(rel string) ClassifiedBind {
return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassMandatory}
}
func oUD(rel string) ClassifiedBind {
return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel, ReadOnly: true}, Class: appbackup.ClassOptional}
}
func xHDD(rel string) ClassifiedBind {
return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassExcluded}
}
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
// A — classified paperless: v2 tree, export ABSENT (copy shrinks), marker written.
func TestTier2V2_ClassifiedPaperless(t *testing.T) {
m, src, target, prov := newTier2V2(t, "paperless-ngx")
prov.has["paperless-ngx"] = true
prov.binds["paperless-ngx"] = []ClassifiedBind{mHDD("appdata/paperless/media"), xHDD("appdata/paperless/export"), xHDD("import/paperless")}
// mounts point at the whole appdata/paperless dir — the LEGACY resolver set (captured only if a bug
// routes a classified app through it; the A red-proof). The classified path ignores mounts.
prov.mounts["paperless-ngx"] = []string{filepath.Join(src, "appdata", "paperless", "media"), filepath.Join(src, "appdata", "paperless", "export")}
mustWrite(t, filepath.Join(src, "appdata", "paperless", "media", "doc.pdf"), "PDF")
mustWrite(t, filepath.Join(src, "appdata", "paperless", "export", "junk.zip"), "ZIP")
if err := m.RunTier2("paperless-ngx"); err != nil {
t.Fatalf("RunTier2: %v", err)
}
destBase := filepath.Join(target, "backups", "secondary", "paperless-ngx")
if got, _ := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker)); string(got) != "2" {
t.Errorf("marker = %q, want 2", got)
}
if !exists(filepath.Join(destBase, "hdd", "appdata", "paperless", "media", "doc.pdf")) {
t.Error("mandatory media leg missing from v2 dest")
}
if exists(filepath.Join(destBase, "hdd", "appdata", "paperless", "export")) {
t.Error("excluded export must NOT appear in the tier-2 copy (legacy set leaking)")
}
if !exists(filepath.Join(destBase, "recovery-unit", "manifest.json")) {
t.Error("recovery-unit leg missing")
}
}
// B — legacy: resolver dirs as legs, N>1 works (no refusal).
func TestTier2V2_LegacyMultiDir(t *testing.T) {
m, src, target, prov := newTier2V2(t, "twodir")
prov.has["twodir"] = false
prov.mounts["twodir"] = []string{filepath.Join(src, "appdata", "alpha", "x"), filepath.Join(src, "appdata", "beta", "y")}
mustWrite(t, filepath.Join(src, "appdata", "alpha", "a.txt"), "A")
mustWrite(t, filepath.Join(src, "appdata", "beta", "b.txt"), "B")
if err := m.RunTier2("twodir"); err != nil {
t.Fatalf("RunTier2 must SUCCEED for N>1 now (refusal lifted): %v", err)
}
destBase := filepath.Join(target, "backups", "secondary", "twodir")
if !exists(filepath.Join(destBase, "hdd", "appdata", "alpha", "a.txt")) || !exists(filepath.Join(destBase, "hdd", "appdata", "beta", "b.txt")) {
t.Error("both legacy appdata dirs must be mirrored as separate v2 legs")
}
if cd := m.settings.GetCrossDriveConfig("twodir"); cd == nil || cd.LastStatus != "ok" {
t.Errorf("want recorded ok, got %+v", cd)
}
}
// C — migration = rebuild, marker LAST; a leg failure leaves NO marker.
func TestTier2V2_MigrationAndMarkerLast(t *testing.T) {
m, src, target, prov := newTier2V2(t, "app")
prov.has["app"] = true
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/data")}
mustWrite(t, filepath.Join(src, "appdata", "app", "data", "f"), "F")
destBase := filepath.Join(target, "backups", "secondary", "app")
// Pre-v2 dest: recovery-unit + old flat appdata, NO marker.
mustWrite(t, filepath.Join(destBase, "recovery-unit", "old.json"), "{}")
mustWrite(t, filepath.Join(destBase, "appdata", "stale.bin"), "STALE")
if err := m.RunTier2("app"); err != nil {
t.Fatalf("RunTier2: %v", err)
}
if exists(filepath.Join(destBase, "appdata")) {
t.Error("old flat appdata/ must be removed on migration")
}
if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "data", "f")) {
t.Error("v2 legs must be mirrored")
}
if got, _ := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker)); string(got) != "2" {
t.Errorf("marker not written LAST: %q", got)
}
// Marker-LAST on failure: a fresh app whose leg mirror fails must leave NO marker.
m2, src2, target2, prov2 := newTier2V2(t, "boom")
prov2.has["boom"] = true
prov2.binds["boom"] = []ClassifiedBind{mHDD("appdata/boom/x")}
mustWrite(t, filepath.Join(src2, "appdata", "boom", "x", "f"), "F")
m2.tier2Mirror = func(s, d string) error {
if strings.Contains(d, "boom") && strings.Contains(d, filepath.Join("hdd", "appdata")) {
return os.ErrPermission // fail the LEG (not the unit)
}
return copyTree(s, d)
}
if err := m2.RunTier2("boom"); err == nil {
t.Fatal("a leg mirror failure must surface as an error")
}
if exists(filepath.Join(target2, "backups", "secondary", "boom", tier2LayoutMarker)) {
t.Error("marker must NOT be written when a leg fails (self-heal next run)")
}
}
// D — reconcile: stale dir removed, ancestor/descendant kept; + the safe-remove boundary proof.
func TestTier2V2_Reconcile(t *testing.T) {
m, src, target, prov := newTier2V2(t, "app")
prov.has["app"] = true
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/media")}
mustWrite(t, filepath.Join(src, "appdata", "app", "media", "keep.jpg"), "J")
destBase := filepath.Join(target, "backups", "secondary", "app")
// Seed the dest with the marker + stale dirs a prior run left.
mustWrite(t, filepath.Join(destBase, tier2LayoutMarker), "2")
mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "app", "media", "covers", "c.jpg"), "C") // descendant of leg → keep
mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "app", "export", "e.bin"), "E") // sibling of leg → stale
mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "other", "o.bin"), "O") // unrelated → stale
if err := m.RunTier2("app"); err != nil {
t.Fatalf("RunTier2: %v", err)
}
if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "media", "covers", "c.jpg")) {
t.Error("descendant content under a leg must be kept")
}
if exists(filepath.Join(destBase, "hdd", "appdata", "app", "export")) {
t.Error("stale sibling dir (re-classed/removed bind) must be reconciled away")
}
if exists(filepath.Join(destBase, "hdd", "appdata", "other")) {
t.Error("unrelated stale dir must be reconciled away")
}
// Boundary proof: tier2SafeRemove refuses a target outside backups/secondary/.
if err := tier2SafeRemove(destBase, filepath.Join(target, "live-appdata")); err == nil {
t.Error("tier2SafeRemove must REFUSE a target outside destBase")
}
if err := tier2SafeRemove("/some/other/root", filepath.Join("/some/other/root", "x")); err == nil {
t.Error("tier2SafeRemove must REFUSE a destBase not under backups/secondary/")
}
}
// E — SSD state-only: optional legs dropped, mandatory kept, honest warning.
func TestTier2V2_SSDStateOnly(t *testing.T) {
m, src, target, prov := newTier2V2(t, "app")
// Remove the off-drive data-drive target so the auto-pick falls to the SSD (state-only).
_ = target
_ = m.settings.RemoveStoragePath(target)
prov.has["app"] = true
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/state"), oUD("media/pics")}
mustWrite(t, filepath.Join(src, "appdata", "app", "state", "db.sql"), "SQL")
mustWrite(t, filepath.Join(src, "userdata", "media", "pics", "p.jpg"), "J")
if err := m.RunTier2("app"); err != nil {
t.Fatalf("RunTier2: %v", err)
}
// The SSD dest is under the system data path (NamespaceRoot(sys, false) → sys/felhom-data).
destBase := filepath.Join(NamespaceRoot(m.systemDataPath, false), "backups", "secondary", "app")
if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "state", "db.sql")) {
t.Error("mandatory state leg must be on the SSD")
}
if exists(filepath.Join(destBase, "userdata", "media", "pics")) {
t.Error("optional leg must NOT be copied to the SSD (state-only tier)")
}
cd := m.settings.GetCrossDriveConfig("app")
if cd == nil || !strings.Contains(cd.LastWarning, "választható tartalom nem került másolásra") {
t.Errorf("state-only warning missing: %+v", cd)
}
}
// F1/F2 — network exclusion, pinned AND auto (two separate assertions).
func TestTier2V2_NetworkExclusion(t *testing.T) {
// F2 (auto): an NFS candidate registered BEFORE a real local drive → the NFS is skipped and the
// local drive wins (NFS-first ordering makes this a genuine red-proof of the auto IsNetwork skip).
t.Run("auto skips network, picks local", func(t *testing.T) {
m, _, fixtureTarget, _ := newTier2V2(t, "app")
_ = m.settings.RemoveStoragePath(fixtureTarget) // drop the fixture drive, re-add after the NAS
nas := "/mnt/nas"
if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil {
t.Fatal(err)
}
local := t.TempDir()
if err := m.settings.AddStoragePath(settings.StoragePath{Path: local, Label: "local", Schedulable: true}); err != nil {
t.Fatal(err)
}
target, err := m.selectTier2Target("app", 1<<20, 1<<20)
if err != nil {
t.Fatalf("selectTier2Target: %v", err)
}
if strings.Contains(target.NamespaceRoot, "nas") {
t.Errorf("the NFS path (iterated first) must be skipped, got %q", target.NamespaceRoot)
}
if target.NamespaceRoot != filepath.FromSlash(local) {
t.Errorf("want the local drive %q, got %q", local, target.NamespaceRoot)
}
})
// F2b: NAS is the ONLY off-disk candidate → the honest network reason.
t.Run("network-only → honest reason", func(t *testing.T) {
m, _, target, _ := newTier2V2(t, "app")
_ = m.settings.RemoveStoragePath(target) // drop the local off-drive target
nas := "/mnt/nas"
if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil {
t.Fatal(err)
}
m.systemDataPath = "" // no SSD either → network is the only candidate
_, err := m.selectTier2Target("app", 1<<20, 1<<20)
if err == nil || !strings.Contains(err.Error(), "Hálózati tároló nem lehet") {
t.Fatalf("network-only must refuse with the honest reason, got %v", err)
}
})
// F1 (pinned): a pinned NFS target is invalid → falls through to auto (the local drive).
t.Run("pinned network invalid → falls through", func(t *testing.T) {
m, _, target, _ := newTier2V2(t, "app")
nas := "/mnt/nas"
if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil {
t.Fatal(err)
}
if err := m.settings.SetTier2Preference("app", false, nas); err != nil {
t.Fatal(err)
}
got, err := m.selectTier2Target("app", 1<<20, 1<<20)
if err != nil {
t.Fatalf("selectTier2Target: %v", err)
}
if got.NamespaceRoot != filepath.FromSlash(target) {
t.Errorf("pinned NFS must fall through to the local auto-pick %q, got %q", target, got.NamespaceRoot)
}
})
}
// G2 — restore refuses a pre-v2 (no marker) copy.
func TestTier2V2_RestoreRefusesOldLayout(t *testing.T) {
m, fake, _, destDrive := newT2RManager(t)
_ = fake
// Remove the marker the v2 fixture wrote → simulate an old flat copy.
if err := os.Remove(filepath.Join(destDrive, "backups", "secondary", "app", tier2LayoutMarker)); err != nil {
t.Fatal(err)
}
m.restoreFilesCopier = func(string, string) (int, error) { t.Fatal("copier must not run on an old-layout refusal"); return 0, nil }
if _, err := m.RestoreTier2Files("app"); err == nil || !strings.Contains(err.Error(), "régi formátumú") {
t.Fatalf("restore must refuse a pre-v2 copy with the marker-refusal string, got %v", err)
}
}
// H — a structurally-refused MANDATORY path is a loud gap in the app's warning.
func TestTier2V2_CaptureGapLoud(t *testing.T) {
m, src, _, prov := newTier2V2(t, "app")
prov.has["app"] = true
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/ok"), mHDD("../evil")} // traversal → Skipped
mustWrite(t, filepath.Join(src, "appdata", "app", "ok", "f"), "F")
if err := m.RunTier2("app"); err != nil {
t.Fatalf("RunTier2: %v", err)
}
cd := m.settings.GetCrossDriveConfig("app")
if cd == nil || !strings.Contains(cd.LastWarning, "nem kerültek a másodlagos mentésbe") {
t.Errorf("capture-gap warning missing from tier-2 status: %+v", cd)
}
}
// classifyTier2Rel keep/remove truth table (the reconcile core, pure).
func TestClassifyTier2Rel(t *testing.T) {
legs := []string{"hdd/appdata/app/media", "userdata/media/books"}
cases := []struct {
rel string
want tier2RelClass
}{
{"hdd/appdata/app/media", tier2KeepInside}, // exact leg
{"hdd/appdata/app/media/covers", tier2KeepInside}, // descendant
{"hdd/appdata", tier2KeepAncestor}, // ancestor
{"hdd/appdata/app", tier2KeepAncestor}, // ancestor
{"hdd/appdata/app/export", tier2Stale}, // sibling → stale
{"hdd/other", tier2Stale}, // unrelated → stale
{"userdata/media", tier2KeepAncestor}, // ancestor of userdata leg
}
for _, c := range cases {
if got := classifyTier2Rel(c.rel, legs); got != c.want {
t.Errorf("classifyTier2Rel(%q) = %d, want %d", c.rel, got, c.want)
}
}
}