Files
felhom-controller/controller/internal/backup/restore_points_test.go
T
admin f413f9539d fix(api): F1 — register GET /api/backup/snapshots so the restore panel can populate
The backups.html restore panel fetched /api/backup/snapshots (a restic-era route
that no longer existed), so the snapshot dropdown never populated and the
"Visszaállítás indítása" button could never enable — customers could not restore
anything from the UI (drill finding F1, DRILL-appdata-restore-2026-07-04).

- backup.Manager.ListRestorePoints: the keep-side restore has exactly ONE restore
  point per app (the current recovery unit); time = newest artifact mtime among
  manifest/db-dumps/volume-dumps; tier always 1 (Tier-2 copies are NOT restorable
  via POST /backup/restore — never listed); drive_label from the storage registry,
  empty for the SSD fallback.
- api: /backup/snapshots route + validStackParam guard (same semantics as
  web.validStackName; traversal → 400, unknown stack → 404, no unit → ok+[]).
- Tests dispatch through Router.ServeHTTP (the bug WAS a missing route) + unit
  tests for newest-mtime/label/empty semantics. Companion red-proof: hollow
  always-[] implementation fails TestListRestorePoints_UnitOnDisk +
  TestBackupSnapshots_UnitOnDisk (verified, reverted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 11:42:39 +02:00

142 lines
5.3 KiB
Go

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 → empty label", 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)
}
if points[0].DriveLabel != "" {
t.Errorf("SSD fallback drive_label = %q, want empty", points[0].DriveLabel)
}
})
}
// 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 }