From b55e6a98c2276f50e114213997056c0760095215 Mon Sep 17 00:00:00 2001 From: matthewkmartin Date: Tue, 3 Nov 2026 09:15:48 -0500 Subject: [PATCH] BUILD-43 Make account registration output conditional on acceptance register and send-code exit 0 when the request is accepted, which does not guarantee that an email or code was sent. Their success output claimed both unconditionally, so a mistyped --email/--phone or an existing account left users waiting on something that never arrives. register and send-code now say a message or code is on its way only if the details match, and point existing customers at "band auth login" or a password reset. verify adds a hint when the API rejects the request with a 400, since a mistyped --email or --phone is reported the same way as a wrong code. README.md and AGENTS.md describe the new behavior, including guidance for agents on not reporting delivery as fact. Add testutil.FakeClient.PostErr and testutil.CaptureStderr so tests can cover failures and the stderr messages. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 12 +++- README.md | 2 + cmd/account/messages_test.go | 131 +++++++++++++++++++++++++++++++++++ cmd/account/register.go | 12 +++- cmd/account/send_code.go | 5 +- cmd/account/verify.go | 13 ++++ internal/testutil/golden.go | 21 +++++- 7 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 cmd/account/messages_test.go diff --git a/AGENTS.md b/AGENTS.md index bf03cd3..ebd32e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -420,10 +420,10 @@ agent with email/web access) to finish. ```bash band account register --phone +15555550100 --email you@example.com --first-name Jane --last-name Doe --accept-tos -# → registration submitted (POST /v1/build/registrations) +# → registration request received (POST /v1/build/registrations) band account send-code --phone +15555550100 --email you@example.com --delivery-channel sms # or "voice" -# → verification code sent. Choosing "sms" IS the customer's consent to +# → verification code requested. Choosing "sms" IS the customer's consent to # receive that one-time code by text — there is no separate flag for it. # STOP: the code is delivered out-of-band (a text message or phone call to @@ -448,6 +448,14 @@ band auth status # confirm 2. Ask the user to review and confirm they accept 3. Only after confirmation, run the command with `--accept-tos` +**Success output does not confirm delivery.** `register` and `send-code` exit 0 when the request is accepted, whether or not an email or code was actually sent. Tell the user a code or email was *requested* and ask them to confirm it arrived; never report it as sent. If the user says nothing arrived after a few minutes: + +- Check that `--email` and `--phone` match what they registered with (a mismatch sends nothing), and re-run `band account register` if the registration may have expired. +- If they may already have a Bandwidth account, send them to `band auth login`, or to to reset the password. +- Do not loop on `send-code`. + +`band account verify` fails with `Code supplied is not valid` for a wrong code **and** for a mismatched `--email`/`--phone`. Check the flags before retrying, and never guess codes. + After calling `band account verify`, stop and tell the user they need to check their email to set a password before generating API credentials. Do not attempt to poll or wait for that step — the next CLI step (`band auth login`) requires credentials that are only available after the human finishes the browser flow. **After login, the account already has a voice app and a phone number.** Build accounts ship with both pre-provisioned. Run `band app list --plain` to discover the voice app — do **not** call `app create` or `number order` on a fresh Build account, you already have what you need to make a call. (`band number list` doesn't work on Build yet; the pre-provisioned number is reachable via the account portal and already wired to the default voice app.) diff --git a/README.md b/README.md index ec8f6f8..ad6cc4e 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ Then finish setup in your browser: Once your credentials are ready, run `band auth login` and you're off. +The CLI confirms that each request was accepted, not that an email or code was actually sent. If nothing arrives, check that `--email` and `--phone` match what you registered with, and run `band account register` again if it's been a while. If you already have a Bandwidth account, run `band auth login` or reset your password at [passport.bandwidth.com/forgot-password](https://passport.bandwidth.com/forgot-password). + **What you get:** Every Build account ships with a voice application and a phone number already provisioned. Run `band auth status` to confirm your account type and capabilities, then `band app list` to see your pre-provisioned voice app — then you're ready to [make a call](#make-a-call). (Your pre-provisioned number is also visible in the Bandwidth App.) **Build is voice-only.** Messaging, number ordering, sub-accounts, VCPs, 10DLC, and toll-free verification all require a full Bandwidth account. If you try one of those commands on a Build account, the CLI fails fast (exit code 4) and points you at the upgrade path. For current Build pricing, credit costs, and trial limits, see [dev.bandwidth.com](https://dev.bandwidth.com/docs/voice/programmable-voice/build-free-trial). [Talk to an expert](https://www.bandwidth.com/talk-to-an-expert/) when you're ready to upgrade. diff --git a/cmd/account/messages_test.go b/cmd/account/messages_test.go new file mode 100644 index 0000000..1b5d2e3 --- /dev/null +++ b/cmd/account/messages_test.go @@ -0,0 +1,131 @@ +package account + +import ( + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/testutil" +) + +// runForStderr executes child against fake and returns what it wrote to stderr +// along with its error. Stdout is captured too so it doesn't leak into test output. +func runForStderr(t *testing.T, fake *testutil.FakeClient, child *cobra.Command, args ...string) (string, error) { + t.Helper() + swapRegistrationClient(t, fake) + root := testutil.NewTestRoot(child) + root.SetArgs(append(args, "--plain")) + var err error + stderr := testutil.CaptureStderr(t, func() { + testutil.CaptureStdout(t, func() { err = root.Execute() }) + }) + return stderr, err +} + +func okResult() *testutil.FakeClient { + return &testutil.FakeClient{PostResult: map[string]interface{}{ + "data": map[string]interface{}{"status": "OK"}, + "errors": []interface{}{}, + }} +} + +func assertContains(t *testing.T, got string, wants ...string) { + t.Helper() + for _, want := range wants { + if !strings.Contains(got, want) { + t.Errorf("stderr missing %q\n got: %q", want, got) + } + } +} + +func assertNotContains(t *testing.T, got string, unwanted ...string) { + t.Helper() + for _, s := range unwanted { + if strings.Contains(got, s) { + t.Errorf("stderr unexpectedly contains %q\n got: %q", s, got) + } + } +} + +// Success only means the request was accepted, so the wording must stay +// conditional and point existing customers somewhere useful. +func TestRegisterOutputIsConditional(t *testing.T) { + stderr, err := runForStderr(t, okResult(), registerCmd, + "register", + "--phone", "+19195551234", + "--email", "user@example.com", + "--first-name", "Jane", + "--last-name", "Doe", + "--accept-tos", + ) + if err != nil { + t.Fatalf("execute: %v", err) + } + + assertContains(t, stderr, + "If this email can be used to create an account", + "user@example.com", + "band auth login", + "https://passport.bandwidth.com/forgot-password", + ) + assertNotContains(t, stderr, "Registration submitted!") +} + +func TestSendCodeOutputIsConditional(t *testing.T) { + stderr, err := runForStderr(t, okResult(), sendCodeCmd, + "send-code", + "--phone", "+19195551234", + "--email", "user@example.com", + "--delivery-channel", "sms", + ) + if err != nil { + t.Fatalf("execute: %v", err) + } + + assertContains(t, stderr, + "If a pending registration matches", + "via sms", + "band account register", + ) + assertNotContains(t, stderr, "Verification code sent via") +} + +func TestVerifyFailureHint(t *testing.T) { + const hint = "match the ones you registered with" + tests := []struct { + name string + err error + wantHint bool + }{ + {name: "rejected code", err: &api.APIError{StatusCode: 400, Body: "Code supplied is not valid."}, wantHint: true}, + {name: "server error", err: &api.APIError{StatusCode: 500}, wantHint: false}, + {name: "network error", err: errors.New("connection refused"), wantHint: false}, + {name: "success", err: nil, wantHint: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := okResult() + fake.PostErr = tt.err + + stderr, err := runForStderr(t, fake, verifyCmd, + "verify", + "--phone", "+19195551234", + "--email", "user@example.com", + "--code", "123456", + ) + + if (err != nil) != (tt.err != nil) { + t.Fatalf("execute error = %v, want error: %v", err, tt.err != nil) + } + if tt.wantHint { + assertContains(t, stderr, hint, "band account register") + } else { + assertNotContains(t, stderr, hint) + } + }) + } +} diff --git a/cmd/account/register.go b/cmd/account/register.go index e195aac..44c3331 100644 --- a/cmd/account/register.go +++ b/cmd/account/register.go @@ -23,7 +23,10 @@ var ( registerSmsOptIn bool ) -const tosURL = "https://www.bandwidth.com/legal/build-terms-of-service/" +const ( + tosURL = "https://www.bandwidth.com/legal/build-terms-of-service/" + forgotPasswordURL = "https://passport.bandwidth.com/forgot-password" +) // registrationClient is a swappable ClientFunc seam for tests (see cmdutil.VoiceClient); accountIDOverride is unused — no account exists yet. var registrationClient cmdutil.ClientFunc = func(string) (api.Requester, string, error) { @@ -119,14 +122,17 @@ func runRegister(cmd *cobra.Command, args []string) error { return err } + // Success only means the request was accepted, so the wording stays conditional. fmt.Fprintln(os.Stderr) - ui.Successf("Registration submitted!") + ui.Successf("Registration request received.") + ui.Infof("If this email can be used to create an account, a registration link is on its way to %s.", registerEmail) + ui.Infof("Already have a Bandwidth account? Run \"band auth login\", or reset your password at %s", forgotPasswordURL) ui.Headerf("Next steps:") ui.Infof("1. Verify your phone number:") ui.Infof(" band account send-code --phone %s --email %s --delivery-channel sms", registerPhone, registerEmail) ui.Infof(" band account verify --phone %s --email %s --code ", registerPhone, registerEmail) ui.Infof(" (pass --delivery-channel voice on send-code for a phone call instead of a text)") - ui.Infof("2. Check your email (%s) for a registration link from Bandwidth to set your password", registerEmail) + ui.Infof("2. If this is a new account, check your email (%s) for a registration link from Bandwidth to set your password", registerEmail) ui.Infof("3. Go to Account > API Credentials to generate your OAuth2 credentials") ui.Infof("4. Run: band auth login --client-id --client-secret ") diff --git a/cmd/account/send_code.go b/cmd/account/send_code.go index ab1b16d..8ee10bc 100644 --- a/cmd/account/send_code.go +++ b/cmd/account/send_code.go @@ -80,8 +80,11 @@ func runSendCode(cmd *cobra.Command, args []string) error { return err } + // Success only means the request was accepted, so the wording stays conditional. fmt.Fprintln(os.Stderr) - ui.Successf("Verification code sent via %s", strings.ToLower(channel)) + ui.Successf("Verification code requested") + ui.Infof("If a pending registration matches this email and phone number, a code is on its way via %s.", strings.ToLower(channel)) + ui.Infof("No code? Check that --email and --phone match what you registered with, or run \"band account register\" again.") ui.Infof("Next: band account verify --phone %s --email %s --code ", sendCodePhone, sendCodeEmail) return nil diff --git a/cmd/account/verify.go b/cmd/account/verify.go index 1396dd6..f2a4ed0 100644 --- a/cmd/account/verify.go +++ b/cmd/account/verify.go @@ -1,11 +1,14 @@ package account import ( + "errors" "fmt" + "net/http" "os" "github.com/spf13/cobra" + "github.com/Bandwidth/cli/internal/api" "github.com/Bandwidth/cli/internal/cmdutil" "github.com/Bandwidth/cli/internal/output" "github.com/Bandwidth/cli/internal/ui" @@ -40,6 +43,15 @@ has no CLI equivalent.`, RunE: runVerify, } +// hintOnRejectedCode points at the likely causes of a 400, since a mistyped +// --email or --phone is reported the same way as a wrong code. +func hintOnRejectedCode(err error) { + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest { + ui.Infof("Check that --email and --phone match the ones you registered with. If it has been a while, run \"band account register\" again.") + } +} + func runVerify(cmd *cobra.Command, args []string) error { client, _, err := registrationClient("") if err != nil { @@ -54,6 +66,7 @@ func runVerify(cmd *cobra.Command, args []string) error { var result interface{} if err := client.Post(cmd.Context(), "/registrations/code/verify", reqBody, &result); err != nil { + hintOnRejectedCode(err) return fmt.Errorf("verifying phone number: %w", err) } diff --git a/internal/testutil/golden.go b/internal/testutil/golden.go index 55a3b03..850a1ca 100644 --- a/internal/testutil/golden.go +++ b/internal/testutil/golden.go @@ -20,6 +20,7 @@ type FakeClient struct { GetResult interface{} PostResult interface{} + PostErr error // when set, Post returns it after recording path/body PostPath string // path from the most recent Post call PostBody interface{} // body from the most recent Post call } @@ -31,6 +32,9 @@ func (f *FakeClient) Get(_ context.Context, path string, result interface{}) err func (f *FakeClient) Post(_ context.Context, path string, body, result interface{}) error { f.PostPath = path f.PostBody = body + if f.PostErr != nil { + return f.PostErr + } if result == nil { return nil } @@ -58,15 +62,26 @@ func NewTestRoot(child *cobra.Command) *cobra.Command { // CaptureStdout runs fn while capturing everything written to os.Stdout. func CaptureStdout(t *testing.T, fn func()) string { t.Helper() - orig := os.Stdout + return capture(t, &os.Stdout, fn) +} + +// CaptureStderr runs fn while capturing everything written to os.Stderr. +func CaptureStderr(t *testing.T, fn func()) string { + t.Helper() + return capture(t, &os.Stderr, fn) +} + +func capture(t *testing.T, target **os.File, fn func()) string { + t.Helper() + orig := *target r, w, err := os.Pipe() if err != nil { t.Fatal(err) } - os.Stdout = w + *target = w fn() w.Close() - os.Stdout = orig + *target = orig out, err := io.ReadAll(r) if err != nil { t.Fatal(err)