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") } }