369 lines
8.5 KiB
Go
369 lines
8.5 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/config"
|
|
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/pipeline"
|
|
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
|
|
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/pgdump"
|
|
)
|
|
|
|
// --- Mocks ---
|
|
|
|
type mockRecipientPub struct {
|
|
schemeID uint16
|
|
keyID []byte
|
|
raw []byte
|
|
}
|
|
|
|
func (m *mockRecipientPub) SchemeID() uint16 { return m.schemeID }
|
|
func (m *mockRecipientPub) KeyID() []byte { return m.keyID }
|
|
func (m *mockRecipientPub) Raw() []byte { return m.raw }
|
|
|
|
type mockKeyManager struct {
|
|
loadPubCalls []loadPubCall
|
|
pub crypto.RecipientPub
|
|
}
|
|
|
|
type loadPubCall struct {
|
|
path string
|
|
schemeID uint16
|
|
}
|
|
|
|
func (m *mockKeyManager) LoadPub(
|
|
path string,
|
|
schemeID uint16,
|
|
) (
|
|
crypto.RecipientPub,
|
|
error,
|
|
) {
|
|
m.loadPubCalls = append(
|
|
m.loadPubCalls,
|
|
loadPubCall{path: path, schemeID: schemeID},
|
|
)
|
|
return m.pub, nil
|
|
}
|
|
|
|
func (m *mockKeyManager) LoadPriv(
|
|
path string,
|
|
schemeID uint16,
|
|
) (
|
|
crypto.RecipientPriv,
|
|
error,
|
|
) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockKeyManager) Generate(
|
|
schemeID uint16,
|
|
pubOut io.Writer,
|
|
privOut io.Writer,
|
|
rand io.Reader,
|
|
) error {
|
|
return nil
|
|
}
|
|
|
|
type successDumper struct {
|
|
data []byte
|
|
receivedOpts pgdump.Options
|
|
}
|
|
|
|
func (d *successDumper) Dump(
|
|
ctx context.Context,
|
|
opts pgdump.Options,
|
|
sink io.Writer,
|
|
) error {
|
|
d.receivedOpts = opts
|
|
if len(d.data) > 0 {
|
|
_, err := sink.Write(d.data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if closer, ok := sink.(io.Closer); ok {
|
|
_ = closer.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type failDumper struct{}
|
|
|
|
func (d *failDumper) Dump(
|
|
ctx context.Context,
|
|
opts pgdump.Options,
|
|
sink io.Writer,
|
|
) error {
|
|
return pgdump.ErrPgDumpFailed(1)
|
|
}
|
|
|
|
type passthroughEncryptor struct{}
|
|
|
|
func (e *passthroughEncryptor) Encrypt(
|
|
plaintext io.Reader,
|
|
recipients []crypto.RecipientPub,
|
|
sink io.Writer,
|
|
rand io.Reader,
|
|
) error {
|
|
_, err := io.Copy(sink, plaintext)
|
|
return err
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func setBackupFlags(
|
|
cmd *cobra.Command,
|
|
backupDir string,
|
|
pqPath string,
|
|
classicalPath string,
|
|
) {
|
|
config.RegisterFlags(cmd)
|
|
_ = cmd.Flags().Set("backup-dir", backupDir)
|
|
_ = cmd.Flags().Set("pq-public-key-path", pqPath)
|
|
_ = cmd.Flags().Set("classical-public-key-path", classicalPath)
|
|
_ = cmd.Flags().Set("pg-host", "localhost")
|
|
_ = cmd.Flags().Set("pg-port", "5432")
|
|
_ = cmd.Flags().Set("pg-user", "testuser")
|
|
_ = cmd.Flags().Set("pg-database", "testdb")
|
|
}
|
|
|
|
func restoreGlobals(t *testing.T) {
|
|
originalNewKeyManager := newKeyManager
|
|
originalNewRunner := newRunner
|
|
originalOutputWriter := outputWriter
|
|
t.Cleanup(func() {
|
|
newKeyManager = originalNewKeyManager
|
|
newRunner = originalNewRunner
|
|
outputWriter = originalOutputWriter
|
|
})
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
func TestBackupCmd_Structure(t *testing.T) {
|
|
if backupCmd == nil {
|
|
t.Fatal("backupCmd is nil")
|
|
}
|
|
if backupCmd.Use != "backup" {
|
|
t.Fatalf("expected Use='backup', got %q", backupCmd.Use)
|
|
}
|
|
}
|
|
|
|
func TestBackupCmd_Success(t *testing.T) {
|
|
restoreGlobals(t)
|
|
t.Setenv("APP_PG_PASSWORD", "testpass")
|
|
|
|
backupDir := t.TempDir()
|
|
pqPath := filepath.Join(backupDir, "pq.pub")
|
|
classicalPath := filepath.Join(backupDir, "classical.pub")
|
|
|
|
_ = os.WriteFile(pqPath, []byte("pq"), 0o644)
|
|
_ = os.WriteFile(classicalPath, []byte("classical"), 0o644)
|
|
|
|
mockKM := &mockKeyManager{
|
|
pub: &mockRecipientPub{
|
|
schemeID: 0x0006,
|
|
keyID: make([]byte, 8),
|
|
raw: make([]byte, 32),
|
|
},
|
|
}
|
|
newKeyManager = func(crypto.Registry) crypto.KeyManager {
|
|
return mockKM
|
|
}
|
|
|
|
testData := []byte("test backup payload")
|
|
dumper := &successDumper{data: testData}
|
|
newRunner = func(...pipeline.Option) pipelineRunner {
|
|
return pipeline.NewRunner(
|
|
pipeline.WithDumper(dumper),
|
|
pipeline.WithEncryptor(&passthroughEncryptor{}),
|
|
)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
outputWriter = &buf
|
|
|
|
cmd := &cobra.Command{}
|
|
setBackupFlags(cmd, backupDir, pqPath, classicalPath)
|
|
_ = cmd.Flags().Set("backup-retention-days", "1")
|
|
|
|
oldFile := filepath.Join(backupDir, "synapse-20230101-000000.dump.pqenc")
|
|
_ = os.WriteFile(oldFile, []byte("old"), 0o644)
|
|
oldTime := time.Now().Add(-48 * time.Hour)
|
|
_ = os.Chtimes(oldFile, oldTime, oldTime)
|
|
|
|
err := backupCmd.RunE(cmd, []string{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(mockKM.loadPubCalls) != 2 {
|
|
t.Fatalf("expected 2 LoadPub calls, got %d", len(mockKM.loadPubCalls))
|
|
}
|
|
if mockKM.loadPubCalls[0].path != pqPath ||
|
|
mockKM.loadPubCalls[0].schemeID != 0x0006 {
|
|
t.Fatalf("unexpected PQ LoadPub call: %+v", mockKM.loadPubCalls[0])
|
|
}
|
|
if mockKM.loadPubCalls[1].path != classicalPath ||
|
|
mockKM.loadPubCalls[1].schemeID != 0x0007 {
|
|
t.Fatalf(
|
|
"unexpected classical LoadPub call: %+v",
|
|
mockKM.loadPubCalls[1],
|
|
)
|
|
}
|
|
|
|
if dumper.receivedOpts.Host != "localhost" {
|
|
t.Fatalf("unexpected host: %q", dumper.receivedOpts.Host)
|
|
}
|
|
if dumper.receivedOpts.Port != 5432 {
|
|
t.Fatalf("unexpected port: %d", dumper.receivedOpts.Port)
|
|
}
|
|
if dumper.receivedOpts.Database != "testdb" {
|
|
t.Fatalf("unexpected database: %q", dumper.receivedOpts.Database)
|
|
}
|
|
if dumper.receivedOpts.User != "testuser" {
|
|
t.Fatalf("unexpected user: %q", dumper.receivedOpts.User)
|
|
}
|
|
if dumper.receivedOpts.Password != "testpass" {
|
|
t.Fatalf("unexpected password: %q", dumper.receivedOpts.Password)
|
|
}
|
|
|
|
key := dumper.receivedOpts.Key
|
|
matched, _ := regexp.MatchString(
|
|
`^synapse-\d{8}-\d{6}-[a-f0-9]{6}\.dump\.pqenc$`,
|
|
key,
|
|
)
|
|
if !matched {
|
|
t.Fatalf("unexpected key format: %q", key)
|
|
}
|
|
|
|
entries, err := os.ReadDir(backupDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var foundFinal int
|
|
var foundTmp int
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if strings.HasSuffix(name, ".dump.pqenc") {
|
|
foundFinal++
|
|
}
|
|
if strings.HasSuffix(name, ".tmp") {
|
|
foundTmp++
|
|
}
|
|
}
|
|
if foundFinal != 1 {
|
|
t.Fatalf("expected 1 final .pqenc file, found %d", foundFinal)
|
|
}
|
|
if foundTmp != 0 {
|
|
t.Fatalf("expected 0 .tmp files, found %d", foundTmp)
|
|
}
|
|
|
|
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
|
|
t.Fatal("expected old file to be pruned by retention")
|
|
}
|
|
|
|
logStr := buf.String()
|
|
if !strings.Contains(logStr, "run_id=") {
|
|
t.Fatal("expected log to contain run_id")
|
|
}
|
|
if !strings.Contains(logStr, "start_time=") {
|
|
t.Fatal("expected log to contain start_time")
|
|
}
|
|
if !strings.Contains(logStr, "end_time=") {
|
|
t.Fatal("expected log to contain end_time")
|
|
}
|
|
if !strings.Contains(logStr, "byte_count=") {
|
|
t.Fatal("expected log to contain byte_count")
|
|
}
|
|
|
|
re := regexp.MustCompile(`byte_count=(\d+)`)
|
|
matches := re.FindAllStringSubmatch(logStr, -1)
|
|
if len(matches) == 0 {
|
|
t.Fatal("expected log to contain byte_count value")
|
|
}
|
|
lastMatch := matches[len(matches)-1][1]
|
|
if lastMatch == "0" {
|
|
t.Fatal("expected non-zero byte_count for successful backup")
|
|
}
|
|
}
|
|
|
|
func TestBackupCmd_PgDumpFailure(t *testing.T) {
|
|
restoreGlobals(t)
|
|
t.Setenv("APP_PG_PASSWORD", "testpass")
|
|
|
|
backupDir := t.TempDir()
|
|
pqPath := filepath.Join(backupDir, "pq.pub")
|
|
classicalPath := filepath.Join(backupDir, "classical.pub")
|
|
|
|
_ = os.WriteFile(pqPath, []byte("pq"), 0o644)
|
|
_ = os.WriteFile(classicalPath, []byte("classical"), 0o644)
|
|
|
|
mockKM := &mockKeyManager{
|
|
pub: &mockRecipientPub{
|
|
schemeID: 0x0006,
|
|
keyID: make([]byte, 8),
|
|
raw: make([]byte, 32),
|
|
},
|
|
}
|
|
newKeyManager = func(crypto.Registry) crypto.KeyManager {
|
|
return mockKM
|
|
}
|
|
|
|
newRunner = func(...pipeline.Option) pipelineRunner {
|
|
return pipeline.NewRunner(
|
|
pipeline.WithDumper(&failDumper{}),
|
|
pipeline.WithEncryptor(&passthroughEncryptor{}),
|
|
)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
outputWriter = &buf
|
|
|
|
cmd := &cobra.Command{}
|
|
setBackupFlags(cmd, backupDir, pqPath, classicalPath)
|
|
|
|
err := backupCmd.RunE(cmd, []string{})
|
|
if err == nil {
|
|
t.Fatal("expected non-nil error")
|
|
}
|
|
|
|
entries, err := os.ReadDir(backupDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if strings.HasSuffix(name, ".pqenc") || strings.HasSuffix(name, ".tmp") {
|
|
t.Fatalf("unexpected file after failure: %s", name)
|
|
}
|
|
}
|
|
|
|
logStr := buf.String()
|
|
if !strings.Contains(logStr, "backup failed") {
|
|
t.Fatal("expected 'backup failed' in log")
|
|
}
|
|
if !strings.Contains(logStr, "run_id=") {
|
|
t.Fatal("expected log to contain run_id")
|
|
}
|
|
if !strings.Contains(logStr, "start_time=") {
|
|
t.Fatal("expected log to contain start_time")
|
|
}
|
|
if !strings.Contains(logStr, "end_time=") {
|
|
t.Fatal("expected log to contain end_time")
|
|
}
|
|
if !strings.Contains(logStr, "byte_count=0") {
|
|
t.Fatal("expected log to contain byte_count=0 for failed backup")
|
|
}
|
|
}
|