mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-21 17:22:14 +00:00
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.
159 lines
5.2 KiB
Go
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")
|
|
}
|
|
}
|