From 68f43fee8788b303cfe3ffbadab4e287f8fa759a Mon Sep 17 00:00:00 2001 From: Francisco Meneses Date: Thu, 11 Jun 2026 16:43:08 -0400 Subject: [PATCH] refactor(export): inject CommandRunner into toolbox Add CommandRunner interface with default exec implementation so native and container export paths are testable without real exec. Co-authored-by: Cursor --- internal/export/toolbox.go | 42 ++++++++---- internal/export/toolbox_test.go | 116 ++++++++++++++++++++++++++++++-- 2 files changed, 139 insertions(+), 19 deletions(-) diff --git a/internal/export/toolbox.go b/internal/export/toolbox.go index 9f3dc84..228a1a9 100644 --- a/internal/export/toolbox.go +++ b/internal/export/toolbox.go @@ -23,6 +23,22 @@ type ProductExporter interface { ExportProduct(ctx context.Context, adminURL, token, systemName string) ([]byte, error) } +// CommandRunner executes external processes. Inject a mock in tests to avoid real exec. +type CommandRunner interface { + Run(ctx context.Context, command string, args []string) (stdout, stderr []byte, err error) +} + +type execCommandRunner struct{} + +func (execCommandRunner) Run(ctx context.Context, command string, args []string) ([]byte, []byte, error) { + cmd := exec.CommandContext(ctx, command, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.Bytes(), stderr.Bytes(), err +} + type ToolboxOptions struct { Runtime string // podman or docker; empty auto-detects Image string @@ -30,6 +46,8 @@ type ToolboxOptions struct { NativeBinary string // CertFile mounts a CA/cert for toolbox TLS (SSL_CERT_FILE in container). CertFile string + // CommandRunner overrides process execution (defaults to os/exec). + CommandRunner CommandRunner } type Toolbox struct { @@ -37,6 +55,7 @@ type Toolbox struct { image string nativeBinary string certFile string + runner CommandRunner } func NewToolbox(opts ToolboxOptions) (*Toolbox, error) { @@ -45,6 +64,10 @@ func NewToolbox(opts ToolboxOptions) (*Toolbox, error) { image: strings.TrimSpace(opts.Image), nativeBinary: strings.TrimSpace(opts.NativeBinary), certFile: strings.TrimSpace(opts.CertFile), + runner: opts.CommandRunner, + } + if t.runner == nil { + t.runner = execCommandRunner{} } if t.image == "" { t.image = DefaultToolboxImage @@ -105,9 +128,8 @@ func buildRemoteURL(adminURL, token string) (string, error) { } func (t *Toolbox) runNative(ctx context.Context, remoteURL, systemName string) ([]byte, error) { - // Official syntax: 3scale product export args := []string{"product", "export", remoteURL, systemName} - return runCommand(ctx, t.nativeBinary, args) + return t.runCommand(ctx, t.nativeBinary, args) } func (t *Toolbox) runContainer(ctx context.Context, remoteURL, systemName string) ([]byte, error) { @@ -119,24 +141,20 @@ func (t *Toolbox) runContainer(ctx context.Context, remoteURL, systemName string ) } args = append(args, t.image, "3scale", "product", "export", remoteURL, systemName) - return runCommand(ctx, t.runtime, args) + return t.runCommand(ctx, t.runtime, args) } -func runCommand(ctx context.Context, command string, args []string) ([]byte, error) { - cmd := exec.CommandContext(ctx, command, args...) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - msg := strings.TrimSpace(stderr.String()) +func (t *Toolbox) runCommand(ctx context.Context, command string, args []string) ([]byte, error) { + stdout, stderr, err := t.runner.Run(ctx, command, args) + if err != nil { + msg := strings.TrimSpace(string(stderr)) if msg == "" { msg = err.Error() } return nil, fmt.Errorf("%w: %s", ErrToolboxFailed, msg) } - out := bytes.TrimSpace(stdout.Bytes()) + out := bytes.TrimSpace(stdout) if len(out) == 0 { return nil, fmt.Errorf("%w: empty output from %s", ErrToolboxFailed, command) } diff --git a/internal/export/toolbox_test.go b/internal/export/toolbox_test.go index 7771da8..f2d37bd 100644 --- a/internal/export/toolbox_test.go +++ b/internal/export/toolbox_test.go @@ -1,6 +1,8 @@ package export import ( + "context" + "errors" "os" "path/filepath" "strings" @@ -104,15 +106,115 @@ func TestNewToolboxNativeMode(t *testing.T) { } func TestRunContainerArgs(t *testing.T) { + var captured struct { + command string + args []string + } + runner := &mockCommandRunner{ + fn: func(command string, args []string) ([]byte, []byte, error) { + captured.command = command + captured.args = append([]string(nil), args...) + return []byte("apiVersion: v1\nkind: Product\n"), nil, nil + }, + } tb := &Toolbox{ - runtime: "podman", - image: DefaultToolboxImage, + runtime: "podman", + image: DefaultToolboxImage, certFile: "/etc/ssl/certs/custom.pem", + runner: runner, + } + out, err := tb.ExportProduct(context.Background(), "https://admin.example.com", "tok", "payments") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "apiVersion") { + t.Fatalf("output = %q", out) + } + if captured.command != "podman" { + t.Fatalf("command = %q", captured.command) + } + joined := strings.Join(captured.args, " ") + for _, want := range []string{ + "run", "--rm", + "SSL_CERT_FILE=/tmp/3scale-toolbox-cert.pem", + "/etc/ssl/certs/custom.pem:/tmp/3scale-toolbox-cert.pem:ro", + DefaultToolboxImage, + "3scale", "product", "export", + "payments", + } { + if !strings.Contains(joined, want) { + t.Fatalf("args missing %q: %v", want, captured.args) + } + } + if !strings.Contains(joined, "tok@admin.example.com") { + t.Fatalf("remote URL not embedded: %v", captured.args) + } +} + +func TestExportProductNativeUsesRunner(t *testing.T) { + var captured struct { + command string + args []string + } + runner := &mockCommandRunner{ + fn: func(command string, args []string) ([]byte, []byte, error) { + captured.command = command + captured.args = append([]string(nil), args...) + return []byte("kind: Product\n"), nil, nil + }, + } + tb := &Toolbox{ + nativeBinary: "/usr/bin/3scale", + runner: runner, + } + if _, err := tb.ExportProduct(context.Background(), "https://tenant.example.com", "secret", "demo_api"); err != nil { + t.Fatal(err) } - // exercise internal path via ExportProduct would need mock exec; verify URL builder instead - remote, err := buildRemoteURL("https://admin.example.com", "tok") - if err != nil || !strings.Contains(remote, "tok@") { - t.Fatalf("remote = %q err = %v", remote, err) + if captured.command != "/usr/bin/3scale" { + t.Fatalf("command = %q", captured.command) + } + if len(captured.args) != 4 || captured.args[0] != "product" || captured.args[3] != "demo_api" { + t.Fatalf("args = %v", captured.args) + } +} + +func TestRunCommandEmptyOutput(t *testing.T) { + tb := &Toolbox{ + nativeBinary: "/usr/bin/3scale", + runner: &mockCommandRunner{ + fn: func(string, []string) ([]byte, []byte, error) { + return nil, nil, nil + }, + }, + } + _, err := tb.ExportProduct(context.Background(), "https://tenant.example.com", "secret", "demo") + if err == nil || !errors.Is(err, ErrToolboxFailed) { + t.Fatalf("err = %v", err) + } +} + +func TestRunCommandStderrError(t *testing.T) { + tb := &Toolbox{ + nativeBinary: "/usr/bin/3scale", + runner: &mockCommandRunner{ + fn: func(string, []string) ([]byte, []byte, error) { + return nil, []byte("connection refused"), errors.New("exit 1") + }, + }, + } + _, err := tb.ExportProduct(context.Background(), "https://tenant.example.com", "secret", "demo") + if err == nil || !strings.Contains(err.Error(), "connection refused") { + t.Fatalf("err = %v", err) + } +} + +type mockCommandRunner struct { + fn func(command string, args []string) (stdout, stderr []byte, err error) +} + +func (m *mockCommandRunner) Run(_ context.Context, command string, args []string) ([]byte, []byte, error) { + if m.fn == nil { + return nil, nil, errors.New("mock not configured") } - _ = tb + return m.fn(command, args) }