Files
felhom-controller/controller/internal/web/async_restore_test.go
T
admin c529a455af feat(backup): async restore family — no proxy-timeout error page on a succeeding restore (v0.102.0)
Re-adjudicates F4: /backup/restore, /backup/tier2/restore, /backup/offbox/restore
blocked the HTTP request until completion, so through cloudflared's 100s cap a
customer got an error page while the restore succeeded (offbox worse — bounded
on r.Context(), canceling the SFTP restore mid-flight). Convert all three to the
offboxRun async shape: fast-path IsRunning refuse, background goroutine
(offbox ctx off r.Context() -> Background+30m), instant redirect. Add mutex-
guarded op-status (opstatus.go) + GET /api/backup/restore-status + a 3s-polling
backups.html banner (neutral running, red on failure). Restore single-flight
unchanged. Tests + red-proof (sync handler blocks indefinitely vs <500ms async).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-06 20:23:49 +02:00

158 lines
6.0 KiB
Go

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) 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 Tier-2 copy dir so RestoreTier2Files proceeds to StopStack (where we block).
if err := os.MkdirAll(filepath.Join(dest, "backups", "secondary", "app", "appdata"), 0o755); 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)
}
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")
}