Files
MrAlders0n d733975165 fix(backup): abort exports on shutdown and sweep stale staging dirs
srv.Shutdown never cancelled the request context, so a SIGTERM mid-export left the plaintext dump and pg_service.conf in TMPDIR. The handler now derives its context from the app context and the router removes staging dirs older than 20 minutes at startup.
2026-09-30 10:31:08 -04:00

159 lines
5.2 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/backup"
)
func TestBackupDownload(t *testing.T) {
opts := backup.Options{ConnectionService: "synthetic-private-service", MaxBytes: 1024, Timeout: time.Second, ConfigPath: "saved.yaml"}
var output string
export := func(ctx context.Context, got backup.Options) error {
if got.ConfigPath != opts.ConfigPath || got.ConnectionService != opts.ConnectionService || got.MaxBytes != opts.MaxBytes || got.Timeout != opts.Timeout {
t.Fatal("startup options changed")
}
output = got.OutputPath
return os.WriteFile(output, []byte("completed fixture"), 0600)
}
handler := backupDownload(opts, export, context.Background())
for _, query := range []string{"?database=other", "?path=/private", "?command=anything"} {
w := httptest.NewRecorder()
handler(w, httptest.NewRequest("GET", "/backup"+query, nil))
if w.Code != 400 || output != "" {
t.Fatal("caller-selected arguments accepted")
}
}
w := httptest.NewRecorder()
handler(w, httptest.NewRequest("GET", "/backup", strings.NewReader("body")))
if w.Code != 400 || output != "" {
t.Fatal("request body accepted")
}
w = httptest.NewRecorder()
handler(w, httptest.NewRequest("GET", "/backup", nil))
if w.Code != 200 || w.Body.String() != "completed fixture" || w.Header().Get("Content-Type") != "application/gzip" ||
w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("Content-Length") != "17" || w.Header().Get("Content-Disposition") != `attachment; filename="beacon-backup.tar.gz"` {
t.Fatalf("unexpected download: %d %v", w.Code, w.Header())
}
if _, err := os.Stat(filepath.Dir(output)); !os.IsNotExist(err) {
t.Fatal("download staging retained")
}
for _, failure := range []error{errors.New("PRIVATE_CANARY"), context.DeadlineExceeded, backup.ErrTooLarge} {
handler = backupDownload(opts, func(_ context.Context, got backup.Options) error {
output = got.OutputPath
return failure
}, context.Background())
w = httptest.NewRecorder()
handler(w, httptest.NewRequest("GET", "/backup", nil))
want := 500
if errors.Is(failure, context.DeadlineExceeded) {
want = 504
}
if errors.Is(failure, backup.ErrTooLarge) {
want = 507
}
if w.Code != want || strings.Contains(w.Body.String(), "PRIVATE_CANARY") || w.Header().Get("Content-Disposition") != "" {
t.Fatal("failure leaked diagnostics or published a download")
}
if _, err := os.Stat(filepath.Dir(output)); !os.IsNotExist(err) {
t.Fatal("failed staging retained")
}
}
w = httptest.NewRecorder()
BackupRouter(backup.Options{}, context.Background()).ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != 503 {
t.Fatal("unconfigured download enabled")
}
}
func TestBackupDownloadConcurrencyAndCancellation(t *testing.T) {
started, finished := make(chan struct{}), make(chan struct{})
var output string
handler := backupDownload(backup.Options{ConnectionService: "fixture"}, func(ctx context.Context, opts backup.Options) error {
output = opts.OutputPath
close(started)
<-ctx.Done()
return ctx.Err()
}, context.Background())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
defer close(finished)
handler(httptest.NewRecorder(), httptest.NewRequest("GET", "/backup", nil).WithContext(ctx))
}()
<-started
w := httptest.NewRecorder()
handler(w, httptest.NewRequest("GET", "/backup", nil))
if w.Code != http.StatusConflict {
t.Fatal("concurrent export started")
}
cancel()
<-finished
if _, err := os.Stat(filepath.Dir(output)); !os.IsNotExist(err) {
t.Fatal("cancelled staging retained")
}
}
func TestBackupDownloadAbortsOnShutdown(t *testing.T) {
shutdown, stop := context.WithCancel(context.Background())
stagedCh := make(chan string, 1)
handler := backupDownload(backup.Options{ConnectionService: "fixture"}, func(ctx context.Context, opts backup.Options) error {
stagedCh <- filepath.Dir(opts.OutputPath)
<-ctx.Done()
return ctx.Err()
}, shutdown)
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
handler.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
close(done)
}()
staged := <-stagedCh
stop()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("export did not stop on shutdown")
}
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status %d, want 503", rec.Code)
}
if _, err := os.Stat(staged); !os.IsNotExist(err) {
t.Fatalf("staging dir survived shutdown: %v", err)
}
}
func TestBackupRouterSweepsStaleStaging(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
stale, err := os.MkdirTemp("", "beacon-download-")
if err != nil {
t.Fatal(err)
}
old := time.Now().Add(-3 * backup.DefaultTimeout)
if err := os.Chtimes(stale, old, old); err != nil {
t.Fatal(err)
}
fresh, err := os.MkdirTemp("", "beacon-download-")
if err != nil {
t.Fatal(err)
}
BackupRouter(backup.Options{}, context.Background())
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Fatal("stale staging dir not swept")
}
if _, err := os.Stat(fresh); err != nil {
t.Fatal("fresh staging dir swept")
}
}