From 9ff177c4af5ee624dc324cdd0a306cddebf9ad37 Mon Sep 17 00:00:00 2001 From: fabio ramirez Date: Tue, 24 Mar 2026 15:12:10 -0600 Subject: [PATCH 1/3] [WEBXP-417] Add `circleci signup` command with hybrid browser flow Implements a new signup command that opens the browser to CircleCI's signup page and receives the authentication token back via a local HTTP server callback, with error handling for PAT creation failures and a --no-browser fallback for manual token entry. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/root.go | 1 + cmd/signup.go | 275 ++++++++++++++++++++++++++++++++++++++++ cmd/signup_unit_test.go | 206 ++++++++++++++++++++++++++++++ 3 files changed, 482 insertions(+) create mode 100644 cmd/signup.go create mode 100644 cmd/signup_unit_test.go diff --git a/cmd/root.go b/cmd/root.go index a5a7fa0fd..2809dec6c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -178,6 +178,7 @@ func MakeCommands() *cobra.Command { rootCmd.AddCommand(newVersionCommand(rootOptions)) rootCmd.AddCommand(newDiagnosticCommand(rootOptions)) rootCmd.AddCommand(newSetupCommand(rootOptions)) + rootCmd.AddCommand(newSignupCommand(rootOptions)) rootCmd.AddCommand(newInitCommand(rootOptions)) rootCmd.AddCommand(followProjectCommand(rootOptions)) diff --git a/cmd/signup.go b/cmd/signup.go new file mode 100644 index 000000000..0e2dddabd --- /dev/null +++ b/cmd/signup.go @@ -0,0 +1,275 @@ +package cmd + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "net/http" + "time" + + "github.com/pkg/browser" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/CircleCI-Public/circleci-cli/prompt" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/CircleCI-Public/circleci-cli/telemetry" +) + +type signupOptions struct { + cfg *settings.Config + noBrowser bool +} + +func newSignupCommand(config *settings.Config) *cobra.Command { + opts := signupOptions{ + cfg: config, + } + + cmd := &cobra.Command{ + Use: "signup", + Short: "Sign up for a CircleCI account and authenticate the CLI", + RunE: func(cmd *cobra.Command, _ []string) error { + err := runSignup(cmd, opts) + + telemetryClient, ok := telemetry.FromContext(cmd.Context()) + if ok { + _ = telemetryClient.Track(createSignupEvent(opts.noBrowser, err)) + } + + return err + }, + } + + cmd.Flags().BoolVar(&opts.noBrowser, "no-browser", false, "Don't open a browser; print the signup URL and prompt for a token instead") + + return cmd +} + +func createSignupEvent(noBrowser bool, err error) telemetry.Event { + properties := map[string]interface{}{ + "no_browser": noBrowser, + "has_been_executed": true, + } + if err != nil { + properties["error"] = err.Error() + } + return telemetry.Event{ + Object: "cli-signup", + Action: "signup", + Properties: properties, + } +} + +func runSignup(cmd *cobra.Command, opts signupOptions) error { + state, err := generateState() + if err != nil { + return errors.Wrap(err, "failed to generate cryptographic state") + } + + if opts.noBrowser { + return signupNoBrowser(opts, state) + } + + return signupWithBrowser(cmd, opts, state) +} + +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func signupNoBrowser(opts signupOptions, state string) error { + signupURL := fmt.Sprintf("https://circleci.com/signup?source=cli&state=%s", state) + fmt.Printf("Open this URL in your browser to sign up:\n\n %s\n\n", signupURL) + + token, err := prompt.ReadSecretStringFromUser("Paste your CircleCI API token here") + if err != nil { + return errors.Wrap(err, "failed to read token") + } + + if token == "" { + return errors.New("no token provided") + } + + return saveToken(opts.cfg, token) +} + +func signupWithBrowser(cmd *cobra.Command, opts signupOptions, state string) error { + // Start an ephemeral HTTP server on a random available port. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return errors.Wrap(err, "failed to start local server") + } + port := listener.Addr().(*net.TCPAddr).Port + + tokenCh := make(chan string, 1) + errCh := make(chan error, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/callback", handleCallback) + mux.HandleFunc("/complete", handleComplete(state, tokenCh, errCh)) + + server := &http.Server{ + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + go func() { + if serveErr := server.Serve(listener); serveErr != nil && serveErr != http.ErrServerClosed { + errCh <- serveErr + } + }() + + signupURL := fmt.Sprintf( + "https://circleci.com/signup?source=cli&state=%s&return-to=http://127.0.0.1:%d/callback", + state, port, + ) + + trackSignupStep(cmd, "browser_opening", nil) + fmt.Println("Opening your browser to sign up for CircleCI...") + + if err := browser.OpenURL(signupURL); err != nil { + fmt.Printf("⚠️ Could not open browser automatically: %v\n", err) + fmt.Printf(" Please manually visit: %s\n", signupURL) + } + + fmt.Println("Waiting for authentication...") + + // Wait for the token or an error, with a timeout. + select { + case token := <-tokenCh: + _ = server.Shutdown(context.Background()) + trackSignupStep(cmd, "token_received", nil) + return saveToken(opts.cfg, token) + case err := <-errCh: + _ = server.Shutdown(context.Background()) + trackSignupStep(cmd, "failed", nil) + return errors.Wrap(err, "signup failed") + case <-time.After(5 * time.Minute): + _ = server.Shutdown(context.Background()) + trackSignupStep(cmd, "timeout", nil) + return errors.New("timed out waiting for signup to complete. Run `circleci setup` to manually configure your CLI with a personal API token") + } +} + +func handleCallback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // The fragment (#token=...&state=...) is never sent to the server by the + // browser. So we serve a small page whose script reads the fragment and + // sends the values back to /complete. + // + // The frontend may also redirect with #error=token_creation_failed&state=... + // when PAT creation fails. The script detects this and forwards the error + // to /complete so the CLI can exit immediately instead of timing out. + fmt.Fprint(w, ` + +CircleCI CLI + +

Authenticating...

+ + +`) +} + +func handleComplete(expectedState string, tokenCh chan<- string, errCh chan<- error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + token := query.Get("token") + state := query.Get("state") + callbackErr := query.Get("error") + + // When an error is present, state validation is best-effort: if state + // is provided it must match, but a missing state is tolerated because + // the frontend may not have had access to it when the failure occurred. + if callbackErr != "" { + if state != "" && state != expectedState { + http.Error(w, "State mismatch — possible CSRF. Please try again.", http.StatusBadRequest) + errCh <- errors.New("state mismatch — possible CSRF attempt") + return + } + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, "Something went wrong. Please return to your terminal.") + errCh <- fmt.Errorf("account created but token delivery failed (%s). Run `circleci setup` to manually configure your CLI with a personal API token", callbackErr) + return + } + + if state != expectedState { + http.Error(w, "State mismatch — possible CSRF. Please try again.", http.StatusBadRequest) + errCh <- errors.New("state mismatch — possible CSRF attempt") + return + } + + if token == "" { + http.Error(w, "Missing token.", http.StatusBadRequest) + errCh <- errors.New("callback returned an empty token. Run `circleci setup` to manually configure your CLI with a personal API token") + return + } + + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, "You may close this window and return to your terminal.") + tokenCh <- token + } +} + +func saveToken(cfg *settings.Config, token string) error { + cfg.Token = token + if err := cfg.WriteToDisk(); err != nil { + return errors.Wrap(err, "failed to save token to config") + } + fmt.Println("\n✅ Welcome to CircleCI! Your CLI is now authenticated.") + return nil +} + +func trackSignupStep(cmd *cobra.Command, step string, extra map[string]interface{}) { + client, ok := telemetry.FromContext(cmd.Context()) + if !ok { + return + } + invID, _ := telemetry.InvocationIDFromContext(cmd.Context()) + telemetry.TrackWorkflowStep(client, "signup", step, invID, extra) +} diff --git a/cmd/signup_unit_test.go b/cmd/signup_unit_test.go new file mode 100644 index 000000000..db9960f1a --- /dev/null +++ b/cmd/signup_unit_test.go @@ -0,0 +1,206 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/CircleCI-Public/circleci-cli/clitest" + "github.com/CircleCI-Public/circleci-cli/settings" +) + +var _ = Describe("Signup", func() { + Describe("generateState", func() { + It("should return a 32-character hex string", func() { + state, err := generateState() + Expect(err).ShouldNot(HaveOccurred()) + Expect(state).To(HaveLen(32)) + Expect(state).To(MatchRegexp(`^[0-9a-f]{32}$`)) + }) + + It("should generate unique values", func() { + a, _ := generateState() + b, _ := generateState() + Expect(a).ToNot(Equal(b)) + }) + }) + + Describe("handleCallback", func() { + It("should return HTML with the authenticating message", func() { + req := httptest.NewRequest("GET", "/callback", nil) + rec := httptest.NewRecorder() + + handleCallback(rec, req) + + body := rec.Body.String() + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(rec.Header().Get("Content-Type")).To(Equal("text/html; charset=utf-8")) + Expect(body).To(ContainSubstring("Authenticating...")) + Expect(body).To(ContainSubstring(`fetch("/complete?`)) + }) + + It("should include error-handling JavaScript", func() { + req := httptest.NewRequest("GET", "/callback", nil) + rec := httptest.NewRecorder() + + handleCallback(rec, req) + + body := rec.Body.String() + Expect(body).To(ContainSubstring(`params.get("error")`)) + Expect(body).To(ContainSubstring(`"error=no_token"`)) + }) + }) + + Describe("handleComplete", func() { + var ( + tokenCh chan string + errCh chan error + state string + ) + + BeforeEach(func() { + tokenCh = make(chan string, 1) + errCh = make(chan error, 1) + state = "abc123" + }) + + It("should accept a valid token and state", func() { + handler := handleComplete(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/complete?token=mytoken&state=abc123", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(rec.Body.String()).To(ContainSubstring("You may close this window")) + Eventually(tokenCh).Should(Receive(Equal("mytoken"))) + }) + + It("should reject a state mismatch", func() { + handler := handleComplete(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/complete?token=mytoken&state=wrong", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("State mismatch")) + Eventually(errCh).Should(Receive()) + }) + + It("should reject a missing token when no error param is present", func() { + handler := handleComplete(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/complete?state=abc123", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("Missing token")) + var received error + Eventually(errCh).Should(Receive(&received)) + Expect(received.Error()).To(ContainSubstring("circleci setup")) + }) + + Context("with error param from frontend", func() { + It("should forward the error when state matches", func() { + handler := handleComplete(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/complete?error=token_creation_failed&state=abc123", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(rec.Body.String()).To(ContainSubstring("Something went wrong")) + var received error + Eventually(errCh).Should(Receive(&received)) + Expect(received.Error()).To(ContainSubstring("token delivery failed")) + Expect(received.Error()).To(ContainSubstring("token_creation_failed")) + Expect(received.Error()).To(ContainSubstring("circleci setup")) + }) + + It("should tolerate a missing state when error is present", func() { + handler := handleComplete(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/complete?error=no_token", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + var received error + Eventually(errCh).Should(Receive(&received)) + Expect(received.Error()).To(ContainSubstring("no_token")) + }) + + It("should reject error with a mismatched state", func() { + handler := handleComplete(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/complete?error=token_creation_failed&state=wrong", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("State mismatch")) + Eventually(errCh).Should(Receive()) + }) + }) + }) + + Describe("saveToken", func() { + var tempSettings *clitest.TempSettings + + BeforeEach(func() { + tempSettings = clitest.WithTempSettings() + }) + + AfterEach(func() { + tempSettings.Close() + }) + + It("should write the token to the config file", func() { + cfg := &settings.Config{ + FileUsed: tempSettings.Config.Path, + Host: "https://circleci.com", + } + + output := clitest.WithCapturedOutput(func() { + err := saveToken(cfg, "my-new-token") + Expect(err).ShouldNot(HaveOccurred()) + }) + + Expect(output).To(ContainSubstring("Welcome to CircleCI")) + Expect(cfg.Token).To(Equal("my-new-token")) + + // Verify it was persisted to disk + file, err := os.Open(tempSettings.Config.Path) + Expect(err).ShouldNot(HaveOccurred()) + defer file.Close() + + content, err := io.ReadAll(file) + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("token: my-new-token")) + }) + }) + + Describe("createSignupEvent", func() { + It("should create event with no_browser property", func() { + event := createSignupEvent(true, nil) + Expect(event.Object).To(Equal("cli-signup")) + Expect(event.Action).To(Equal("signup")) + Expect(event.Properties["no_browser"]).To(BeTrue()) + Expect(event.Properties["has_been_executed"]).To(BeTrue()) + Expect(event.Properties).ToNot(HaveKey("error")) + }) + + It("should include error when present", func() { + event := createSignupEvent(false, fmt.Errorf("something broke")) + Expect(event.Properties["error"]).To(Equal("something broke")) + Expect(event.Properties["no_browser"]).To(BeFalse()) + }) + }) +}) From 7efd99ab884b7a5e008ea10c12841ac852ebedb9 Mon Sep 17 00:00:00 2001 From: fabio ramirez Date: Wed, 25 Mar 2026 10:37:03 -0600 Subject: [PATCH 2/3] Adapt signup to cross-origin token delivery flow (Pete's feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove HTML bridge page (handleCallback) — browser stays on circleci.com - Frontend creates PAT and delivers it via cross-origin fetch to localhost - Add CORS middleware pinned to https://app.circleci.com - Rename handleComplete → handleToken, single /token endpoint - Use cli_state/cli_port params to avoid collision with Auth0's state - Build signup URL via url.Values with relative return-to path - Update --no-browser to point to login page with token settings return-to - Update tests: remove handleCallback tests, add CORS tests Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/signup.go | 88 ++++++++++++---------------------------- cmd/signup_unit_test.go | 90 ++++++++++++++++++++++++++--------------- 2 files changed, 84 insertions(+), 94 deletions(-) diff --git a/cmd/signup.go b/cmd/signup.go index 0e2dddabd..71766b6c7 100644 --- a/cmd/signup.go +++ b/cmd/signup.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "net/http" + "net/url" "time" "github.com/pkg/browser" @@ -85,7 +86,7 @@ func generateState() (string, error) { } func signupNoBrowser(opts signupOptions, state string) error { - signupURL := fmt.Sprintf("https://circleci.com/signup?source=cli&state=%s", state) + signupURL := "https://app.circleci.com/authentication/login?f=gho&return-to=/settings/user/tokens" fmt.Printf("Open this URL in your browser to sign up:\n\n %s\n\n", signupURL) token, err := prompt.ReadSecretStringFromUser("Paste your CircleCI API token here") @@ -112,8 +113,7 @@ func signupWithBrowser(cmd *cobra.Command, opts signupOptions, state string) err errCh := make(chan error, 1) mux := http.NewServeMux() - mux.HandleFunc("/callback", handleCallback) - mux.HandleFunc("/complete", handleComplete(state, tokenCh, errCh)) + mux.HandleFunc("/token", corsMiddleware(handleToken(state, tokenCh, errCh))) server := &http.Server{ Handler: mux, @@ -127,10 +127,14 @@ func signupWithBrowser(cmd *cobra.Command, opts signupOptions, state string) err } }() - signupURL := fmt.Sprintf( - "https://circleci.com/signup?source=cli&state=%s&return-to=http://127.0.0.1:%d/callback", - state, port, - ) + // Build the signup URL. The return-to is a relative path so it passes + // the existing domain whitelist. The CLI port and state are embedded as + // query params that the successful-signup page will read. + returnTo := fmt.Sprintf("/successful-signup?source=cli&cli_port=%d&cli_state=%s", port, state) + params := url.Values{} + params.Set("f", "gho") + params.Set("return-to", returnTo) + signupURL := "https://app.circleci.com/authentication/login?" + params.Encode() trackSignupStep(cmd, "browser_opening", nil) fmt.Println("Opening your browser to sign up for CircleCI...") @@ -159,68 +163,28 @@ func signupWithBrowser(cmd *cobra.Command, opts signupOptions, state string) err } } -func handleCallback(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - // The fragment (#token=...&state=...) is never sent to the server by the - // browser. So we serve a small page whose script reads the fragment and - // sends the values back to /complete. - // - // The frontend may also redirect with #error=token_creation_failed&state=... - // when PAT creation fails. The script detects this and forwards the error - // to /complete so the CLI can exit immediately instead of timing out. - fmt.Fprint(w, ` - -CircleCI CLI - -

Authenticating...

- - -`) + next(w, r) + } } -func handleComplete(expectedState string, tokenCh chan<- string, errCh chan<- error) http.HandlerFunc { +func handleToken(expectedState string, tokenCh chan<- string, errCh chan<- error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() token := query.Get("token") - state := query.Get("state") + state := query.Get("cli_state") callbackErr := query.Get("error") // When an error is present, state validation is best-effort: if state diff --git a/cmd/signup_unit_test.go b/cmd/signup_unit_test.go index db9960f1a..ad782f987 100644 --- a/cmd/signup_unit_test.go +++ b/cmd/signup_unit_test.go @@ -30,33 +30,59 @@ var _ = Describe("Signup", func() { }) }) - Describe("handleCallback", func() { - It("should return HTML with the authenticating message", func() { - req := httptest.NewRequest("GET", "/callback", nil) + Describe("corsMiddleware", func() { + var dummyHandler http.HandlerFunc + var handlerCalled bool + + BeforeEach(func() { + handlerCalled = false + dummyHandler = func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + } + }) + + It("should set CORS headers on a GET request", func() { + wrapped := corsMiddleware(dummyHandler) + req := httptest.NewRequest("GET", "/token", nil) rec := httptest.NewRecorder() - handleCallback(rec, req) + wrapped.ServeHTTP(rec, req) - body := rec.Body.String() + Expect(rec.Header().Get("Access-Control-Allow-Origin")).To(Equal("https://app.circleci.com")) + Expect(rec.Header().Get("Access-Control-Allow-Methods")).To(Equal("GET, OPTIONS")) + Expect(rec.Header().Get("Access-Control-Allow-Headers")).To(Equal("Content-Type")) + Expect(handlerCalled).To(BeTrue()) Expect(rec.Code).To(Equal(http.StatusOK)) - Expect(rec.Header().Get("Content-Type")).To(Equal("text/html; charset=utf-8")) - Expect(body).To(ContainSubstring("Authenticating...")) - Expect(body).To(ContainSubstring(`fetch("/complete?`)) }) - It("should include error-handling JavaScript", func() { - req := httptest.NewRequest("GET", "/callback", nil) + It("should return 204 on OPTIONS preflight without calling the handler", func() { + wrapped := corsMiddleware(dummyHandler) + req := httptest.NewRequest("OPTIONS", "/token", nil) + rec := httptest.NewRecorder() + + wrapped.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusNoContent)) + Expect(rec.Header().Get("Access-Control-Allow-Origin")).To(Equal("https://app.circleci.com")) + Expect(handlerCalled).To(BeFalse()) + }) + + It("should pin origin to app.circleci.com not wildcard", func() { + wrapped := corsMiddleware(dummyHandler) + req := httptest.NewRequest("GET", "/token", nil) rec := httptest.NewRecorder() - handleCallback(rec, req) + wrapped.ServeHTTP(rec, req) - body := rec.Body.String() - Expect(body).To(ContainSubstring(`params.get("error")`)) - Expect(body).To(ContainSubstring(`"error=no_token"`)) + origin := rec.Header().Get("Access-Control-Allow-Origin") + Expect(origin).To(Equal("https://app.circleci.com")) + Expect(origin).ToNot(Equal("*")) }) }) - Describe("handleComplete", func() { + Describe("handleToken", func() { var ( tokenCh chan string errCh chan error @@ -69,9 +95,9 @@ var _ = Describe("Signup", func() { state = "abc123" }) - It("should accept a valid token and state", func() { - handler := handleComplete(state, tokenCh, errCh) - req := httptest.NewRequest("GET", "/complete?token=mytoken&state=abc123", nil) + It("should accept a valid token and cli_state", func() { + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?token=mytoken&cli_state=abc123", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -81,9 +107,9 @@ var _ = Describe("Signup", func() { Eventually(tokenCh).Should(Receive(Equal("mytoken"))) }) - It("should reject a state mismatch", func() { - handler := handleComplete(state, tokenCh, errCh) - req := httptest.NewRequest("GET", "/complete?token=mytoken&state=wrong", nil) + It("should reject a cli_state mismatch", func() { + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?token=mytoken&cli_state=wrong", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -94,8 +120,8 @@ var _ = Describe("Signup", func() { }) It("should reject a missing token when no error param is present", func() { - handler := handleComplete(state, tokenCh, errCh) - req := httptest.NewRequest("GET", "/complete?state=abc123", nil) + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?cli_state=abc123", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -108,9 +134,9 @@ var _ = Describe("Signup", func() { }) Context("with error param from frontend", func() { - It("should forward the error when state matches", func() { - handler := handleComplete(state, tokenCh, errCh) - req := httptest.NewRequest("GET", "/complete?error=token_creation_failed&state=abc123", nil) + It("should forward the error when cli_state matches", func() { + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?error=token_creation_failed&cli_state=abc123", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -124,9 +150,9 @@ var _ = Describe("Signup", func() { Expect(received.Error()).To(ContainSubstring("circleci setup")) }) - It("should tolerate a missing state when error is present", func() { - handler := handleComplete(state, tokenCh, errCh) - req := httptest.NewRequest("GET", "/complete?error=no_token", nil) + It("should tolerate a missing cli_state when error is present", func() { + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?error=no_token", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -137,9 +163,9 @@ var _ = Describe("Signup", func() { Expect(received.Error()).To(ContainSubstring("no_token")) }) - It("should reject error with a mismatched state", func() { - handler := handleComplete(state, tokenCh, errCh) - req := httptest.NewRequest("GET", "/complete?error=token_creation_failed&state=wrong", nil) + It("should reject error with a mismatched cli_state", func() { + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?error=token_creation_failed&cli_state=wrong", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) From e9855989668ebca378cbd2188fd46ba14076dcee Mon Sep 17 00:00:00 2001 From: fabio ramirez Date: Wed, 25 Mar 2026 12:19:58 -0600 Subject: [PATCH 3/3] Update root_test.go subcommand count from 29 to 30 for new signup command Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/root_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root_test.go b/cmd/root_test.go index bdcc6f534..dc4209e2e 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -16,7 +16,7 @@ var _ = Describe("Root", func() { Describe("subcommands", func() { It("can create commands", func() { commands := cmd.MakeCommands() - Expect(len(commands.Commands())).To(Equal(29)) + Expect(len(commands.Commands())).To(Equal(30)) }) })