package pgdump import ( "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "reflect" "strings" "testing" "time" "git.tswf.io/infra/go-synapse-backupper/pkg/domain/pgdump" ) // mockCommandContext creates a test helper that asserts the command name and args, // and returns a shell command that produces the given stdout, stderr, and exit code. func mockCommandContext( t *testing.T, wantName string, wantArgs []string, stdout string, stderr string, exitCode int, ) func(ctx context.Context, name string, arg ...string) *exec.Cmd { return func(ctx context.Context, name string, arg ...string) *exec.Cmd { if name != wantName { t.Errorf("command name = %q, want %q", name, wantName) } if !reflect.DeepEqual(arg, wantArgs) { t.Errorf("args = %v, want %v", arg, wantArgs) } script := fmt.Sprintf( "printf '%%s' '%s'; printf '%%s' '%s' >&2; exit %d", stdout, stderr, exitCode, ) return exec.CommandContext(ctx, "sh", "-c", script) } } func TestDump_Success(t *testing.T) { wantArgs := []string{ "--format=custom", "--exclude-table=e2e_one_time_keys_json", } adapter := &adapter{ commandContext: mockCommandContext( t, "pg_dump", wantArgs, "dumpdata", "", 0, ), } var buf bytes.Buffer err := adapter.Dump( context.Background(), pgdump.Options{Database: "testdb"}, &buf, ) if err != nil { t.Fatalf("unexpected error: %v", err) } if buf.String() != "dumpdata" { t.Errorf("output = %q, want %q", buf.String(), "dumpdata") } } func TestDump_WaitAfterStdoutEOF(t *testing.T) { // This test verifies that after io.Copy returns (stdout EOF), // cmd.Wait() is called and the exit code is verified before returning. wantArgs := []string{ "--format=custom", "--exclude-table=e2e_one_time_keys_json", } adapter := &adapter{ commandContext: mockCommandContext( t, "pg_dump", wantArgs, "dumpdata", "", 0, ), } var buf bytes.Buffer err := adapter.Dump( context.Background(), pgdump.Options{Database: "testdb"}, &buf, ) if err != nil { t.Fatalf("unexpected error: %v", err) } if buf.String() != "dumpdata" { t.Errorf("output = %q, want %q", buf.String(), "dumpdata") } } func TestDump_NonZeroExitCode(t *testing.T) { wantArgs := []string{ "--format=custom", "--exclude-table=e2e_one_time_keys_json", } adapter := &adapter{ commandContext: mockCommandContext( t, "pg_dump", wantArgs, "", "stderr error message", 1, ), } var buf bytes.Buffer err := adapter.Dump( context.Background(), pgdump.Options{Database: "testdb"}, &buf, ) if err == nil { t.Fatal("expected error, got nil") } if !errors.Is(err, pgdump.ErrPgDumpFailed(1)) { t.Errorf("error = %v, want ErrPgDumpFailed(1)", err) } } func TestDump_StderrCaptured(t *testing.T) { wantStderr := "stderr captured" var capturedCmd *exec.Cmd adapter := &adapter{ commandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd { script := fmt.Sprintf("printf '%%s' '%s' >&2; exit 0", wantStderr) capturedCmd = exec.CommandContext(ctx, "sh", "-c", script) return capturedCmd }, } var buf bytes.Buffer err := adapter.Dump( context.Background(), pgdump.Options{Database: "testdb"}, &buf, ) if err != nil { t.Fatalf("unexpected error: %v", err) } if capturedCmd.Stderr == nil { t.Fatal("expected cmd.Stderr to be set, got nil") } stderrBuilder, ok := capturedCmd.Stderr.(*strings.Builder) if !ok { t.Fatalf("expected cmd.Stderr to be *strings.Builder, got %T", capturedCmd.Stderr) } if stderrBuilder.String() != wantStderr { t.Errorf("stderr = %q, want %q", stderrBuilder.String(), wantStderr) } } func TestDump_ContextCancellation(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() adapter := &adapter{ commandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd { return exec.CommandContext(ctx, "sh", "-c", "while :; do :; done") }, } var buf bytes.Buffer err := adapter.Dump(ctx, pgdump.Options{Database: "testdb"}, &buf) if err == nil { t.Fatal("expected error due to context cancellation, got nil") } // Accept either context deadline exceeded or signal killed. if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(err.Error(), "signal") { t.Logf("got error: %v (acceptable variants: context.DeadlineExceeded or signal killed)", err) } } func TestDump_DefaultExcludeTables(t *testing.T) { wantArgs := []string{ "--format=custom", "--exclude-table=e2e_one_time_keys_json", } adapter := &adapter{ commandContext: mockCommandContext( t, "pg_dump", wantArgs, "", "", 0, ), } err := adapter.Dump( context.Background(), pgdump.Options{Database: "testdb"}, io.Discard, ) if err != nil { t.Fatalf("unexpected error: %v", err) } } func TestDump_CustomExcludeTables(t *testing.T) { wantArgs := []string{ "--format=custom", "--exclude-table=table_a", "--exclude-table=table_b", } adapter := &adapter{ commandContext: mockCommandContext( t, "pg_dump", wantArgs, "", "", 0, ), } err := adapter.Dump( context.Background(), pgdump.Options{ Database: "testdb", ExcludeTables: []string{"table_a", "table_b"}, }, io.Discard, ) if err != nil { t.Fatalf("unexpected error: %v", err) } } func TestDump_EnvVars(t *testing.T) { var capturedCmd *exec.Cmd adapter := &adapter{ commandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd { capturedCmd = exec.CommandContext(ctx, "sh", "-c", "exit 0") return capturedCmd }, } err := adapter.Dump( context.Background(), pgdump.Options{ Host: "myhost", Port: 5433, User: "myuser", Password: "mypass", Database: "mydb", }, io.Discard, ) if err != nil { t.Fatalf("unexpected error: %v", err) } envStr := strings.Join(capturedCmd.Env, "\n") if strings.Contains(envStr, "PGPASSWORD=") { t.Errorf("PGPASSWORD must not be passed to pg_dump subprocess") } wantEnvVars := []string{ "PGHOST=myhost", "PGPORT=5433", "PGUSER=myuser", "PGDATABASE=mydb", } for _, wantEnv := range wantEnvVars { if !strings.Contains(envStr, wantEnv) { t.Errorf("env missing %q", wantEnv) } } if !strings.Contains(envStr, "PGPASSFILE=") { t.Errorf("PGPASSFILE must be set when a password is provided") } } func TestDump_PgpassFileRemovedAfterRun(t *testing.T) { var capturedCmd *exec.Cmd adapter := &adapter{ commandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd { capturedCmd = exec.CommandContext(ctx, "sh", "-c", "exit 0") return capturedCmd }, } err := adapter.Dump( context.Background(), pgdump.Options{ Password: "secret", Database: "mydb", }, io.Discard, ) if err != nil { t.Fatalf("unexpected error: %v", err) } envStr := strings.Join(capturedCmd.Env, "\n") passFilePrefix := "PGPASSFILE=" idx := strings.Index(envStr, passFilePrefix) if idx == -1 { t.Fatalf("PGPASSFILE not found in env") } pgpassPath := envStr[idx+len(passFilePrefix):] if newlineIdx := strings.Index(pgpassPath, "\n"); newlineIdx != -1 { pgpassPath = pgpassPath[:newlineIdx] } if _, statErr := os.Stat(pgpassPath); !os.IsNotExist(statErr) { t.Errorf("temporary pgpass file %s was not removed after Dump returned", pgpassPath) } } func TestWritePgPassFile_Permissions(t *testing.T) { path, cleanup, err := writePgPassFile(pgdump.Options{ Host: "myhost", Port: 5432, Database: "mydb", User: "myuser", Password: "secret", }) if err != nil { t.Fatalf("unexpected error: %v", err) } defer cleanup() info, err := os.Stat(path) if err != nil { t.Fatalf("stat pgpass file: %v", err) } if info.Mode().Perm() != 0o600 { t.Errorf("pgpass file permissions = %o, want %o", info.Mode().Perm(), 0o600) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read pgpass file: %v", err) } want := "myhost:5432:mydb:myuser:secret\n" if string(content) != want { t.Errorf("pgpass content = %q, want %q", string(content), want) } } func TestDump_InvalidExcludeTable(t *testing.T) { adapter := &adapter{ commandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd { return exec.CommandContext(ctx, "sh", "-c", "exit 0") }, } err := adapter.Dump( context.Background(), pgdump.Options{ Database: "testdb", ExcludeTables: []string{"table; DROP TABLE users;--"}, }, io.Discard, ) if err == nil { t.Fatal("expected error for invalid exclude-table identifier") } if !strings.Contains(err.Error(), "invalid exclude-table identifier") { t.Errorf("error = %v, want invalid exclude-table identifier", err) } } func TestDump_PipeWriterClosed(t *testing.T) { adapter := &adapter{ commandContext: mockCommandContext( t, "pg_dump", []string{ "--format=custom", "--exclude-table=e2e_one_time_keys_json", }, "pipe data", "", 0, ), } pr, pw := io.Pipe() readDone := make(chan struct{}) var readData []byte var readErr error go func() { readData, readErr = io.ReadAll(pr) close(readDone) }() err := adapter.Dump( context.Background(), pgdump.Options{Database: "testdb"}, pw, ) if err != nil { t.Fatalf("unexpected error: %v", err) } <-readDone if readErr != nil { t.Fatalf("read error: %v", readErr) } if string(readData) != "pipe data" { t.Errorf("read data = %q, want %q", string(readData), "pipe data") } } func TestDump_PipeWriterClosedWithError(t *testing.T) { adapter := &adapter{ commandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd { return exec.CommandContext(ctx, "sh", "-c", "exit 1") }, } pr, pw := io.Pipe() readDone := make(chan struct{}) var readErr error go func() { _, readErr = io.ReadAll(pr) close(readDone) }() err := adapter.Dump( context.Background(), pgdump.Options{Database: "testdb"}, pw, ) if err == nil { t.Fatal("expected error, got nil") } <-readDone if readErr == nil { t.Fatal("expected read error due to pipe close with error, got nil") } if !errors.Is(readErr, pgdump.ErrPgDumpFailed(1)) { t.Errorf("read error = %v, want ErrPgDumpFailed(1)", readErr) } }