Files
beacon-server/internal/backup/export_integration_test.go
T
n30nex 9164659544 feat(backup): export private database and saved-config bundles (#151)
* feat(backup): export bounded database and saved-config bundles

* test(backup): preserve synthetic channel keys in exports

* fix(backup): check writable file closure before publication
2026-09-18 06:17:52 -04:00

217 lines
7.6 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/db"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestExportPostgres runs only on an explicitly selected private test server with
// pg_dump/psql installed. It creates and drops two randomly named test databases.
// BEACON_BACKUP_TEST_BINARY points to the exact compiled command being verified.
func TestExportPostgres(t *testing.T) {
if os.Getenv("BEACON_BACKUP_TEST_POSTGRES") != "1" {
t.Skip("set BEACON_BACKUP_TEST_POSTGRES=1 and BEACON_BACKUP_TEST_BINARY on a private PostgreSQL test server")
}
binary := os.Getenv("BEACON_BACKUP_TEST_BINARY")
if !filepath.IsAbs(binary) {
t.Fatal("the exact backup command must be an absolute path")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
admin, err := pgx.Connect(ctx, "")
if err != nil {
t.Fatal("cannot connect to private test PostgreSQL")
}
defer admin.Close(context.Background())
names := []string{"beacon_backup_test_" + strings.ToLower(rand.Text()), "beacon_backup_test_" + strings.ToLower(rand.Text())}
for _, name := range names {
ident := pgx.Identifier{name}.Sanitize()
if _, err := admin.Exec(ctx, "CREATE DATABASE "+ident); err != nil {
t.Fatal(err)
}
defer func() {
cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second)
defer stop()
if _, err := admin.Exec(cleanup, "DROP DATABASE "+ident); err != nil {
t.Errorf("cannot remove own test database: %v", err)
}
}()
}
poolFor := func(name string) *pgxpool.Pool {
t.Helper()
cfg, err := pgxpool.ParseConfig("")
if err != nil {
t.Fatal("invalid private test connection settings")
}
cfg.ConnConfig.Database = name
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal("cannot open own test database")
}
return pool
}
source, target := poolFor(names[0]), poolFor(names[1])
defer source.Close()
defer target.Close()
if err := db.RunMigrations(ctx, source); err != nil {
t.Fatal(err)
}
_, err = source.Exec(ctx, `
CREATE TABLE backup_fixture (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, message text, raw bytea, detail jsonb);
INSERT INTO backup_fixture (message, raw, detail) VALUES ('café 雪', decode('00ff10', 'hex'), '{"nested":[1,null,true]}'), ('line one
line two', NULL, '{}');
CREATE TABLE backup_child (id bigint PRIMARY KEY REFERENCES backup_fixture(id));
INSERT INTO backup_child VALUES (1);
CREATE VIEW backup_view AS SELECT id, message FROM backup_fixture;
CREATE MATERIALIZED VIEW backup_materialized AS SELECT count(*) AS count FROM backup_child;
`)
if err != nil {
t.Fatal(err)
}
const check = `SELECT json_build_object(
'rows', (SELECT json_agg(x ORDER BY id) FROM backup_fixture x),
'children', (SELECT json_agg(x ORDER BY id) FROM backup_child x),
'view', (SELECT json_agg(x ORDER BY id) FROM backup_view x),
'materialized', (SELECT json_agg(x) FROM backup_materialized x),
'migrations', (SELECT json_agg(x ORDER BY filename) FROM schema_migrations x),
'columns', (SELECT md5(string_agg(table_name||column_name||data_type, ',' ORDER BY table_name,ordinal_position)) FROM information_schema.columns WHERE table_schema='public')
)::text`
var before, after string
if err := source.QueryRow(ctx, check).Scan(&before); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
config := []byte("# saved configuration, including a synthetic key\nchannel_keys:\n keys:\n '00': {key: '00000000000000000000000000000000', name: fixture}\n")
configPath, output := filepath.Join(dir, "config.yaml"), filepath.Join(dir, "backup.tar.gz")
if err := os.WriteFile(configPath, config, 0600); err != nil {
t.Fatal(err)
}
command := exec.CommandContext(ctx, binary, "-config", configPath, "-output", output, "-max-bytes", "16777216", "-timeout", "1m")
command.Env = append(os.Environ(), "PGDATABASE="+names[0])
if err := command.Run(); err != nil {
t.Fatal("exact backup command failed")
}
original, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
for _, failure := range []string{"existing", "size", "connection", "timeout"} {
t.Run(failure, func(t *testing.T) {
failedOutput := filepath.Join(dir, failure+".tar.gz")
limit, timeout, database := "16777216", "30s", names[0]
if failure == "existing" {
failedOutput = output
} else if failure == "size" {
limit = "64"
} else if failure == "connection" {
database = "beacon_backup_test_missing_" + strings.ToLower(rand.Text())
} else {
lock, err := source.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer lock.Rollback(context.Background())
if _, err := lock.Exec(ctx, "LOCK TABLE backup_fixture IN ACCESS EXCLUSIVE MODE"); err != nil {
t.Fatal(err)
}
timeout = "1s"
}
failed := exec.CommandContext(ctx, binary, "-config", configPath, "-output", failedOutput, "-max-bytes", limit, "-timeout", timeout)
failed.Env = append(os.Environ(), "PGDATABASE="+database)
message, err := failed.CombinedOutput()
if err == nil || bytes.Contains(message, []byte(database)) || bytes.Contains(message, []byte("café")) {
t.Fatal("expected a failure without database identity or payload diagnostics")
}
if failure == "existing" {
retained, err := os.ReadFile(output)
if err != nil || !bytes.Equal(retained, original) {
t.Fatal("existing output changed")
}
} else if _, err := os.Lstat(failedOutput); !os.IsNotExist(err) {
t.Fatal("failed export published an output")
}
staging, _ := filepath.Glob(filepath.Join(dir, ".beacon-backup-*"))
if len(staging) != 0 {
t.Fatal("failed export retained private staging")
}
})
}
f, err := os.Open(output)
if err != nil {
t.Fatal(err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
defer gz.Close()
tr, members := tar.NewReader(gz), map[string][]byte{}
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
members[h.Name], err = io.ReadAll(tr)
if err != nil {
t.Fatal(err)
}
}
if _, err := io.Copy(io.Discard, gz); err != nil {
t.Fatal(err)
}
var manifest Manifest
if err := json.Unmarshal(members["manifest.json"], &manifest); err != nil {
t.Fatal(err)
}
if len(members) != 3 || manifest.FormatVersion != 1 || !bytes.Equal(members["config.yaml"], config) {
t.Fatal("unexpected bundle members or saved config")
}
for _, member := range manifest.Files {
hash := sha256.Sum256(members[member.Name])
if member.SHA256 != hex.EncodeToString(hash[:]) || member.Size != int64(len(members[member.Name])) {
t.Fatal("bundle checksum mismatch")
}
}
restore := exec.CommandContext(ctx, "psql", "-X", "--set=ON_ERROR_STOP=on", "--single-transaction")
restore.Env = append(os.Environ(), "PGDATABASE="+names[1])
restore.Stdin = bytes.NewReader(members["database.sql"])
if err := restore.Run(); err != nil {
t.Fatal("restore into own empty database failed")
}
if err := target.QueryRow(ctx, check).Scan(&after); err != nil {
t.Fatal(err)
}
if before != after {
t.Fatal("schema, journal, data, relationships or materialized view changed after restore")
}
var nextID int
if err := target.QueryRow(ctx, "INSERT INTO backup_fixture (message) VALUES ('next') RETURNING id").Scan(&nextID); err != nil || nextID != 3 {
t.Fatal("identity sequence did not survive restore")
}
t.Log("exact command restored all migrations, columns, Unicode/bytea/JSON data, relationships, views and identity sequence")
}