package backup import ( "io" "log" "os" "path/filepath" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // newRestorePointsManager builds a Manager over a tempdir unit layout for the ListRestorePoints // tests. drive is the in-guest namespace root (≠ systemDataPath ⇒ treated as a user-data drive). func newRestorePointsManager(t *testing.T, drive string, sett *settings.Settings) *Manager { t.Helper() return &Manager{ logger: log.New(io.Discard, "", 0), settings: sett, systemDataPath: filepath.Join(t.TempDir(), "sys"), stackProvider: &fakeRecoveryProvider{hdd: drive}, } } // TestListRestorePoints_UnitOnDisk proves the F1 endpoint's data source: a recovery unit on disk // yields EXACTLY ONE tier-1 point whose Time is the NEWEST artifact mtime (here: a db-dump made // fresher than the manifest — the nightly-dump-vs-checksum-skipped-manifest case). This is the // half a hollow "returns []" handler could never pass; the companion below is the same layout // with the manifest deleted. func TestListRestorePoints_UnitOnDisk(t *testing.T) { drive := filepath.Join(t.TempDir(), "drive") mustWrite(t, RecoveryUnitManifestPath(drive, "app"), "{}") dumpPath := filepath.Join(AppDBDumpPath(drive, "app"), "app-postgres.sql") mustWrite(t, dumpPath, "dump") // Make the dump decisively newer than the manifest (mtime granularity safety). dumpTime := time.Now().Add(1 * time.Hour).Truncate(time.Second) if err := os.Chtimes(dumpPath, dumpTime, dumpTime); err != nil { t.Fatal(err) } m := newRestorePointsManager(t, drive, nil) points, found := m.ListRestorePoints("app") if !found { t.Fatal("stack should be found") } if len(points) != 1 { t.Fatalf("want exactly 1 restore point, got %d (%v)", len(points), points) } p := points[0] if p.Tier != 1 { t.Errorf("tier = %d, want 1 (tier-2 copies are not restorable via /backup/restore)", p.Tier) } if p.ShortID != "helyi" { t.Errorf("short_id = %q, want %q", p.ShortID, "helyi") } if want := dumpTime.UTC().Format(time.RFC3339); p.Time != want { t.Errorf("time = %q, want newest artifact mtime %q (the db-dump, not the older manifest)", p.Time, want) } } // COMPANION red-proof for the above: the SAME layout minus the manifest must yield an EMPTY list // (found=true — "no backup yet" is a valid answer, not an error). Together the pair kills the // hollow implementations: always-[] fails the first test, always-1-entry fails this one. func TestListRestorePoints_NoUnitYet(t *testing.T) { drive := filepath.Join(t.TempDir(), "drive") // db-dump exists but there is NO manifest — the unit is what makes an app restorable. mustWrite(t, filepath.Join(AppDBDumpPath(drive, "app"), "app-postgres.sql"), "dump") m := newRestorePointsManager(t, drive, nil) points, found := m.ListRestorePoints("app") if !found { t.Fatal("a known stack without a unit is still found") } if len(points) != 0 { t.Errorf("want empty list without a recovery unit, got %v", points) } } // TestListRestorePoints_DriveLabel proves drive_label resolution: a registered storage path's // label for drive-resident apps, and EMPTY for the SSD system-data fallback (not a bogus basename). func TestListRestorePoints_DriveLabel(t *testing.T) { sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } t.Run("registered drive → label", func(t *testing.T) { drive := filepath.Join(t.TempDir(), "usb") if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "Tárhely (usb)"}); err != nil { t.Fatal(err) } mustWrite(t, RecoveryUnitManifestPath(drive, "app"), "{}") m := newRestorePointsManager(t, drive, sett) points, _ := m.ListRestorePoints("app") if len(points) != 1 || points[0].DriveLabel != "Tárhely (usb)" { t.Errorf("drive label: %v", points) } }) t.Run("SSD fallback → clear system-drive label (F6)", func(t *testing.T) { sys := filepath.Join(t.TempDir(), "sysdata") nsRoot := NamespaceRoot(sys, false) // system path appends felhom-data mustWrite(t, RecoveryUnitManifestPath(nsRoot, "app"), "{}") m := &Manager{ logger: log.New(io.Discard, "", 0), settings: sett, systemDataPath: sys, stackProvider: &fakeRecoveryProvider{hdd: ""}, // no HDD_PATH → falls back to systemDataPath } points, found := m.ListRestorePoints("app") if !found || len(points) != 1 { t.Fatalf("points: %v found=%v", points, found) } // F6: a sys_drive app's restore point carries a clear label, never blank. if points[0].DriveLabel != systemDriveLabel { t.Errorf("SSD fallback drive_label = %q, want %q", points[0].DriveLabel, systemDriveLabel) } }) } // TestListRestorePoints_UnknownStack proves the found=false path (the handler's 404). func TestListRestorePoints_UnknownStack(t *testing.T) { m := &Manager{ logger: log.New(io.Discard, "", 0), systemDataPath: t.TempDir(), stackProvider: &unknownStackProvider{}, } if _, found := m.ListRestorePoints("ghost"); found { t.Error("unknown stack must report found=false") } // No provider wired at all → also not found (nothing is restorable). m.stackProvider = nil if _, found := m.ListRestorePoints("ghost"); found { t.Error("nil provider must report found=false") } } // unknownStackProvider is a fakeRecoveryProvider whose stacks never resolve. type unknownStackProvider struct{ fakeRecoveryProvider } func (u *unknownStackProvider) GetStackComposePath(string) (string, bool) { return "", false }