Files

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) pipeline.Runner {
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) pipeline.Runner {
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")
}
}