diff --git a/.github/workflows/main_test.yml b/.github/workflows/main_test.yml new file mode 100644 index 000000000..7890917fc --- /dev/null +++ b/.github/workflows/main_test.yml @@ -0,0 +1,49 @@ +on: + push: + pull_request: + workflow_dispatch: +name: Test +permissions: + contents: read +jobs: + test: + strategy: + matrix: + go-version: [1.24.1] + platform: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.platform }} + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + # Windows throws false positives with linting because of CRLF / goimports incompat + - name: Set git to use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Checkout code + uses: actions/checkout@v2 + - name: Run Setup + run: make setup + - name: install diffutils + if: runner.os == 'macOS' + run: brew install diffutils + - name: Install protoc + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install protoc deps + run: | + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28 + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2 + go install github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc@v1.5.1 + shell: bash + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.64.2 + args: --timeout=3m + - name: Run Tests + run: make ci + shell: bash diff --git a/pkg/cmd/login.go b/pkg/cmd/login.go index 4b70ad3b9..9af0a63c2 100644 --- a/pkg/cmd/login.go +++ b/pkg/cmd/login.go @@ -38,6 +38,16 @@ func (lc *loginCmd) runLoginCmd(cmd *cobra.Command, args []string) error { return err } + // If the user provides an API key via the global --api-key flag, prefer a + // non-browser login flow. This is especially important for headless/Docker. + if Config.Profile.APIKey != "" { + if lc.interactive { + return login.InteractiveLogin(cmd.Context(), &Config) + } + + return login.LoginWithAPIKey(cmd.Context(), stripe.DefaultAPIBaseURL, &Config, Config.Profile.APIKey) + } + if lc.interactive { return login.InteractiveLogin(cmd.Context(), &Config) } diff --git a/pkg/login/api_key_login.go b/pkg/login/api_key_login.go new file mode 100644 index 000000000..718f21f3d --- /dev/null +++ b/pkg/login/api_key_login.go @@ -0,0 +1,53 @@ +package login + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/stripe/stripe-cli/pkg/config" + "github.com/stripe/stripe-cli/pkg/validators" +) + +// LoginWithAPIKey configures the CLI using a user-provided API key. +// +// This path intentionally avoids the browser/pairing-code flow so that it can +// be used in headless environments (e.g., Docker/CI). +func LoginWithAPIKey(ctx context.Context, apiBaseURL string, cfg *config.Config, apiKey string) error { + apiKey = strings.TrimSpace(apiKey) + if err := validators.APIKey(apiKey); err != nil { + return err + } + + // Ensure we have a device name even if InitConfig hasn't run (e.g., in tests). + if strings.TrimSpace(cfg.Profile.DeviceName) == "" { + hostName, err := os.Hostname() + if err != nil { + hostName = "unknown" + } + cfg.Profile.DeviceName = hostName + } + + // Treat the provided key as the configured test mode key, mirroring the + // interactive login flow. + cfg.Profile.TestModeAPIKey = apiKey + + displayName, _ := getDisplayName(ctx, nil, apiBaseURL, apiKey) + cfg.Profile.DisplayName = displayName + + if err := cfg.Profile.CreateProfile(); err != nil { + return err + } + + message, err := SuccessMessage(ctx, nil, apiBaseURL, apiKey) + if err != nil { + fmt.Printf("> Error verifying the CLI was setup successfully: %s\n", + err, + ) + return nil + } + + fmt.Printf("> %s\n", message) + return nil +} diff --git a/pkg/login/api_key_login_test.go b/pkg/login/api_key_login_test.go new file mode 100644 index 000000000..d0b0007a3 --- /dev/null +++ b/pkg/login/api_key_login_test.go @@ -0,0 +1,77 @@ +package login + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/stripe/stripe-cli/pkg/config" + "github.com/stripe/stripe-cli/pkg/login/acct" +) + +func TestLoginWithAPIKeyDoesNotUseBrowserFlow(t *testing.T) { + viper.Reset() + defer viper.Reset() + + apiKey := "sk_test_1234567890" + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "GET", r.Method) + require.Equal(t, "/v1/account", r.URL.Path) + + account := &acct.Account{ID: "acct_123"} + account.Settings.Dashboard.DisplayName = "test-display" + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(account)) + })) + defer ts.Close() + + profilesFile := filepath.Join(t.TempDir(), "stripe", "config.toml") + viper.SetConfigFile(profilesFile) + + cfg := &config.Config{ + Color: "auto", + LogLevel: "info", + Profile: config.Profile{ + DeviceName: "st-testing", + ProfileName: "default", + }, + ProfilesFile: profilesFile, + } + cfg.InitConfig() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + os.Stdout = w + err = LoginWithAPIKey(context.Background(), ts.URL, cfg, apiKey) + _ = w.Close() + os.Stdout = oldStdout + require.NoError(t, err) + + outBytes, readErr := io.ReadAll(r) + require.NoError(t, readErr) + output := string(outBytes) + + require.NotContains(t, strings.ToLower(output), "pairing code") + require.NotContains(t, output, "Press Enter") + require.Contains(t, output, "Done! The Stripe CLI is configured") + + configBytes, fileErr := os.ReadFile(profilesFile) + require.NoError(t, fileErr) + require.Contains(t, string(configBytes), "test_mode_api_key") + require.Contains(t, string(configBytes), apiKey) +} diff --git a/pkg/login/interactive_login.go b/pkg/login/interactive_login.go index 9f6382a2b..91dac66e7 100644 --- a/pkg/login/interactive_login.go +++ b/pkg/login/interactive_login.go @@ -22,9 +22,17 @@ import ( // InteractiveLogin lets the user set configuration on the command line func InteractiveLogin(ctx context.Context, config *config.Config) error { - apiKey, err := getConfigureAPIKey(os.Stdin) - if err != nil { - return err + apiKey := strings.TrimSpace(config.Profile.APIKey) + if apiKey == "" { + var err error + apiKey, err = getConfigureAPIKey(os.Stdin) + if err != nil { + return err + } + } else { + if err := validators.APIKey(apiKey); err != nil { + return err + } } config.Profile.DeviceName = getConfigureDeviceName(os.Stdin)