Files
beacon-server/internal/backup/export_test.go
T
MrAlders0n 041d9c1f45 release: v2.0.2
Release the changes on `dev` as v2.0.2: authenticated MeshMapper imports and the traffic-sorted observer directory, including rolling seven-day counts from available history. Bump the API version and regenerate Swagger.

MeshMapper integration requires an API key. Regional admins’ existing per-region or grouped-region coverage keys also work for these APIs. Deployments should set `MESHMAPPER_API_KEY` and restart Beacon.

Validation: `go build ./...`, `gofmt -l .` (empty), `go vet ./...`, `go test ./...`, `govulncheck ./...`, and race tests for MeshMapper and scopestore passed in a clean checkout. PostgreSQL integration tests require CI because Docker is unavailable locally.
2026-10-05 18:35:41 -04:00

264 lines
8.0 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
const testSQL = "CREATE TABLE fixture (id bigint PRIMARY KEY, name text);\nINSERT INTO fixture VALUES (1, 'café');\n"
func TestBackupDoesNotExportMeshMapperAPIKey(t *testing.T) {
for _, saved := range []string{
"meshmapper:\n api_key: backup-secret-canary\n",
"shared: &integration {api_key: backup-secret-canary}\nmeshmapper: *integration\n",
"meshmapper:\n <<: {api_key: backup-secret-canary}\n",
"meshmapper:\n api_key: [backup-secret-canary]\n",
} {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(saved), 0600); err != nil {
t.Fatal(err)
}
data, err := readConfig(path)
if err == nil || data != nil {
t.Fatal("saved MeshMapper key could enter a backup response")
}
if strings.Contains(err.Error(), "backup-secret-canary") {
t.Fatal("backup error exposed the key")
}
}
t.Setenv("MESHMAPPER_API_KEY", "environment-secret-canary")
saved := []byte("# Saved config remains verbatim.\nmeshmapper:\n api_key: ''\n")
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, saved, 0600); err != nil {
t.Fatal(err)
}
data, err := readConfig(path)
if err != nil || !bytes.Equal(data, saved) {
t.Fatal("environment key changed the saved-config export")
}
}
// TestDumpProcess supplies an actual subprocess, including failure and cancellation.
func TestDumpProcess(t *testing.T) {
mode := os.Getenv("BEACON_BACKUP_TEST_PROCESS")
if mode == "" {
return
}
switch mode {
case "ok":
_, _ = io.WriteString(os.Stdout, testSQL)
case "fail":
_, _ = io.WriteString(os.Stdout, "incomplete dump")
_, _ = io.WriteString(os.Stderr, "PRIVATE_PASSWORD_CANARY")
os.Exit(17)
case "large":
_, _ = os.Stdout.Write(bytes.Repeat([]byte("x"), 65536))
case "hang":
_, _ = io.WriteString(os.Stdout, "incomplete dump")
time.Sleep(time.Minute)
case "empty":
default:
os.Exit(2)
}
os.Exit(0)
}
func helper(t *testing.T, mode string) func(context.Context) *exec.Cmd {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return func(ctx context.Context) *exec.Cmd {
cmd := exec.CommandContext(ctx, exe, "-test.run=^TestDumpProcess$")
cmd.Env = append(os.Environ(), "BEACON_BACKUP_TEST_PROCESS="+mode)
return cmd
}
}
func setup(t *testing.T) Options {
t.Helper()
t.Setenv("PGDATABASE", "fixture")
dir := t.TempDir()
opts := Options{ConfigPath: filepath.Join(dir, "saved.yaml"), OutputPath: filepath.Join(dir, "backup.tar.gz"),
MaxBytes: int64(len(testSQL)), Timeout: 10 * time.Second, Version: "test-revision"}
if err := os.WriteFile(opts.ConfigPath, []byte("# preserve comments and this synthetic key\nchannel_keys:\n keys:\n '00': {key: '00000000000000000000000000000000', name: fixture}\n"), 0600); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
paths, err := filepath.Glob(filepath.Join(dir, ".beacon-backup-*"))
if err != nil || len(paths) != 0 {
t.Errorf("private staging was not cleaned: %v %v", paths, err)
}
})
return opts
}
func TestExportBundle(t *testing.T) {
opts := setup(t)
if err := export(context.Background(), opts, helper(t, "ok")); err != nil {
t.Fatal(err)
}
f, err := os.Open(opts.OutputPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
info, err := f.Stat()
if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0600) {
t.Fatalf("backup must be private: %v %v", info, err)
}
gz, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
defer gz.Close()
tr := tar.NewReader(gz)
files := map[string][]byte{}
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil || header.Mode != 0600 || header.Typeflag != tar.TypeReg {
t.Fatalf("invalid archive member: %v %v", header, err)
}
if _, duplicate := files[header.Name]; duplicate {
t.Fatal("duplicate archive name")
}
files[header.Name], err = io.ReadAll(tr)
if err != nil {
t.Fatal(err)
}
}
if _, err := io.Copy(io.Discard, gz); err != nil {
t.Fatal("invalid gzip footer:", err)
}
config, _ := os.ReadFile(opts.ConfigPath)
if len(files) != 3 || string(files["database.sql"]) != testSQL || !bytes.Equal(files["config.yaml"], config) {
t.Fatal("payloads differ from the supplied SQL and saved config")
}
var manifest Manifest
if err := json.Unmarshal(files["manifest.json"], &manifest); err != nil {
t.Fatal(err)
}
if manifest.FormatVersion != 1 || manifest.ToolVersion != opts.Version || manifest.CreatedAt.IsZero() || len(manifest.Files) != 2 || len(manifest.Excluded) != 5 {
t.Fatalf("incomplete manifest: %+v", manifest)
}
for _, member := range manifest.Files {
digest := sha256.Sum256(files[member.Name])
if member.Size != int64(len(files[member.Name])) || member.SHA256 != hex.EncodeToString(digest[:]) {
t.Errorf("manifest mismatch for %s", member.Name)
}
}
}
func TestExportFailureCleanup(t *testing.T) {
for _, mode := range []string{"fail", "large", "hang", "empty"} {
t.Run(mode, func(t *testing.T) {
opts := setup(t)
if mode == "hang" {
opts.Timeout = time.Second
}
err := export(context.Background(), opts, helper(t, mode))
if err == nil || strings.Contains(err.Error(), "PRIVATE_PASSWORD_CANARY") {
t.Fatalf("expected sanitized failure, got %v", err)
}
if mode == "large" && !errors.Is(err, ErrTooLarge) {
t.Fatalf("wrong limit error: %v", err)
}
if mode == "hang" && !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("wrong timeout error: %v", err)
}
if _, err := os.Lstat(opts.OutputPath); !errors.Is(err, os.ErrNotExist) {
t.Fatal("failed export published an output")
}
})
}
}
func TestExportNeverReplacesDestination(t *testing.T) {
for _, race := range []bool{false, true} {
t.Run(map[bool]string{false: "existing", true: "created-during-export"}[race], func(t *testing.T) {
opts := setup(t)
putExisting := func() {
if err := os.WriteFile(opts.OutputPath, []byte("retain this backup"), 0600); err != nil {
t.Fatal(err)
}
}
if !race {
putExisting()
}
cmd := helper(t, "ok")
err := export(context.Background(), opts, func(ctx context.Context) *exec.Cmd {
if !race {
t.Fatal("existing output must be rejected before starting pg_dump")
}
putExisting()
return cmd(ctx)
})
data, readErr := os.ReadFile(opts.OutputPath)
if err == nil || readErr != nil || string(data) != "retain this backup" {
t.Fatalf("existing backup changed: %v %v", err, readErr)
}
})
}
}
func TestExportValidation(t *testing.T) {
for _, mode := range []string{"database", "limit", "config", "canceled"} {
t.Run(mode, func(t *testing.T) {
opts := setup(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
switch mode {
case "database":
t.Setenv("PGDATABASE", "")
case "limit":
opts.MaxBytes = 0
case "config":
if err := os.WriteFile(opts.ConfigPath, make([]byte, maxConfigBytes+1), 0600); err != nil {
t.Fatal(err)
}
case "canceled":
cancel()
}
if err := export(ctx, opts, func(context.Context) *exec.Cmd { t.Fatal("dump ran on invalid input"); return nil }); err == nil {
t.Fatal("invalid export succeeded")
}
})
}
}
func TestDumpArgumentsExcludeConnectionSecrets(t *testing.T) {
t.Setenv("PGPASSWORD", "PRIVATE_PASSWORD_CANARY")
t.Setenv("POSTGRES_DSN", "postgres://user:PRIVATE_PASSWORD_CANARY@localhost/db")
cmd := dumpCommand(context.Background())
if strings.Contains(strings.Join(cmd.Args, " "), "PRIVATE_PASSWORD_CANARY") {
t.Fatal("credentials reached command arguments")
}
args := strings.Join(cmd.Args, " ")
for _, required := range []string{"--no-password", "--lock-wait-timeout=5000", "--no-owner", "--no-acl", "--no-tablespaces", "--format=plain"} {
if !strings.Contains(args, required) {
t.Errorf("missing dump boundary: %s", required)
}
}
}