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
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package backup
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRestoreOpStatus covers the async restore op-status surface (Part B): begin → running,
|
||||
// end(success/failure) → terminal record, and the deep-copy getter (mutating the returned value must
|
||||
// not corrupt the Manager's state).
|
||||
func TestRestoreOpStatus(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
// idle
|
||||
if st := m.RestoreStatus(); st.Running || st.Last != nil {
|
||||
t.Fatalf("idle status should be empty: %+v", st)
|
||||
}
|
||||
|
||||
// begin → running with op/stack
|
||||
m.BeginRestoreOp("restore", "vaultwarden")
|
||||
st := m.RestoreStatus()
|
||||
if !st.Running || st.Op != "restore" || st.Stack != "vaultwarden" {
|
||||
t.Fatalf("running status = %+v, want running restore/vaultwarden", st)
|
||||
}
|
||||
if st.StartedAt.IsZero() {
|
||||
t.Error("StartedAt not stamped")
|
||||
}
|
||||
|
||||
// end(success) → not running, terminal Last carries the message + op + stack
|
||||
m.EndRestoreOp(true, "kész")
|
||||
st = m.RestoreStatus()
|
||||
if st.Running {
|
||||
t.Error("still running after EndRestoreOp")
|
||||
}
|
||||
if st.Last == nil || !st.Last.OK || st.Last.Message != "kész" || st.Last.Op != "restore" || st.Last.Stack != "vaultwarden" {
|
||||
t.Fatalf("terminal = %+v, want ok restore/vaultwarden 'kész'", st.Last)
|
||||
}
|
||||
|
||||
// deep copy: mutating the returned Last must NOT change the Manager's stored record.
|
||||
st.Last.Message = "MUTATED"
|
||||
if again := m.RestoreStatus(); again.Last.Message != "kész" {
|
||||
t.Fatalf("RestoreStatus is not deep-copied: internal message = %q", again.Last.Message)
|
||||
}
|
||||
|
||||
// failure path → terminal Last.OK false with the error message and the new op.
|
||||
m.BeginRestoreOp("tier2-restore", "paperless")
|
||||
if !m.RestoreStatus().Running {
|
||||
t.Error("second op not running")
|
||||
}
|
||||
m.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: boom")
|
||||
st = m.RestoreStatus()
|
||||
if st.Running || st.Last.OK || st.Last.Op != "tier2-restore" || st.Last.Stack != "paperless" {
|
||||
t.Fatalf("failure terminal = %+v", st.Last)
|
||||
}
|
||||
if st.Last.Message != "Fájl-visszaállítás sikertelen: boom" {
|
||||
t.Errorf("failure message = %q", st.Last.Message)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user