mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 11:39:53 +00:00
173 lines
6.1 KiB
Go
173 lines
6.1 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package router
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/db"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api/handlers"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/backup"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/config"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// This opt-in test only creates/restores/drops its own randomly named databases.
|
|
func TestBackupDownloadPostgres(t *testing.T) {
|
|
if os.Getenv("BEACON_BACKUP_TEST_POSTGRES") != "1" {
|
|
t.Skip("requires pg_dump/psql and an explicitly selected private PostgreSQL test server")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
admin, err := pgx.Connect(ctx, "")
|
|
if err != nil {
|
|
t.Fatal("cannot connect to private PostgreSQL")
|
|
}
|
|
defer admin.Close(context.Background())
|
|
sourceName, targetName := "beacon_download_"+strings.ToLower(rand.Text()), "beacon_download_"+strings.ToLower(rand.Text())
|
|
for _, name := range []string{sourceName, targetName} {
|
|
ident := pgx.Identifier{name}.Sanitize()
|
|
if _, err := admin.Exec(ctx, "CREATE DATABASE "+ident); err != nil {
|
|
t.Fatal("cannot create private fixture")
|
|
}
|
|
defer func() {
|
|
if _, err := admin.Exec(ctx, "DROP DATABASE "+ident+" WITH (FORCE)"); err != nil {
|
|
t.Error("cannot remove private fixture")
|
|
}
|
|
}()
|
|
}
|
|
cfg, err := pgxpool.ParseConfig("")
|
|
if err != nil {
|
|
t.Fatal("invalid fixture configuration")
|
|
}
|
|
cfg.ConnConfig.Database = sourceName
|
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
|
if err != nil {
|
|
t.Fatal("cannot open source fixture")
|
|
}
|
|
defer pool.Close()
|
|
if err := db.RunMigrations(ctx, pool); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := pool.Exec(ctx, "CREATE TABLE download_fixture (id bigint GENERATED ALWAYS AS IDENTITY, message text); INSERT INTO download_fixture(message) VALUES ('café 雪')"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dsn := (&url.URL{Scheme: "postgres", Host: net.JoinHostPort(cfg.ConnConfig.Host, strconv.Itoa(int(cfg.ConnConfig.Port))),
|
|
User: url.UserPassword(cfg.ConnConfig.User, cfg.ConnConfig.Password), Path: "/" + sourceName,
|
|
RawQuery: "sslmode=disable&pool_max_conns=2"}).String()
|
|
service, err := backup.ConnectionService(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, "config.yaml")
|
|
saved := []byte("# verbatim saved file\nbackup:\n enabled: true\n")
|
|
if err := os.WriteFile(configPath, saved, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("TMPDIR", dir)
|
|
server := httptest.NewServer(New(nil, nil, nil, Options{
|
|
Auth: config.AuthConfig{APIKey: "fixture-key"},
|
|
AdminRoutes: map[string]http.Handler{"/backup": handlers.BackupRouter(backup.Options{
|
|
ConnectionService: service, ConfigPath: configPath, MaxBytes: 16 << 20, Timeout: 30 * time.Second, Version: "http-fixture",
|
|
})},
|
|
}))
|
|
defer server.Close()
|
|
request, _ := http.NewRequestWithContext(ctx, "GET", server.URL+"/api/v1/admin/backup", nil)
|
|
request.Header.Set("Authorization", "Bearer fixture-key")
|
|
response, err := server.Client().Do(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != 200 || response.Header.Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("download status=%d", response.StatusCode)
|
|
}
|
|
gz, err := gzip.NewReader(response.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer gz.Close()
|
|
tarReader, members := tar.NewReader(gz), map[string][]byte{}
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil || header.Typeflag != tar.TypeReg || header.Mode != 0600 {
|
|
t.Fatal("invalid member")
|
|
}
|
|
members[header.Name], err = io.ReadAll(tarReader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := io.Copy(io.Discard, gz); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var manifest backup.Manifest
|
|
if json.Unmarshal(members["manifest.json"], &manifest) != nil || len(members) != 3 || manifest.FormatVersion != 1 || !bytes.Equal(members["config.yaml"], saved) {
|
|
t.Fatal("invalid manifest/config")
|
|
}
|
|
for _, file := range manifest.Files {
|
|
sum := sha256.Sum256(members[file.Name])
|
|
if file.Size != int64(len(members[file.Name])) || file.SHA256 != hex.EncodeToString(sum[:]) {
|
|
t.Fatal("checksum mismatch")
|
|
}
|
|
}
|
|
if bytes.Contains(members["database.sql"], []byte("beacon_backup]")) {
|
|
t.Fatal("connection settings leaked into bundle")
|
|
}
|
|
restore := exec.CommandContext(ctx, "psql", "-X", "--set=ON_ERROR_STOP=on", "--single-transaction")
|
|
restore.Env = append(os.Environ(), "PGDATABASE="+targetName)
|
|
restore.Stdin = bytes.NewReader(members["database.sql"])
|
|
if err := restore.Run(); err != nil {
|
|
t.Fatal("cannot restore downloaded SQL into empty fixture")
|
|
}
|
|
targetCfg := admin.Config().Copy()
|
|
targetCfg.Database = targetName
|
|
target, err := pgx.ConnectConfig(ctx, targetCfg)
|
|
if err != nil {
|
|
t.Fatal("cannot connect to restored fixture")
|
|
}
|
|
defer target.Close(context.Background())
|
|
const fingerprint = `SELECT md5(string_agg(table_name||column_name||data_type, ',' ORDER BY table_name,ordinal_position)) FROM information_schema.columns WHERE table_schema='public'`
|
|
var before, after, message string
|
|
if pool.QueryRow(ctx, fingerprint).Scan(&before) != nil || target.QueryRow(ctx, fingerprint).Scan(&after) != nil || before != after {
|
|
t.Fatal("schema differs after restore")
|
|
}
|
|
if target.QueryRow(ctx, "SELECT message FROM download_fixture").Scan(&message) != nil || message != "café 雪" {
|
|
t.Fatal("wrong database or changed data")
|
|
}
|
|
var nextID int
|
|
if target.QueryRow(ctx, "INSERT INTO download_fixture(message) VALUES ('next') RETURNING id").Scan(&nextID) != nil || nextID != 2 {
|
|
t.Fatal("identity sequence changed")
|
|
}
|
|
paths, _ := filepath.Glob(filepath.Join(dir, "beacon-download-*"))
|
|
if len(paths) != 0 {
|
|
t.Fatal("private HTTP staging retained")
|
|
}
|
|
t.Log("HTTP bundle restored current Beacon schema and Unicode data; native client ignored ambient PGDATABASE")
|
|
}
|