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)