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/ 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//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 → /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 /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/. 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") } }