Files
felhom-controller/controller/internal/backup/tier2_appdata_test.go
T
admin 68f0e0cf5c F-S2 + F-S3: compose-derived appdata dir resolution (v0.131.0)
The controller assumed an app's HDD appdata dir is always appdata/<stackName>.
paperless-ngx writes appdata/paperless (stack paperless-ngx), so every consumer
keying by stack name silently missed it via a stat-and-skip. One canonical
resolver appbackup.AppDataDirNames derives the real dir name(s) from the app's
compose ${HDD_PATH} binds; all consumers use it.

- F-S2 (tier-2): RunTier2 mirrors the resolved appdata/<name> (paperless docs
  got NO tier-2 copy before). Tier2Info size + RestoreTier2Files live dir use it.
  WARN when a declared appdata dir is absent. New tier2Mirror seam.
- F-S3 (migrate, NEW): all six per-app appdata legs (collision/size/copy/verify/
  cleanup/skip-set) now loop resolved names. scope="app" migration of paperless
  previously copied nothing and left an empty media dir (scope="all" was saved by
  the merge walk). WARN on missing declared dir in the copy leg.
- Multi-dir (N>1) refusal: tier-2 backup/info/restore refuse loudly (Hungarian);
  migrate supports N. No catalog app hits it today; lifted by Task 3.
- Display: storage page sums resolved dirs.
- Truth repair: the v0.130.0 "tier-2 copies the namespace wholesale" claim is
  false; corrected in CHANGELOG + main.go export-adapter comment.

+9 tests; red-proofs RP-1..RP-5 all confirmed. Controller-only, no agent/hub
coupling. Task 1 of the backup-classification-redesign arc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
2026-07-14 17:45:53 +02:00

216 lines
8.4 KiB
Go

package backup
import (
"errors"
"io"
"log"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// newRunTier2Manager builds a Manager wired to a fake provider, a real temp-dir source drive holding
// a recovery unit + an appdata/<appDataName> dir (created iff appDataName != ""), and a (non-existent)
// schedulable off-drive target so selectTier2Target resolves a real target without touching the disk
// (SamePhysicalDevice returns false for an unstattable path on both Linux and Windows). Compose
// mounts default to appdata/<appDataName>/media + /export; callers override fake.mounts for the
// legacy/multi-dir shapes. The tier2Mirror seam captures the (src,dst) of each leg.
func newRunTier2Manager(t *testing.T, stack, appDataName string) (m *Manager, src string, captured *[][2]string) {
t.Helper()
tmp := t.TempDir()
src = filepath.Join(tmp, "usb") // the source drive == HDD_PATH == namespace root (Model A)
sysPath := filepath.Join(tmp, "sys")
target := filepath.Join(tmp, "off-drive-target") // never created → treated as a different device
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: "off", Schedulable: true}); err != nil {
t.Fatal(err)
}
// A recovery unit (gates RunTier2) + the resolved appdata dir, both with real bytes.
mustWrite(t, filepath.Join(RecoveryUnitPath(src, stack), "manifest.json"), "{}")
var mounts []string
if appDataName != "" {
mustWrite(t, filepath.Join(AppDataDir(src, appDataName), "media", "a.jpg"), "JPEGDATA")
mounts = []string{
filepath.Join(src, "appdata", appDataName, "media"),
filepath.Join(src, "appdata", appDataName, "export"),
}
}
fake := &t2rFakeProvider{hdd: src, mounts: mounts}
cfg := &config.Config{}
cfg.Paths.SystemDataPath = sysPath
m = NewManager(cfg, sett, log.New(io.Discard, "", 0))
m.stackProvider = fake
m.systemDataPath = sysPath
pairs := &[][2]string{}
m.tier2Mirror = func(s, d string) error {
*pairs = append(*pairs, [2]string{s, d})
return nil
}
return m, src, pairs
}
// TestRunTier2_PaperlessShape (Scenario A / RP-2): the appdata leg is mirrored from the REAL
// compose-derived dir (appdata/paperless), and the recorded size includes its bytes. Companion
// RP-2: reverting the L146 site to AppDataDir(nsRoot, stackName) makes the appdata leg mirror a
// non-existent dir — the appdata capture below (dst ".../appdata") never fires.
func TestRunTier2_PaperlessShape(t *testing.T) {
m, srcDrive, captured := newRunTier2Manager(t, "paperless-ngx", "paperless")
if err := m.RunTier2("paperless-ngx"); err != nil {
t.Fatalf("RunTier2: %v", err)
}
// The appdata leg must mirror appdata/paperless → <destBase>/appdata.
wantSrc := AppDataDir(srcDrive, "paperless")
var appdataLeg *[2]string
for i := range *captured {
if filepath.Base((*captured)[i][1]) == "appdata" {
appdataLeg = &(*captured)[i]
}
}
if appdataLeg == nil {
t.Fatalf("appdata leg was never mirrored (F-S2 regression); captured=%v", *captured)
}
if appdataLeg[0] != wantSrc {
t.Errorf("appdata mirror src = %q, want the resolved dir %q", appdataLeg[0], wantSrc)
}
if filepath.Base(appdataLeg[1]) != "appdata" {
t.Errorf("appdata mirror dst = %q, want flat <destBase>/appdata", appdataLeg[1])
}
// Recorded success size must include the paperless bytes (non-empty, > 0).
cd := m.settings.GetCrossDriveConfig("paperless-ngx")
if cd == nil || cd.LastStatus != "ok" {
t.Fatalf("expected recorded ok status, got %+v", cd)
}
if cd.LastSizeHuman == "" || cd.LastSizeHuman == "0 B" {
t.Errorf("recorded size = %q, want it to include the appdata bytes", cd.LastSizeHuman)
}
}
// TestRunTier2_LegacyShape (Scenario B): a match-name app AND a no-binds app both mirror the exact
// same src the pre-fix code keyed by stack name — byte-identical behavior.
func TestRunTier2_LegacyShape(t *testing.T) {
t.Run("matching name (nextcloud)", func(t *testing.T) {
m, srcDrive, captured := newRunTier2Manager(t, "nextcloud", "nextcloud")
m.stackProvider.(*t2rFakeProvider).mounts = []string{filepath.Join(srcDrive, "appdata", "nextcloud")}
if err := m.RunTier2("nextcloud"); err != nil {
t.Fatalf("RunTier2: %v", err)
}
assertAppdataSrc(t, captured, AppDataDir(srcDrive, "nextcloud"))
})
t.Run("no appdata binds (fallback == stack name)", func(t *testing.T) {
// The appdata dir is created under the stack name; mounts are cleared → resolver falls back.
m, srcDrive, captured := newRunTier2Manager(t, "vaultwarden", "vaultwarden")
m.stackProvider.(*t2rFakeProvider).mounts = nil
if err := m.RunTier2("vaultwarden"); err != nil {
t.Fatalf("RunTier2: %v", err)
}
assertAppdataSrc(t, captured, AppDataDir(srcDrive, "vaultwarden"))
})
}
func assertAppdataSrc(t *testing.T, captured *[][2]string, want string) {
t.Helper()
for _, p := range *captured {
if filepath.Base(p[1]) == "appdata" {
if p[0] != want {
t.Errorf("appdata mirror src = %q, want %q", p[0], want)
}
return
}
}
t.Fatalf("appdata leg not mirrored; captured=%v", *captured)
}
// TestRunTier2_MultiDirRefusal (Scenario C / RP-5): two distinct appdata dirs → an honest no_target
// status with the EXACT Hungarian reason, an [ERROR] log, and NO mirror call. Companion RP-5:
// deleting the N>1 guard in tier2AppDataName makes the mirror fire (call-count > 0).
func TestRunTier2_MultiDirRefusal(t *testing.T) {
m, srcDrive, captured := newRunTier2Manager(t, "twodir", "alpha")
m.stackProvider.(*t2rFakeProvider).mounts = []string{
filepath.Join(srcDrive, "appdata", "alpha", "x"),
filepath.Join(srcDrive, "appdata", "beta", "y"),
}
if err := m.RunTier2("twodir"); err != nil {
t.Fatalf("RunTier2 must record a status, not error: %v", err)
}
if n := len(*captured); n != 0 {
t.Errorf("mirror was called %d time(s) on a multi-dir refusal — want 0", n)
}
cd := m.settings.GetCrossDriveConfig("twodir")
if cd == nil || cd.LastStatus != "no_target" {
t.Fatalf("expected no_target status, got %+v", cd)
}
if cd.LastError != errTier2MultiDir.Error() {
t.Errorf("reason = %q, want %q", cd.LastError, errTier2MultiDir.Error())
}
}
// TestTier2Info_MultiDirRefusal (Scenario C, info tier): the config-panel view surfaces the same
// refusal reason.
func TestTier2Info_MultiDirRefusal(t *testing.T) {
m, srcDrive, _ := newRunTier2Manager(t, "twodir", "alpha")
m.stackProvider.(*t2rFakeProvider).mounts = []string{
filepath.Join(srcDrive, "appdata", "alpha", "x"),
filepath.Join(srcDrive, "appdata", "beta", "y"),
}
info := m.Tier2Info("twodir")
if !info.NoTarget {
t.Fatal("expected NoTarget on a multi-dir app")
}
if info.NoTargetReason != errTier2MultiDir.Error() {
t.Errorf("reason = %q, want %q", info.NoTargetReason, errTier2MultiDir.Error())
}
}
// TestRestoreTier2Files_ResolvedLiveDir (Scenario E / RP-3): the copier's dst is the RESOLVED live
// dir (appdata/paperless), not appdata/<stackName>. Companion RP-3: reverting liveDir to stack-name
// keying makes dst appdata/paperless-ngx and this fails.
func TestRestoreTier2Files_ResolvedLiveDir(t *testing.T) {
m, fake, liveDrive, _ := newT2RManager(t)
fake.mounts = []string{
filepath.Join(liveDrive, "appdata", "paperless", "media"),
}
var gotDst string
m.restoreFilesCopier = func(_, dst string) (int, error) { gotDst = dst; return 0, nil }
if _, err := m.RestoreTier2Files("app"); err != nil {
t.Fatalf("restore: %v", err)
}
if want := AppDataDir(liveDrive, "paperless"); gotDst != want {
t.Errorf("restore dst = %q, want resolved live dir %q", gotDst, want)
}
}
// TestRestoreTier2Files_MultiDirRefusal (Scenario C, restore tier): two dirs → errTier2MultiDirRestore
// BEFORE the app is stopped (effect assertion: StopStack never invoked).
func TestRestoreTier2Files_MultiDirRefusal(t *testing.T) {
m, fake, liveDrive, _ := newT2RManager(t)
fake.mounts = []string{
filepath.Join(liveDrive, "appdata", "alpha", "x"),
filepath.Join(liveDrive, "appdata", "beta", "y"),
}
called := false
m.restoreFilesCopier = func(string, string) (int, error) { called = true; return 0, nil }
_, err := m.RestoreTier2Files("app")
if !errors.Is(err, errTier2MultiDirRestore) {
t.Fatalf("err = %v, want errTier2MultiDirRestore", err)
}
if len(fake.stopped) != 0 {
t.Errorf("app was STOPPED on a multi-dir refusal: %v", fake.stopped)
}
if called {
t.Error("copier invoked on a refusal")
}
}