package web import ( "io" "log" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // blockProvider is a StackDataProvider whose StopStack blocks on a channel — so a Tier-2 restore // parks mid-flight while the test asserts the HANDLER already returned (async) and that a concurrent // POST is refused without launching a second restore. type blockProvider struct { hdd string release chan struct{} stops int32 // atomic: number of StopStack calls == number of restores that actually launched } func (p *blockProvider) GetStackComposePath(string) (string, bool) { return "", false } func (p *blockProvider) ListDeployedStacks() []backup.StackSummary { return nil } func (p *blockProvider) GetStackHDDMounts(string) []string { return nil } func (p *blockProvider) GetStackHDDPath(string) string { return p.hdd } func (p *blockProvider) GetDockerVolumes(string) []string { return nil } func (p *blockProvider) StopStack(string) error { atomic.AddInt32(&p.stops, 1) <-p.release // park here until the test releases it return nil } func (p *blockProvider) StartStack(string) error { return nil } func (p *blockProvider) RefreshAndIsRunning(string) bool { return true } func (p *blockProvider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) { return backup.RecoveryInfo{}, false } func (p *blockProvider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) { return nil, false } func (p *blockProvider) RecoverStackSecrets(string, []string) map[string]string { return nil } func (p *blockProvider) RecreateStackFromUnit(string, string, map[string]string) error { return nil } func newAsyncRestoreServer(t *testing.T) (*Server, *blockProvider, *backup.Manager) { t.Helper() tmp := t.TempDir() live := filepath.Join(tmp, "usb") dest := filepath.Join(tmp, "flash") lg := log.New(io.Discard, "", 0) sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) if err != nil { t.Fatal(err) } for _, p := range []string{live, dest} { if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: filepath.Base(p)}); err != nil { t.Fatal(err) } } if err := sett.SetCrossDriveConfig("app", &settings.CrossDriveBackup{ Enabled: true, Method: "rsync", DestinationPath: dest, LastRun: "2026-07-06T03:30:00Z", LastStatus: "ok", }); err != nil { t.Fatal(err) } // a recorded v2 Tier-2 copy (marker + an hdd/ leg) so RestoreTier2Files proceeds to StopStack. destBase := filepath.Join(dest, "backups", "secondary", "app") if err := os.MkdirAll(filepath.Join(destBase, "hdd", "appdata"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(destBase, ".felhom-tier2-layout"), []byte("2"), 0o644); err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.DataDir = tmp m := backup.NewManager(cfg, sett, lg) prov := &blockProvider{hdd: live, release: make(chan struct{})} m.SetStackProvider(prov) s := &Server{cfg: cfg, backupMgr: m, logger: lg} return s, prov, m } func postTier2(s *Server) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/backup/tier2/restore", strings.NewReader("stack_name=app")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.backupTier2RestoreHandler(w, req) return w } func waitFor(t *testing.T, cond func() bool, msg string) { t.Helper() // Budget past waitForHealthy's 3s post-restore settling sleep (+ copier). for i := 0; i < 700; i++ { if cond() { return } time.Sleep(10 * time.Millisecond) } t.Fatalf("timeout waiting for: %s", msg) } // B1 — the restore handler returns INSTANTLY (302) while the restore runs in the background, and the // op-status transitions running → terminal. RED-PROOF: on the pre-fix synchronous handler (calling // RestoreTier2Files inline) this POST blocks in StopStack until the channel is released, so the // sub-500ms response assertion FAILS. func TestBackupTier2Restore_AsyncReturnsInstantly(t *testing.T) { s, prov, m := newAsyncRestoreServer(t) start := time.Now() w := postTier2(s) elapsed := time.Since(start) if elapsed > 500*time.Millisecond { t.Fatalf("handler blocked %v — restore is not async (the F4 shape)", elapsed) } if w.Code != http.StatusFound { t.Fatalf("want 302, got %d", w.Code) } if loc := w.Header().Get("Location"); !strings.Contains(loc, "elindult") { t.Fatalf("redirect should carry the 'elindult' flash; got %q", loc) } // the background restore is now parked in StopStack → op-status shows running. waitFor(t, func() bool { return m.RestoreStatus().Running }, "restore op running") st := m.RestoreStatus() if st.Op != "tier2-restore" || st.Stack != "app" { t.Fatalf("running status = %+v", st) } // release → the restore completes → terminal status. close(prov.release) waitFor(t, func() bool { return !m.RestoreStatus().Running && m.RestoreStatus().Last != nil }, "restore terminal") if got := atomic.LoadInt32(&prov.stops); got != 1 { t.Fatalf("StopStack called %d times, want exactly 1", got) } } // B2 — a concurrent restore POST while one runs is REFUSED (fast-path IsRunning) and does NOT launch a // second restore. RED-PROOF: removing the IsRunning() fast-path lets the second POST launch a second // goroutine → StopStack count becomes 2. func TestBackupTier2Restore_DoubleClickRefused(t *testing.T) { s, prov, m := newAsyncRestoreServer(t) // first restore → parks in StopStack (acquires the single-flight running flag). _ = postTier2(s) waitFor(t, func() bool { return m.IsRunning() }, "first restore holding the single-flight lock") // second restore while the first runs → refused with the "már fut" flash, no second launch. w2 := postTier2(s) if loc := w2.Header().Get("Location"); !strings.Contains(loc, "m%C3%A1r+fut") && !strings.Contains(loc, "már fut") { t.Fatalf("second POST should be refused with 'már fut'; got %q", loc) } // IsRunning flips before the goroutine reaches StopStack — wait for the FIRST stop to land, so // the ==1 assertion below measures "no second launch", not goroutine scheduling (parallel-load flake). waitFor(t, func() bool { return atomic.LoadInt32(&prov.stops) >= 1 }, "first restore reached StopStack") if got := atomic.LoadInt32(&prov.stops); got != 1 { t.Fatalf("double-click launched a second restore: StopStack count = %d, want 1", got) } close(prov.release) waitFor(t, func() bool { return !m.IsRunning() }, "first restore done") }