package api import ( "encoding/json" "io" "log" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // snapshotsStubProvider implements backup.StackDataProvider for the F1 endpoint tests: one known // stack ("app") living on `hdd`. Everything else is inert. type snapshotsStubProvider struct{ hdd string } func (p *snapshotsStubProvider) GetStackComposePath(name string) (string, bool) { if name == "app" { return filepath.Join(p.hdd, "compose", "docker-compose.yml"), true } return "", false } func (p *snapshotsStubProvider) ListDeployedStacks() []backup.StackSummary { return nil } func (p *snapshotsStubProvider) GetStackHDDMounts(string) []string { return nil } func (p *snapshotsStubProvider) GetStackHDDPath(string) string { return p.hdd } func (p *snapshotsStubProvider) GetDockerVolumes(string) []string { return nil } func (p *snapshotsStubProvider) StopStack(string) error { return nil } func (p *snapshotsStubProvider) StartStack(string) error { return nil } func (p *snapshotsStubProvider) RefreshAndIsRunning(string) bool { return true } func (p *snapshotsStubProvider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) { return backup.RecoveryInfo{}, false } func (p *snapshotsStubProvider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) { return nil, false } func (p *snapshotsStubProvider) RecoverStackSecrets(string, []string) map[string]string { return nil } func (p *snapshotsStubProvider) RecreateStackFromUnit(string, string, map[string]string) error { return nil } // newSnapshotsRouter wires a Router with a real backup.Manager over a tempdir drive. func newSnapshotsRouter(t *testing.T) (*Router, string) { t.Helper() drive := filepath.Join(t.TempDir(), "drive") cfg := &config.Config{} cfg.Paths.SystemDataPath = filepath.Join(t.TempDir(), "sys") mgr := backup.NewManager(cfg, nil, log.New(io.Discard, "", 0)) mgr.SetStackProvider(&snapshotsStubProvider{hdd: drive}) return &Router{cfg: cfg, backupMgr: mgr, logger: log.New(io.Discard, "", 0)}, drive } // getSnapshots dispatches through Router.ServeHTTP so the tests also prove the ROUTE is // registered — the F1 bug was precisely a fetch to a route that did not exist. func getSnapshots(t *testing.T, r *Router, rawQuery string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/backup/snapshots?"+rawQuery, nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) return rec } type snapshotsResp struct { OK bool `json:"ok"` Error string `json:"error"` Data []struct { Time string `json:"time"` ShortID string `json:"short_id"` Tier int `json:"tier"` DriveLabel string `json:"drive_label"` } `json:"data"` } func decodeSnapshots(t *testing.T, rec *httptest.ResponseRecorder) snapshotsResp { t.Helper() var v snapshotsResp if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { t.Fatalf("decode %q: %v", rec.Body.String(), err) } return v } // Scenario A (data half): a recovery unit on disk → 200 with EXACTLY ONE tier-1 "helyi" entry the // restore panel JS can render and POST back. func TestBackupSnapshots_UnitOnDisk(t *testing.T) { r, drive := newSnapshotsRouter(t) manifest := backup.RecoveryUnitManifestPath(drive, "app") if err := os.MkdirAll(filepath.Dir(manifest), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(manifest, []byte("{}"), 0644); err != nil { t.Fatal(err) } rec := getSnapshots(t, r, "stack=app") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } v := decodeSnapshots(t, rec) if !v.OK || len(v.Data) != 1 { t.Fatalf("want ok with exactly 1 entry, got %+v", v) } if v.Data[0].Tier != 1 || v.Data[0].ShortID != "helyi" || v.Data[0].Time == "" { t.Errorf("entry = %+v (want tier 1, short_id helyi, non-empty time)", v.Data[0]) } } // Scenario B: known stack, no recovery unit yet → ok:true with an EMPTY data list (the JS shows // "Nincs elérhető mentés" and keeps the button disabled — reached honestly, not via a 404). func TestBackupSnapshots_NoBackupYet(t *testing.T) { r, _ := newSnapshotsRouter(t) rec := getSnapshots(t, r, "stack=app") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } v := decodeSnapshots(t, rec) if !v.OK || len(v.Data) != 0 { t.Errorf("want ok with empty list, got %+v", v) } } // Scenario C: guards — traversal and empty names are 400 BEFORE any filesystem work; an unknown // stack is 404; a Router without a backup manager is 400. func TestBackupSnapshots_Guards(t *testing.T) { r, _ := newSnapshotsRouter(t) for _, q := range []string{"stack=../../etc", "stack=", "stack=a/b", "stack=.."} { rec := getSnapshots(t, r, q) if rec.Code != http.StatusBadRequest { t.Errorf("%q: status = %d, want 400; body=%s", q, rec.Code, rec.Body.String()) } } rec := getSnapshots(t, r, "stack=ghost") if rec.Code != http.StatusNotFound { t.Errorf("unknown stack: status = %d, want 404; body=%s", rec.Code, rec.Body.String()) } noMgr := &Router{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0)} rec = getSnapshots(t, noMgr, "stack=app") if rec.Code != http.StatusBadRequest { t.Errorf("nil backupMgr: status = %d, want 400", rec.Code) } }