From 1ac800c332e7b8ae70a9ca56c325a5d78de62ab5 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Fri, 6 Nov 2026 05:12:44 +0530 Subject: [PATCH 1/4] feat(poppy): add the auth0 poppy commands Adds `auth0 poppy setup`, `config` and `doctor`, and `auth0 poppy agents register`, `list`, `show` and `remove`. Setup shows a grouped plan with before and after values, asks the questions it needs one at a time in a terminal, and applies the plan. Output is rendered through internal/display and the command docs are included. The commands are marked experimental. The agents commands need the read:agents, create:agents and delete:agents scopes, which the README explains. --- README.md | 11 + docs/auth0_poppy.md | 18 + docs/auth0_poppy_agents.md | 18 + docs/auth0_poppy_agents_list.md | 53 + docs/auth0_poppy_agents_register.md | 59 + docs/auth0_poppy_agents_remove.md | 55 + docs/auth0_poppy_agents_show.md | 52 + docs/auth0_poppy_config.md | 60 + docs/auth0_poppy_doctor.md | 53 + docs/auth0_poppy_setup.md | 69 + docs/index.md | 1 + internal/cli/cli.go | 4 + internal/cli/poppy.go | 1528 +++++++++ internal/cli/poppy_agents.go | 671 ++++ internal/cli/poppy_agents_test.go | 874 +++++ internal/cli/poppy_test.go | 2979 +++++++++++++++++ internal/cli/root.go | 7 +- internal/cli/root_test.go | 25 + internal/display/poppy.go | 1242 +++++++ internal/display/poppy_agents.go | 347 ++ internal/display/poppy_agents_test.go | 299 ++ internal/display/poppy_layout.go | 324 ++ internal/display/poppy_layout_test.go | 297 ++ internal/display/poppy_test.go | 962 ++++++ .../apply-partial-failure-60.golden.txt | 19 + .../testdata/apply-partial-failure.golden.txt | 17 + .../testdata/apply-success-ascii.golden.txt | 6 + .../display/testdata/apply-success.golden.txt | 6 + ...s-show-three-and-count-the-rest.golden.txt | 5 + .../hints-five-hints-are-all-shown.golden.txt | 6 + .../display/testdata/hints-narrow.golden.txt | 5 + .../testdata/hints-three-hints.golden.txt | 4 + .../testdata/plan-before-apply.golden.txt | 46 + .../testdata/plan-dry-run-100.golden.txt | 52 + .../testdata/plan-dry-run-60.golden.txt | 64 + .../testdata/plan-dry-run-ascii.golden.txt | 55 + .../testdata/plan-dry-run-pipe.golden.txt | 46 + .../display/testdata/plan-dry-run.golden.txt | 55 + .../testdata/plan-no-custom-domain.golden.txt | 49 + .../plan-nothing-to-change.golden.txt | 8 + .../testdata/plan-several-apis.golden.txt | 61 + .../plan-unchanged-collapsed.golden.txt | 25 + .../testdata/plan-unknown-before.golden.txt | 22 + .../display/testdata/report-ascii.golden.txt | 13 + .../testdata/report-escapes.golden.txt | 9 + .../display/testdata/report-fail.golden.txt | 15 + .../display/testdata/report-green.golden.txt | 38 + internal/display/testdata/report-green.json | 145 + .../report-no-header-details.golden.txt | 6 + .../display/testdata/report-other.golden.txt | 11 + .../testdata/report-skipped-tenant.golden.txt | 11 + .../display/testdata/report-warn.golden.txt | 11 + .../testdata/report-width-100.golden.txt | 19 + .../testdata/report-width-60.golden.txt | 23 + 54 files changed, 10859 insertions(+), 1 deletion(-) create mode 100644 docs/auth0_poppy.md create mode 100644 docs/auth0_poppy_agents.md create mode 100644 docs/auth0_poppy_agents_list.md create mode 100644 docs/auth0_poppy_agents_register.md create mode 100644 docs/auth0_poppy_agents_remove.md create mode 100644 docs/auth0_poppy_agents_show.md create mode 100644 docs/auth0_poppy_config.md create mode 100644 docs/auth0_poppy_doctor.md create mode 100644 docs/auth0_poppy_setup.md create mode 100644 internal/cli/poppy.go create mode 100644 internal/cli/poppy_agents.go create mode 100644 internal/cli/poppy_agents_test.go create mode 100644 internal/cli/poppy_test.go create mode 100644 internal/display/poppy.go create mode 100644 internal/display/poppy_agents.go create mode 100644 internal/display/poppy_agents_test.go create mode 100644 internal/display/poppy_layout.go create mode 100644 internal/display/poppy_layout_test.go create mode 100644 internal/display/poppy_test.go create mode 100644 internal/display/testdata/apply-partial-failure-60.golden.txt create mode 100644 internal/display/testdata/apply-partial-failure.golden.txt create mode 100644 internal/display/testdata/apply-success-ascii.golden.txt create mode 100644 internal/display/testdata/apply-success.golden.txt create mode 100644 internal/display/testdata/hints-eight-hints-show-three-and-count-the-rest.golden.txt create mode 100644 internal/display/testdata/hints-five-hints-are-all-shown.golden.txt create mode 100644 internal/display/testdata/hints-narrow.golden.txt create mode 100644 internal/display/testdata/hints-three-hints.golden.txt create mode 100644 internal/display/testdata/plan-before-apply.golden.txt create mode 100644 internal/display/testdata/plan-dry-run-100.golden.txt create mode 100644 internal/display/testdata/plan-dry-run-60.golden.txt create mode 100644 internal/display/testdata/plan-dry-run-ascii.golden.txt create mode 100644 internal/display/testdata/plan-dry-run-pipe.golden.txt create mode 100644 internal/display/testdata/plan-dry-run.golden.txt create mode 100644 internal/display/testdata/plan-no-custom-domain.golden.txt create mode 100644 internal/display/testdata/plan-nothing-to-change.golden.txt create mode 100644 internal/display/testdata/plan-several-apis.golden.txt create mode 100644 internal/display/testdata/plan-unchanged-collapsed.golden.txt create mode 100644 internal/display/testdata/plan-unknown-before.golden.txt create mode 100644 internal/display/testdata/report-ascii.golden.txt create mode 100644 internal/display/testdata/report-escapes.golden.txt create mode 100644 internal/display/testdata/report-fail.golden.txt create mode 100644 internal/display/testdata/report-green.golden.txt create mode 100644 internal/display/testdata/report-green.json create mode 100644 internal/display/testdata/report-no-header-details.golden.txt create mode 100644 internal/display/testdata/report-other.golden.txt create mode 100644 internal/display/testdata/report-skipped-tenant.golden.txt create mode 100644 internal/display/testdata/report-warn.golden.txt create mode 100644 internal/display/testdata/report-width-100.golden.txt create mode 100644 internal/display/testdata/report-width-60.golden.txt diff --git a/README.md b/README.md index 123477c79..e0aea2a95 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,16 @@ auth0 login --domain --client-id --client-secret --client-id --client-assertion-private-key --client-assertion-signing-alg ``` +### Extra scopes for `auth0 poppy agents` + +`auth0 poppy setup`, `config` and `doctor` work with the default login scopes. The `auth0 poppy agents` commands also need the `read:agents`, `create:agents` and `delete:agents` scopes, which are not requested by default because Agent as Principal is still in limited early access. If your tenant has it, log in again with them: + +```bash +auth0 login --scopes read:agents,create:agents,delete:agents +``` + +If a scope is missing, the command stops before changing anything and prints the exact `auth0 login --scopes ...` command to run. + ### Re-authentication When your access token expires, the CLI will prompt you to confirm whether to continue with your default tenant or select a different one: @@ -282,6 +292,7 @@ Select **y** to proceed with your default tenant, or **N** to choose a different - [auth0 logout](https://auth0.github.io/auth0-cli/auth0_logout.html) - Log out of a tenant's session - [auth0 logs](https://auth0.github.io/auth0-cli/auth0_logs.html) - View tenant logs - [auth0 orgs](https://auth0.github.io/auth0-cli/auth0_orgs.html) - Manage resources for organizations +- [auth0 poppy](https://auth0.github.io/auth0-cli/auth0_poppy.html) - (Experimental) Prepare your company for personal agents with the Personal Agent Protocol (Poppy) - [auth0 protection](https://auth0.github.io/auth0-cli/auth0_protection.html) - Manage resources for attack protection - [auth0 quickstarts](https://auth0.github.io/auth0-cli/auth0_quickstarts.html) - Quickstart support for getting bootstrapped - [auth0 refresh-tokens](https://auth0.github.io/auth0-cli/auth0_refresh-tokens.html) - Manage resources for refresh tokens diff --git a/docs/auth0_poppy.md b/docs/auth0_poppy.md new file mode 100644 index 000000000..379858461 --- /dev/null +++ b/docs/auth0_poppy.md @@ -0,0 +1,18 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 poppy + +(Experimental) Prepares your company for personal agents, using the Personal Agent Protocol (Poppy). Poppy settings are spread across the tenant, an API and a web app, and the discovery files on your website must repeat Auth0's values exactly. These commands set that up, write one config file, a `poppy.json` document, for your web and API SDKs, manage the agents you allow, and check the whole chain before an agent arrives. + +**Warning:** This command is experimental and is subject to change in future versions. + +## Commands + +- [auth0 poppy agents](auth0_poppy_agents.md) - Manage the agents your company allows +- [auth0 poppy config](auth0_poppy_config.md) - Rebuild the Poppy config file from the tenant +- [auth0 poppy doctor](auth0_poppy_doctor.md) - Check the Poppy discovery chain end to end +- [auth0 poppy setup](auth0_poppy_setup.md) - Turn on Poppy for the tenant, an API and a web app + diff --git a/docs/auth0_poppy_agents.md b/docs/auth0_poppy_agents.md new file mode 100644 index 000000000..b2f17c144 --- /dev/null +++ b/docs/auth0_poppy_agents.md @@ -0,0 +1,18 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 poppy agents + +(Experimental) Manages the personal agents your company allows. In the beta, Auth0 accepts only agents your company registers, so the registered list is the allow list. A Poppy agent is an agent app with a grant to a Poppy API. + +**Warning:** This command is experimental and is subject to change in future versions. + +## Commands + +- [auth0 poppy agents list](auth0_poppy_agents_list.md) - List the registered agents +- [auth0 poppy agents register](auth0_poppy_agents_register.md) - Register an agent so it can sign in to your company +- [auth0 poppy agents remove](auth0_poppy_agents_remove.md) - Remove an agent's Poppy access +- [auth0 poppy agents show](auth0_poppy_agents_show.md) - Show the details of an agent + diff --git a/docs/auth0_poppy_agents_list.md b/docs/auth0_poppy_agents_list.md new file mode 100644 index 000000000..cadb061da --- /dev/null +++ b/docs/auth0_poppy_agents_list.md @@ -0,0 +1,53 @@ +--- +layout: default +parent: auth0 poppy agents +has_toc: false +--- +# auth0 poppy agents list + +(Experimental) Lists the Poppy agents registered with your company, with their metadata status and the APIs each one can use. By default it shows only agents that have a grant to a Poppy API; use `--all` to include the others. + +**Warning:** This command is experimental and is subject to change in future versions. + +## Usage +``` +auth0 poppy agents list [flags] +``` + +## Examples + +``` + auth0 poppy agents list + auth0 poppy agents list --all + auth0 poppy agents list --json +``` + + +## Flags + +``` + --all Include agents that have no grant to a Poppy API. + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 poppy agents list](auth0_poppy_agents_list.md) - List the registered agents +- [auth0 poppy agents register](auth0_poppy_agents_register.md) - Register an agent so it can sign in to your company +- [auth0 poppy agents remove](auth0_poppy_agents_remove.md) - Remove an agent's Poppy access +- [auth0 poppy agents show](auth0_poppy_agents_show.md) - Show the details of an agent + + diff --git a/docs/auth0_poppy_agents_register.md b/docs/auth0_poppy_agents_register.md new file mode 100644 index 000000000..890a85f98 --- /dev/null +++ b/docs/auth0_poppy_agents_register.md @@ -0,0 +1,59 @@ +--- +layout: default +parent: auth0 poppy agents +has_toc: false +--- +# auth0 poppy agents register + +(Experimental) Registers an agent from the URL of its metadata document. The command previews the document, creates or updates the agent app, creates the agent record and grants the agent your Poppy APIs. It changes the tenant only: the config file is not written, and is read only to show the hints of the scopes on offer. By default it grants every Poppy API of the tenant. In a terminal it asks which scopes to grant; otherwise pass `--scopes` or `--all-scopes`. DPoP is required only with `--require-dpop`. Running it again refreshes the agent app from the current document and changes nothing else, which is also how you pick up keys the agent has rotated. + +**Warning:** This command is experimental and is subject to change in future versions. + +## Usage +``` +auth0 poppy agents register [agent-url] [flags] +``` + +## Examples + +``` + auth0 poppy agents register https://muse.example/agent.json + auth0 poppy agents register https://muse.example/agent.json --api https://api.shop.example --scopes orders:read --yes + auth0 poppy agents register https://muse.example/agent.json --all-scopes --require-dpop +``` + + +## Flags + +``` + --all-scopes Grant every offered scope. Mutually exclusive with --scopes. + --api stringArray A Poppy API, by id or identifier. Repeat for several. When registering an agent, the Poppy APIs to grant (default: every Poppy API of the tenant). + --json Output in json format. + --json-compact Output in compact json format. + --name string The Company's name for the config file. When registering an agent, the agent's name. + --out string Path of the config file. (default "auth0-poppy.json") + --require-dpop Require DPoP-bound tokens for the agent. Off by default. + --scopes strings Comma-separated list of scopes to grant. Without a terminal, pass this or --all-scopes. Mutually exclusive with --all-scopes. + --yes Skip confirmation. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 poppy agents list](auth0_poppy_agents_list.md) - List the registered agents +- [auth0 poppy agents register](auth0_poppy_agents_register.md) - Register an agent so it can sign in to your company +- [auth0 poppy agents remove](auth0_poppy_agents_remove.md) - Remove an agent's Poppy access +- [auth0 poppy agents show](auth0_poppy_agents_show.md) - Show the details of an agent + + diff --git a/docs/auth0_poppy_agents_remove.md b/docs/auth0_poppy_agents_remove.md new file mode 100644 index 000000000..a25e20e54 --- /dev/null +++ b/docs/auth0_poppy_agents_remove.md @@ -0,0 +1,55 @@ +--- +layout: default +parent: auth0 poppy agents +has_toc: false +--- +# auth0 poppy agents remove + +(Experimental) Removes the agent's Poppy access: its grants to your Poppy APIs. It changes the tenant only, and does not touch the config file. When the agent has no other grants it also removes the agent record and the app, so registering the agent again creates a new app and its users must sign in again. When the app has other grants, only the Poppy access is removed and the other grants are listed, unless you pass `--remove-all-grants`; in a terminal you are asked which to do. This command does not revoke tokens already issued: access tokens stay valid until they expire. Use `--dry-run` to print what would be removed, and `--force` to skip the confirmation. + +**Warning:** This command is experimental and is subject to change in future versions. + +## Usage +``` +auth0 poppy agents remove [agent-url|agent-id] [flags] +``` + +## Examples + +``` + auth0 poppy agents remove https://muse.example/agent.json + auth0 poppy agents remove https://muse.example/agent.json --dry-run + auth0 poppy agents remove --remove-all-grants --force +``` + + +## Flags + +``` + --dry-run Print the plan and change nothing. + --force Skip confirmation. + --json Output in json format. + --json-compact Output in compact json format. + --remove-all-grants Also delete the app's other grants, then the agent record and the app. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 poppy agents list](auth0_poppy_agents_list.md) - List the registered agents +- [auth0 poppy agents register](auth0_poppy_agents_register.md) - Register an agent so it can sign in to your company +- [auth0 poppy agents remove](auth0_poppy_agents_remove.md) - Remove an agent's Poppy access +- [auth0 poppy agents show](auth0_poppy_agents_show.md) - Show the details of an agent + + diff --git a/docs/auth0_poppy_agents_show.md b/docs/auth0_poppy_agents_show.md new file mode 100644 index 000000000..fc6815b82 --- /dev/null +++ b/docs/auth0_poppy_agents_show.md @@ -0,0 +1,52 @@ +--- +layout: default +parent: auth0 poppy agents +has_toc: false +--- +# auth0 poppy agents show + +(Experimental) Shows one agent's details, given its URL or agent ID. It also checks the agent's metadata document live and tells you whether the keys Auth0 stored are still current, so you know when the agent needs to be registered again. + +**Warning:** This command is experimental and is subject to change in future versions. + +## Usage +``` +auth0 poppy agents show [agent-url|agent-id] [flags] +``` + +## Examples + +``` + auth0 poppy agents show https://muse.example/agent.json + auth0 poppy agents show + auth0 poppy agents show https://muse.example/agent.json --json +``` + + +## Flags + +``` + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 poppy agents list](auth0_poppy_agents_list.md) - List the registered agents +- [auth0 poppy agents register](auth0_poppy_agents_register.md) - Register an agent so it can sign in to your company +- [auth0 poppy agents remove](auth0_poppy_agents_remove.md) - Remove an agent's Poppy access +- [auth0 poppy agents show](auth0_poppy_agents_show.md) - Show the details of an agent + + diff --git a/docs/auth0_poppy_config.md b/docs/auth0_poppy_config.md new file mode 100644 index 000000000..2921a1e1f --- /dev/null +++ b/docs/auth0_poppy_config.md @@ -0,0 +1,60 @@ +--- +layout: default +parent: auth0 poppy +has_toc: false +--- +# auth0 poppy config + +(Experimental) Rebuilds the config file from the tenant without changing the tenant, so CI and deploys can refresh it safely. It reads the tenant, adds the values Auth0 does not store, and writes the file. If the tenant is not set up for Poppy, it exits with an error naming the missing setting. The config file is for one website, so with several company domains pass `--company-domain` to pick the one this app serves. The file is a protocol 0.4 `poppy.json` document and nothing else, so your web SDK can serve it as `/.well-known/poppy.json`. A file written for 0.3 is upgraded, and members of the file that the CLI does not use are kept. The file holds no API identifier and no web app, so without `--api` the APIs are the Poppy APIs of the tenant that the `apis` entries of the existing file are at, and without `--app` the web app is the only app of the tenant that has the browser join on. When that does not settle on one, the command stops and asks for `--api` or `--app`. + +**Warning:** This command is experimental and is subject to change in future versions. + +## Usage +``` +auth0 poppy config [flags] +``` + +## Examples + +``` + auth0 poppy config + auth0 poppy config --company-domain shop.example --out ./config/auth0-poppy.json + auth0 poppy config --company-domain shop.example --api https://api.shop.example --app "Shop Web" --json +``` + + +## Flags + +``` + --api stringArray A Poppy API, by id or identifier. Repeat for several. When registering an agent, the Poppy APIs to grant (default: every Poppy API of the tenant). + --api-url stringArray Where an API's OpenAPI document lives, as =. Repeat for several. Asked if the config file has no entry for the API, with /openapi.json as the default when the identifier is a URL. + --app string Client ID or name of the web app used for the browser join. + --company-domain string The Company's website host, such as shop.example. With several company domains, picks the one this app serves. + --force-config Overwrite a config file that belongs to a different issuer. + --hint stringArray Hints for a scope, as name=changes:irreversible,personal_data:true. Repeat for several. + --json Output in json format. + --json-compact Output in compact json format. + --name string The Company's name for the config file. When registering an agent, the agent's name. + --out string Path of the config file. (default "auth0-poppy.json") +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 poppy agents](auth0_poppy_agents.md) - Manage the agents your company allows +- [auth0 poppy config](auth0_poppy_config.md) - Rebuild the Poppy config file from the tenant +- [auth0 poppy doctor](auth0_poppy_doctor.md) - Check the Poppy discovery chain end to end +- [auth0 poppy setup](auth0_poppy_setup.md) - Turn on Poppy for the tenant, an API and a web app + + diff --git a/docs/auth0_poppy_doctor.md b/docs/auth0_poppy_doctor.md new file mode 100644 index 000000000..4971674c6 --- /dev/null +++ b/docs/auth0_poppy_doctor.md @@ -0,0 +1,53 @@ +--- +layout: default +parent: auth0 poppy +has_toc: false +--- +# auth0 poppy doctor + +(Experimental) Checks the whole discovery chain without changing anything: the files on your website, Auth0's metadata, the tenant settings and each registered agent. Each failure comes with the spec section and a FIX line telling you what to change. It understands sites on protocol 0.3 and 0.4, and runs the extra checks of 0.4 (D15 to D17) only on sites that declare it. The checks that only need the company domain (D1 to D7, and D15 to D17) work without a login, so they also work against other companies. The checks that read your tenant (D8 to D13: tenant settings, the web app, the MFA policy, and each agent's metadata, keys and grants, and D14, which compares the config file with the tenant) run when you are logged in to a tenant, or use the environment credentials of `AUTH0_CLI_AUTH_MODE=env`, and show as skipped otherwise. This command never starts a login by itself: an expired or missing session only skips those checks. They read the tenant with `read:tenant_settings`, `read:clients`, `read:mfa_policies`, `read:client_grants`, `read:agents`, `read:resource_servers` and `read:client_credentials`; a check whose scope is missing is skipped with the login command that adds it. The command exits with 1 when a check fails. + +**Warning:** This command is experimental and is subject to change in future versions. + +## Usage +``` +auth0 poppy doctor [domain] [flags] +``` + +## Examples + +``` + auth0 poppy doctor shop.example + auth0 poppy doctor shop.example --config ./config/auth0-poppy.json + auth0 poppy doctor shop.example --json-compact +``` + + +## Flags + +``` + --config string Config file to check. Used only if it exists. (default "auth0-poppy.json") + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 poppy agents](auth0_poppy_agents.md) - Manage the agents your company allows +- [auth0 poppy config](auth0_poppy_config.md) - Rebuild the Poppy config file from the tenant +- [auth0 poppy doctor](auth0_poppy_doctor.md) - Check the Poppy discovery chain end to end +- [auth0 poppy setup](auth0_poppy_setup.md) - Turn on Poppy for the tenant, an API and a web app + + diff --git a/docs/auth0_poppy_setup.md b/docs/auth0_poppy_setup.md new file mode 100644 index 000000000..ef12de36e --- /dev/null +++ b/docs/auth0_poppy_setup.md @@ -0,0 +1,69 @@ +--- +layout: default +parent: auth0 poppy +has_toc: false +--- +# auth0 poppy setup + +(Experimental) Turns on the Poppy settings for the tenant, an existing API and an existing web app, and writes the config file. The API and the web app must already exist, and their scopes, MFA policy and Actions are left alone. The command shows a plan first and marks the tenant-wide changes, then applies it once you confirm; use `--dry-run` to see the plan only, and `--yes` to skip the question, which is required without a terminal. In a terminal it asks for any required value you did not pass, such as the OpenAPI URL of an API or the login route of the web app. Running it again changes nothing when the tenant already matches, or continues where a failed run stopped. It finishes by running `auth0 poppy doctor`, including the checks that read the tenant, unless `--skip-doctor` is set; a failed check does not fail setup, because your website may not serve the config file until you deploy it. With `--guest-api-access` the guest grant carries only the scopes you choose, with `--guest-scopes` or `--guest-all-scopes`; in a terminal setup asks, and it also asks whether to let signed-out guests use the API when no guest grant exists yet. Setup adds the scope `poppy:browser-sign-in` to each API when it is missing, with a default description unless you give one with `--scope`; every other scope offered to agents needs a description, which a terminal asks for and otherwise comes from `--scope =`. + +**Warning:** This command is experimental and is subject to change in future versions. + +## Usage +``` +auth0 poppy setup [flags] +``` + +## Examples + +``` + auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app "Shop Web" --scope orders:read="See your orders and their status. Read only." + auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app "Shop Web" --dry-run + auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app "Shop Web" --guest-api-access --guest-scopes orders:read --yes + auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app "Shop Web" --api-url https://api.shop.example=https://api.shop.example/openapi.json --login-uri https://shop.example/login --hint checkout=changes:irreversible,personal_data:true --yes +``` + + +## Flags + +``` + --api stringArray A Poppy API, by id or identifier. Repeat for several. When registering an agent, the Poppy APIs to grant (default: every Poppy API of the tenant). + --api-url stringArray Where an API's OpenAPI document lives, as =. Repeat for several. Asked if the config file has no entry for the API, with /openapi.json as the default when the identifier is a URL. + --app string Client ID or name of the web app used for the browser join. + --company-domain string The Company's website host, such as shop.example. With several company domains, picks the one this app serves. + --dry-run Print the plan and change nothing. + --force-config Overwrite a config file that belongs to a different issuer. + --guest-all-scopes The guest grant carries every scope of the API. Only affects the guest grant; use --scope (singular) to describe scopes. Needs --guest-api-access. Mutually exclusive with --guest-scopes. + --guest-api-access Also create the client grant that lets signed-out guests reach the API. Choose its scopes with --guest-scopes or --guest-all-scopes; in a terminal, setup asks. + --guest-scopes strings Comma-separated scopes the guest grant carries, which every API must define. Only affects the guest grant; use --scope (singular) to describe scopes. Needs --guest-api-access. Without a terminal, pass this or --guest-all-scopes. Mutually exclusive with --guest-all-scopes. + --hint stringArray Hints for a scope, as name=changes:irreversible,personal_data:true. Repeat for several. + --json Output in json format. + --json-compact Output in compact json format. + --login-uri string The web app's login route, used for the browser join. Asked if the app has none. + --name string The Company's name for the config file. When registering an agent, the agent's name. + --out string Path of the config file. (default "auth0-poppy.json") + --scope stringArray The description of a scope that has none, as name="description". With one API, also adds a scope the API lacks. Repeat for several. Existing descriptions are never edited. + --skip-doctor Do not run the doctor checks after setup. + --yes Skip confirmation. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 poppy agents](auth0_poppy_agents.md) - Manage the agents your company allows +- [auth0 poppy config](auth0_poppy_config.md) - Rebuild the Poppy config file from the tenant +- [auth0 poppy doctor](auth0_poppy_doctor.md) - Check the Poppy discovery chain end to end +- [auth0 poppy setup](auth0_poppy_setup.md) - Turn on Poppy for the tenant, an API and a web app + + diff --git a/docs/index.md b/docs/index.md index 6b931069d..c1c04432d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -109,6 +109,7 @@ The help for any command can also be emitted as JSON by combining `--help` with - [auth0 network-acl](auth0_network-acl.md) - Manage network ACL settings - [auth0 orgs](auth0_orgs.md) - Manage resources for organizations - [auth0 phone](auth0_phone.md) - Manage phone providers +- [auth0 poppy](auth0_poppy.md) - Set up your company for personal agents - [auth0 protection](auth0_protection.md) - Manage resources for attack protection - [auth0 quickstarts](auth0_quickstarts.md) - Quickstart support for getting bootstrapped - [auth0 refresh-tokens](auth0_refresh-tokens.md) - Manage resources for refresh tokens diff --git a/internal/cli/cli.go b/internal/cli/cli.go index df4f43aa3..202792d05 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -16,6 +16,7 @@ import ( "github.com/auth0/auth0-cli/internal/config" "github.com/auth0/auth0-cli/internal/display" "github.com/auth0/auth0-cli/internal/iostream" + "github.com/auth0/auth0-cli/internal/poppy/doctor" "github.com/auth0/auth0-cli/internal/prompt" ) @@ -58,6 +59,9 @@ type cli struct { detectedAgent string executedCommandPath string + // The options of the doctor checks that run at the end of poppy setup. + poppyDoctor doctor.Options + Config config.Config } diff --git a/internal/cli/poppy.go b/internal/cli/poppy.go new file mode 100644 index 000000000..eed6bfb1b --- /dev/null +++ b/internal/cli/poppy.go @@ -0,0 +1,1528 @@ +package cli + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "strings" + "unicode/utf8" + + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/poppy/config" + "github.com/auth0/auth0-cli/internal/poppy/discovery" + "github.com/auth0/auth0-cli/internal/poppy/doctor" + "github.com/auth0/auth0-cli/internal/poppy/setup" + "github.com/auth0/auth0-cli/internal/poppy/text" + "github.com/auth0/auth0-cli/internal/prompt" +) + +// poppyExperimentalWarning closes the help text of every Poppy command. +const poppyExperimentalWarning = "**Warning:** This command is experimental and is subject to change in future versions." + +// poppyAsker is how the Poppy commands ask questions. Whether a command may ask +// depends on canPrompt alone, whatever the output format. Tests replace the +// fields to run as if in a terminal. +type poppyAsker struct { + canPrompt func(cmd *cobra.Command) bool + // Erasable tells whether the lines of a question can be taken back off the + // screen that the writer draws. + erasable func(w io.Writer) bool + // Interrupt ends the command after Ctrl-C. + interrupt func(err error) error + // The four kinds of prompt. A prompt that the user ends with Ctrl-C returns + // terminal.InterruptErr. + text func(s *poppyScreen, label, def string) (string, error) + confirm func(s *poppyScreen, message string, def bool) (bool, error) + choose func(s *poppyScreen, message string, options []string, def int) (int, error) + chooseMany func(s *poppyScreen, message string, options []string, defaults []int) ([]int, error) + guest func(s *poppyScreen) poppyGuestPrompts +} + +var poppyAsk = poppyAsker{ + canPrompt: canPrompt, + erasable: display.PoppyCanErase, + interrupt: handleInputError, + text: terminalPoppyText, + confirm: terminalPoppyConfirm, + choose: terminalPoppyChoose, + chooseMany: terminalPoppyChooseMany, + guest: terminalPoppyGuestPrompts, +} + +// errPoppyDoctorFailed is returned after the report is printed when a doctor check failed. +var errPoppyDoctorFailed = errors.New("some doctor checks failed") + +// errNoPoppySession means there is no tenant login the doctor checks can use +// without asking the user for anything. +var errNoPoppySession = errors.New("no usable tenant login") + +var ( + poppyCompanyDomain = Flag{ + Name: "Company Domain", + LongForm: "company-domain", + Help: "The Company's website host, such as shop.example. With several company domains, picks the one this app serves.", + } + poppyAPI = Flag{ + Name: "API", + LongForm: "api", + Help: "A Poppy API, by id or identifier. Repeat for several. When registering an agent, the Poppy APIs to grant (default: every Poppy API of the tenant).", + } + poppyAPIURL = Flag{ + Name: "API URL", + LongForm: "api-url", + Help: "Where an API's OpenAPI document lives, as =. Repeat for several. Asked if the config file has no entry for the API, with /openapi.json as the default when the identifier is a URL.", + } + poppyApp = Flag{ + Name: "App", + LongForm: "app", + Help: "Client ID or name of the web app used for the browser join.", + } + poppyScope = Flag{ + Name: "Scope", + LongForm: "scope", + Help: "The description of a scope that has none, as name=\"description\". With one API, also adds a scope the API lacks. Repeat for several. Existing descriptions are never edited.", + } + poppyHint = Flag{ + Name: "Hint", + LongForm: "hint", + Help: "Hints for a scope, as name=changes:irreversible,personal_data:true. Repeat for several.", + } + poppyLoginURI = Flag{ + Name: "Login URI", + LongForm: "login-uri", + Help: "The web app's login route, used for the browser join. Asked if the app has none.", + } + poppyName = Flag{ + Name: "Name", + LongForm: "name", + Help: "The Company's name for the config file. When registering an agent, the agent's name.", + } + poppyOut = Flag{ + Name: "Out", + LongForm: "out", + Help: "Path of the config file.", + } + poppyDryRun = Flag{ + Name: "Dry Run", + LongForm: "dry-run", + Help: "Print the plan and change nothing.", + } + poppyYes = Flag{ + Name: "Yes", + LongForm: "yes", + Help: "Skip confirmation.", + } + poppySkipDoctor = Flag{ + Name: "Skip Doctor", + LongForm: "skip-doctor", + Help: "Do not run the doctor checks after setup.", + } + poppyGuestAPIAccess = Flag{ + Name: "Guest API Access", + LongForm: "guest-api-access", + Help: "Also create the client grant that lets signed-out guests reach the API. Choose its scopes with --guest-scopes or --guest-all-scopes; in a terminal, setup asks.", + } + poppyGuestScopes = Flag{ + Name: "Guest Scopes", + LongForm: "guest-scopes", + Help: "Comma-separated scopes the guest grant carries, which every API must define. Only affects the guest grant; use --scope (singular) to describe scopes. Needs --guest-api-access. Without a terminal, pass this or --guest-all-scopes. Mutually exclusive with --guest-all-scopes.", + } + poppyGuestAllScopes = Flag{ + Name: "Guest All Scopes", + LongForm: "guest-all-scopes", + Help: "The guest grant carries every scope of the API. Only affects the guest grant; use --scope (singular) to describe scopes. Needs --guest-api-access. Mutually exclusive with --guest-scopes.", + } + poppyForceConfig = Flag{ + Name: "Force Config", + LongForm: "force-config", + Help: "Overwrite a config file that belongs to a different issuer.", + } + poppyGrantScopes = Flag{ + Name: "Scopes", + LongForm: "scopes", + Help: "Comma-separated list of scopes to grant. Without a terminal, pass this or --all-scopes. Mutually exclusive with --all-scopes.", + } + poppyAllScopes = Flag{ + Name: "All Scopes", + LongForm: "all-scopes", + Help: "Grant every offered scope. Mutually exclusive with --scopes.", + } + poppyRequireDPoP = Flag{ + Name: "Require DPoP", + LongForm: "require-dpop", + Help: "Require DPoP-bound tokens for the agent. Off by default.", + } + poppyAll = Flag{ + Name: "All", + LongForm: "all", + Help: "Include agents that have no grant to a Poppy API.", + } + poppyConfig = Flag{ + Name: "Config", + LongForm: "config", + Help: "Config file to check. Used only if it exists.", + } + poppyRemoveAllGrants = Flag{ + Name: "Remove All Grants", + LongForm: "remove-all-grants", + Help: "Also delete the app's other grants, then the agent record and the app.", + } +) + +func poppyCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "poppy", + Short: "Set up your company for personal agents", + Long: poppyLong("Prepares your company for personal agents, using the Personal Agent Protocol (Poppy). " + + "Poppy settings are spread across the tenant, an API and a web app, and the discovery files on your " + + "website must repeat Auth0's values exactly. These commands set that up, write one config file, a `poppy.json` document, for " + + "your web and API SDKs, manage the agents you allow, and check the whole chain before an agent arrives."), + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(poppySetupCmd(cli)) + cmd.AddCommand(poppyConfigCmd(cli)) + cmd.AddCommand(poppyDoctorCmd(cli)) + cmd.AddCommand(poppyAgentsCmd(cli)) + + return cmd +} + +func poppyDoctorCmd(cli *cli) *cobra.Command { + var configFile string + + cmd := &cobra.Command{ + Use: "doctor [domain]", + Args: cobra.MaximumNArgs(1), + Short: "Check the Poppy discovery chain end to end", + Long: poppyLong("Checks the whole discovery chain without changing anything: the files on your website, Auth0's " + + "metadata, the tenant settings and each registered agent. Each failure comes with the spec section and a " + + "FIX line telling you what to change. It understands sites on protocol 0.3 and 0.4, and runs the extra " + + "checks of 0.4 (D15 to D17) only on sites that declare it. The checks that only need the company domain " + + "(D1 to D7, and D15 to D17) work without a login, so they also work against other companies. The checks that read your tenant (D8 to " + + "D13: tenant settings, the web app, the MFA policy, and each agent's metadata, keys and grants, and D14, " + + "which compares the config file with the tenant) run when you are logged in to a tenant, or use the " + + "environment credentials of `AUTH0_CLI_AUTH_MODE=env`, and show as skipped otherwise. This command " + + "never starts a login by itself: an expired or missing session only skips those checks. They read the " + + "tenant with `read:tenant_settings`, `read:clients`, `read:mfa_policies`, `read:client_grants`, " + + "`read:agents`, `read:resource_servers` and `read:client_credentials`; a check whose scope is missing is " + + "skipped with the login command that adds it. The command exits with 1 when a check fails."), + Example: ` auth0 poppy doctor shop.example + auth0 poppy doctor shop.example --config ./config/auth0-poppy.json + auth0 poppy doctor shop.example --json-compact`, + RunE: func(cmd *cobra.Command, args []string) error { + domain, err := poppyDoctorDomain(args, configFile) + if err != nil { + return err + } + + cli.tenantExplicit = cmd.Flags().Changed("tenant") + + tenant, err := cli.optionalPoppyTenant(cmd.Context()) + if err != nil { + return err + } + + opts := cli.poppyDoctor + opts.Tenant, opts.ConfigFile = tenant, configFile + + return runPoppyDoctor(cmd.Context(), cli, domain, opts) + }, + } + + poppyConfig.RegisterString(cmd, &configFile, config.DefaultFileName) + registerPoppyOutputFlags(cmd, cli) + + return cmd +} + +// poppyDoctorTenant gives the doctor checks access to the tenant through the +// Management API clients of a login. +func (c *cli) poppyDoctorTenant() *doctor.Tenant { + return &doctor.Tenant{State: c.poppyReader(), Agents: c.poppyAgents(), Policies: c.apiv3.GuardianPolicy} +} + +// optionalPoppyTenant returns access to the tenant when a login can be used +// as it is, and nil when it cannot. It never starts a login, asks a question +// or writes the config: see setupPoppyAuthentication. An error means the +// environment credentials the caller asked for do not work. +func (c *cli) optionalPoppyTenant(ctx context.Context) (*doctor.Tenant, error) { + switch err := c.setupPoppyAuthentication(ctx); { + case errors.Is(err, errNoPoppySession): + return nil, nil + case err != nil: + return nil, err + } + + return c.poppyDoctorTenant(), nil +} + +// setupPoppyAuthentication is setupWithAuthentication limited to what needs no +// interaction. With environment credentials selected it uses them. Otherwise it +// reads the saved login and uses it only if its token is still valid, and +// returns errNoPoppySession when it is not: it never refreshes a token, +// repairs the config, or runs a device-code login, as setupWithAuthentication +// does. +func (c *cli) setupPoppyAuthentication(ctx context.Context) error { + if err := validateAuthMode(os.Getenv); err != nil { + return err + } + + if envAuthEnabled(os.Getenv) { + return c.setupWithEnvAuthentication(ctx) + } + + if err := c.Config.Initialize(); err != nil { + return fmt.Errorf("%w: %w", errNoPoppySession, err) + } + + tenant, err := c.Config.GetTenant(cmp.Or(c.tenant, c.Config.DefaultTenant)) + if err != nil { + return fmt.Errorf("%w: %w", errNoPoppySession, err) + } + + accessToken, err := tenant.CheckAuthenticationStatus() + if err != nil { + return fmt.Errorf("%w: %w", errNoPoppySession, err) + } + + invokerMetadata := c.invokerMetadataHeaderValue() + + api, err := initializeManagementClient(tenant.Domain, accessToken, invokerMetadata) + if err != nil { + return authError{err: err, reason: "client_init_failed"} + } + + apiv3, err := initializeManagementClientV3(tenant.Domain, accessToken, invokerMetadata) + if err != nil { + return authError{err: err, reason: "client_init_failed"} + } + + c.tenant = tenant.Domain + c.api = auth0.NewAPI(api) + c.apiv3 = auth0.NewAPIV3(apiv3) + + return nil +} + +// poppyDoctorDomain returns the normalized company domain to check: the +// argument, else organization.domain of the config file when it exists. +func poppyDoctorDomain(args []string, configFile string) (string, error) { + if len(args) == 1 { + domain, err := discovery.NormalizeDomain(args[0]) + if err != nil { + return "", validationError{err: err} + } + + return domain, nil + } + + cfg, err := config.ReadLenient(configFile) + switch { + case errors.Is(err, fs.ErrNotExist): + return "", validationError{err: fmt.Errorf("pass a domain, such as auth0 poppy doctor shop.example, or run the command where %s exists", configFile)} + case err != nil: + return "", err + } + + domain, err := discovery.NormalizeDomain(cfg.Organization.Domain) + if err != nil { + return "", validationError{err: fmt.Errorf("organization.domain of %s: %w", configFile, err)} + } + + return domain, nil +} + +// runPoppyDoctor prints the doctor report and fails when a check failed. +func runPoppyDoctor(ctx context.Context, cli *cli, domain string, opts doctor.Options) error { + report := doctor.Run(ctx, domain, opts) + + cli.renderer.PoppyDoctor(report) + + if report.ExitCode() != 0 { + return errPoppyDoctorFailed + } + + return nil +} + +// poppyLong marks a command's help text as experimental. +func poppyLong(description string) string { + return "(Experimental) " + description + "\n\n" + poppyExperimentalWarning +} + +// registerPoppyOutputFlags adds the machine-readable output flags. +func registerPoppyOutputFlags(cmd *cobra.Command, cli *cli) { + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") +} + +type poppySetupOptions struct { + DryRun bool + Yes bool + SkipDoctor bool +} + +func poppySetupCmd(cli *cli) *cobra.Command { + var ( + flags setup.Flags + opts poppySetupOptions + ) + + cmd := &cobra.Command{ + Use: "setup", + Args: cobra.NoArgs, + Short: "Turn on Poppy for the tenant, an API and a web app", + Long: poppyLong("Turns on the Poppy settings for the tenant, an existing API and an existing web app, and " + + "writes the config file. The API and the web app must already exist, and their scopes, MFA policy and " + + "Actions are left alone. The command shows a plan first and marks the tenant-wide changes, then applies " + + "it once you confirm; use `--dry-run` to see the plan only, and `--yes` to skip the question, which " + + "is required without a terminal. In a terminal it asks for any required " + + "value you did not pass, such as the OpenAPI URL of an API or the login route of the web app. Running " + + "it again changes nothing when the tenant already matches, or continues where a failed run stopped. It " + + "finishes by running `auth0 poppy doctor`, including the checks that read the tenant, unless `--skip-doctor` is set; a failed check does not fail " + + "setup, because your website may not serve the config file until you deploy it. With `--guest-api-access` the " + + "guest grant carries only the scopes you choose, with `--guest-scopes` or `--guest-all-scopes`; in a terminal " + + "setup asks, and it also asks whether to let signed-out guests use the API when no guest grant exists yet. " + + "Setup adds the scope `poppy:browser-sign-in` to each API when it is missing, with a default description " + + "unless you give one with `--scope`; every other scope offered to agents needs a description, which a terminal asks for " + + "and otherwise comes from `--scope =`."), + Example: ` auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app "Shop Web" --scope orders:read="See your orders and their status. Read only." + auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app "Shop Web" --dry-run + auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app "Shop Web" --guest-api-access --guest-scopes orders:read --yes + auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app "Shop Web" --api-url https://api.shop.example=https://api.shop.example/openapi.json --login-uri https://shop.example/login --hint checkout=changes:irreversible,personal_data:true --yes`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runPoppySetup(cmd, cli, flags, opts) + }, + } + + poppyCompanyDomain.RegisterString(cmd, &flags.CompanyDomain, "") + poppyAPI.RegisterStringArray(cmd, &flags.APIs, nil) + poppyAPIURL.RegisterStringArray(cmd, &flags.APIURLs, nil) + poppyApp.RegisterString(cmd, &flags.App, "") + poppyScope.RegisterStringArray(cmd, &flags.Scopes, nil) + poppyHint.RegisterStringArray(cmd, &flags.Hints, nil) + poppyName.RegisterString(cmd, &flags.Name, "") + poppyLoginURI.RegisterString(cmd, &flags.LoginURI, "") + poppyOut.RegisterString(cmd, &flags.Out, config.DefaultFileName) + poppyDryRun.RegisterBool(cmd, &opts.DryRun, false) + poppyYes.RegisterBool(cmd, &opts.Yes, false) + poppySkipDoctor.RegisterBool(cmd, &opts.SkipDoctor, false) + poppyGuestAPIAccess.RegisterBool(cmd, &flags.GuestAPIAccess, false) + poppyGuestScopes.RegisterStringSlice(cmd, &flags.GuestScopes, nil) + poppyGuestAllScopes.RegisterBool(cmd, &flags.GuestAllScopes, false) + poppyForceConfig.RegisterBool(cmd, &flags.ForceConfig, false) + registerPoppyOutputFlags(cmd, cli) + cmd.MarkFlagsMutuallyExclusive(poppyGuestScopes.LongForm, poppyGuestAllScopes.LongForm) + + return cmd +} + +func runPoppySetup(cmd *cobra.Command, cli *cli, flags setup.Flags, opts poppySetupOptions) error { + in, err := flags.Parse() + if err != nil { + return poppySetupError(err) + } + + screen := cli.newPoppyScreen("Poppy setup") + screen.ForDomain(in.CompanyDomain) + + if err := askPoppyQuestions(cmd, screen, &in, setup.RequiredQuestions(in)); err != nil { + return err + } + + screen.ForDomain(in.CompanyDomain) + + if err := cli.checkPoppyScopes(setup.ReadScopes); err != nil { + return err + } + + file, err := readPoppyConfigLenient(in.Out) + if err != nil { + return err + } + + state, err := cli.readPoppyState(cmd.Context(), in.APIs, in.App, file) + if err != nil { + return err + } + + if err := setup.Check(state, in); err != nil { + if !poppyAsk.canPrompt(cmd) || !isPoppyScopeDescriptionRequired(err) { + return poppySetupError(err) + } + + if err := describePoppyScopes(screen, setup.ScopeQuestions(state, in), &in); err != nil { + return err + } + + if err := setup.Check(state, in); err != nil { + return poppySetupError(err) + } + } + + for questions := setup.Questions(state, in); len(questions) > 0; questions = setup.Questions(state, in) { + if err := askPoppyQuestions(cmd, screen, &in, questions); err != nil { + return err + } + } + + unhinted := 0 + if poppyAsk.canPrompt(cmd) { + if unhinted, err = askPoppyHints(screen, setup.HintScopes(state, in, true), &in); err != nil { + return err + } + } + + if err := choosePoppyGuestScopes(poppyAsk.canPrompt(cmd), poppyAsk.guest(screen), state, &in); err != nil { + return err + } + + plan, err := setup.Build(state, in) + if err != nil { + return poppySetupError(err) + } + + if !opts.DryRun { + if err := cli.checkPoppyScopes(plan.Scopes()); err != nil { + return err + } + } + + textOpts := display.PoppySetupOptions{ + Inputs: in, + DryRun: opts.DryRun, + Command: setup.CommandOptions{Yes: opts.Yes, SkipDoctor: opts.SkipDoctor}, + NoHeader: screen.HeaderShown(), + Unhinted: unhinted, + } + + if opts.DryRun { + if !cli.renderer.PoppyHuman() { + screen.DropHeader() + cli.renderer.PoppyJSON(plan) + + return nil + } + + cli.renderer.PoppyPlan(plan, textOpts) + + // The title is printed once. After the plan, a question goes below it. + screen.Printed() + + return nil + } + + // With --json, only a person at a terminal sees the plan, before the question. + if cli.renderer.PoppyHuman() || poppyAsk.canPrompt(cmd) { + cli.renderer.PoppyPlan(plan, textOpts) + + // The title is printed once. After the plan, a question goes below it. + screen.Printed() + } + + applied := []string{} + + if !plan.Empty() { + switch setup.Decide(opts.Yes, poppyAsk.canPrompt(cmd)) { + case setup.Refuse: + details, _ := json.Marshal(plan) + + return validationError{ + err: errors.New("confirmation required: re-run with --yes to apply the plan"), + details: details, + reason: "confirmation_required", + } + case setup.Ask: + question := fmt.Sprintf("Apply %s?", display.PoppyChangeCount(plan.Count())) + + proceed, err := screen.confirmAsk("Apply the plan", confirmPoppyReason(plan, in), question) + if err != nil { + return err + } + + if !proceed { + cli.renderer.PoppyDeclined("Nothing was applied.") + + return nil + } + } + + if applied, err = cli.applyPoppyPlan(cmd.Context(), state, in, plan); err != nil { + return cli.writePoppyFailure(plan, cli.poppyError(err), textOpts) + } + } + + result := poppySetupResult{Plan: plan, Applied: applied} + + if !opts.SkipDoctor { + // Setup has a login, so the doctor checks can read the tenant too. + doctorOpts := cli.poppyDoctor + doctorOpts.Tenant, doctorOpts.ConfigFile = cli.poppyDoctorTenant(), in.Out + + var report doctor.Report + _ = ansi.Waiting(func() error { + report = doctor.Run(cmd.Context(), in.CompanyDomain, doctorOpts) + + return nil + }) + result.Doctor = &report + } + + if cli.renderer.PoppyJSON(result) { + return nil + } + + cli.renderer.PoppySetupResult(plan, result.Doctor, textOpts) + + return nil +} + +// poppySetupResult is the JSON output of a setup that is not a dry run. +type poppySetupResult struct { + Plan setup.Plan `json:"plan"` + Applied []string `json:"applied"` + Doctor *doctor.Report `json:"doctor,omitempty"` +} + +// confirmPoppyReason says what the confirmation of a plan is about. +func confirmPoppyReason(plan setup.Plan, in setup.Inputs) string { + reason := "This changes your Auth0 tenant and writes " + in.Out + "." + + for _, change := range plan.Changes { + if change.Impact == setup.ImpactTenantWide { + return reason + " The tenant settings affect every app." + } + } + + return reason +} + +// writePoppyFailure prints the steps of a setup that stopped partway: the ones +// made, the one that failed and the ones not run, and the command that continues. +// It returns the error to end the command with. The steps already say what was +// applied and how to continue, so a person gets only the cause. The JSON error +// keeps the whole account. +func (c *cli) writePoppyFailure(plan setup.Plan, err error, opts display.PoppySetupOptions) error { + var partial *setup.PartialError + if !errors.As(err, &partial) || !c.renderer.PoppyHuman() { + return err + } + + c.renderer.PoppySetupFailure(plan, setup.Outcome{Done: len(partial.Applied), Failure: partial.Err}, opts) + + return partial.Err +} + +// applyPoppyPlan makes the changes of the plan and returns what it changed. +func (c *cli) applyPoppyPlan(ctx context.Context, state *setup.State, in setup.Inputs, plan setup.Plan) ([]string, error) { + applier := &setup.Applier{ + Tenant: c.apiv3.TenantSettings, + Servers: c.apiv3.ResourceServerV3, + Clients: c.apiv3.ClientV3, + Grants: c.apiv3.ClientGrant, + } + + var applied []string + err := ansi.Waiting(func() (err error) { + applied, err = applier.Apply(ctx, state, in, plan) + + return err + }) + + return applied, err +} + +// poppyReader builds the reader of the tenant state over the Management API clients. +func (c *cli) poppyReader() *setup.Reader { + return &setup.Reader{ + TenantDomain: c.tenant, + Tenant: c.apiv3.TenantSettings, + Domains: c.api.CustomDomain, + Servers: c.apiv3.ResourceServerV3, + Clients: c.apiv3.ClientV3, + Grants: c.apiv3.ClientGrant, + } +} + +// readPoppyState reads the tenant and the requested APIs and web app, and adds +// the existing config file to them. +func (c *cli) readPoppyState(ctx context.Context, apis []string, app string, file *config.Config) (*setup.State, error) { + reader := c.poppyReader() + + var state *setup.State + err := ansi.Waiting(func() (err error) { + if state, err = reader.Read(ctx, apis, app); err != nil { + return err + } + + state.File = file + + return nil + }) + if err != nil { + return nil, c.poppyError(poppySetupError(err)) + } + + return state, nil +} + +// readPoppyConfigLenient reads the config file without validating it, so a +// stale file can still be rebuilt. It returns nil when there is no file. +func readPoppyConfigLenient(path string) (*config.Config, error) { + cfg, err := config.ReadLenient(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + + return cfg, err +} + +// askPoppyQuestions asks for each missing value in a terminal, each in its own +// block that goes away once answered. Without a terminal it stops at the first, +// naming the flag that gives it. +func askPoppyQuestions(cmd *cobra.Command, screen *poppyScreen, in *setup.Inputs, questions []setup.Question) error { + for _, question := range questions { + if !poppyAsk.canPrompt(cmd) { + return validationError{err: fmt.Errorf("%s is required. %s", question.Flag, question.Reason), reason: "missing_flag"} + } + + if err := screen.askText(question, in); err != nil { + return err + } + } + + return nil +} + +// isPoppyScopeDescriptionRequired tells whether err says scopes lack a description. +func isPoppyScopeDescriptionRequired(err error) bool { + var input *setup.InputError + + return errors.As(err, &input) && input.Reason == setup.ReasonScopeDescriptionRequired +} + +// describePoppyScopes asks for the description of each scope that has none and +// stores it as if --scope had been passed. An answer that is empty or only blanks +// is asked again. +func describePoppyScopes(screen *poppyScreen, questions []setup.Question, in *setup.Inputs) error { + for _, question := range questions { + if err := screen.askText(question, in); err != nil { + return err + } + } + + return nil +} + +// poppyGuestPrompts are the questions about the guest grant. Tests replace them. +type poppyGuestPrompts struct { + // Allow asks whether signed-out guests may use the API. + allow func() (bool, error) + // All asks whether the guest grant carries every scope. False means the user + // wants to select the scopes. + all func() (bool, error) + // Pick asks which of the scopes of an API the guest grant carries, with none + // chosen to begin with. It returns the names of the ones chosen. + pick func(audience string, scopes []setup.GuestScope) ([]string, error) + // Note tells the user something that is not an error. + note func(format string, args ...any) +} + +const ( + poppyGuestAllChoice = "All scopes" + poppyGuestSelectChoice = "Select scopes" +) + +func terminalPoppyGuestPrompts(s *poppyScreen) poppyGuestPrompts { + return poppyGuestPrompts{ + note: s.Note, + allow: func() (allow bool, err error) { + err = s.ask("Guest access", "Signed-out guests can use the API with only the scopes you choose.", func() (err error) { + allow, err = s.asker.confirm(s, "Let signed-out guests use the API?", false) + + return err + }) + + return allow, err + }, + all: func() (all bool, err error) { + err = s.ask("Guest scopes", "The guest grant carries every scope of the API, or only the ones you select.", func() error { + picked, err := s.asker.choose(s, "All scopes, or select scopes?", []string{poppyGuestAllChoice, poppyGuestSelectChoice}, 1) + all = picked == 0 + + return err + }) + + return all, err + }, + pick: func(audience string, scopes []setup.GuestScope) (names []string, err error) { + choices := make([]display.PoppyScopeChoice, len(scopes)) + for i, scope := range scopes { + choices[i] = display.PoppyScopeChoice{Name: scope.Name, Description: scope.Description, Hints: scope.Hints} + } + + title := "Guest scopes on " + text.Line(audience) + + err = s.ask(title, "Signed-out guests get only the scopes you select.", func() error { + picked, err := s.asker.chooseMany(s, title, s.ScopeRows(choices), nil) + for _, i := range picked { + names = append(names, scopes[i].Name) + } + + return err + }) + + return names, err + }, + } +} + +// choosePoppyGuestScopes settles which scopes the guest grant carries. A flag +// answers its question. Without one, a terminal asks and anything else fails +// before a change is made. +func choosePoppyGuestScopes(interactive bool, prompts poppyGuestPrompts, state *setup.State, in *setup.Inputs) error { + if interactive && !in.GuestAPIAccess { + if state.AllGuestGrants() { + return nil + } + + allow, err := prompts.allow() + if err != nil || !allow { + return err + } + + in.GuestAPIAccess = true + } + + chosen, err := setup.ChooseGuestScopes(state, *in) + + switch { + case errors.Is(err, setup.ErrGuestScopesRequired) && interactive: + return askPoppyGuestScopes(prompts, state, in) + case errors.Is(err, setup.ErrGuestScopesRequired): + return poppyGuestScopesRequiredError(state, *in) + case err != nil: + return poppySetupError(err) + } + + in.GuestScopes = chosen + + return nil +} + +// askPoppyGuestScopes asks whether the guest grant carries every scope or a +// selection, and for the selection on each API that has no guest grant yet. An +// API with no scope chosen gets no guest grant. +func askPoppyGuestScopes(prompts poppyGuestPrompts, state *setup.State, in *setup.Inputs) error { + all, err := prompts.all() + if err != nil { + return err + } + + if all { + in.GuestAllScopes = true + + return nil + } + + offers, err := setup.GuestOffers(state, *in) + if err != nil { + return poppySetupError(err) + } + + in.GuestScopes = make(map[string][]string, len(offers)) + + var skipped []string + + for _, offer := range offers { + picked, err := prompts.pick(offer.Audience, offer.Scopes) + if err != nil { + return err + } + + if len(picked) == 0 { + skipped = append(skipped, offer.Audience) + + continue + } + + in.GuestScopes[offer.Audience] = picked + } + + // One note is enough: when nothing was chosen it covers every API. + if len(in.GuestScopes) == 0 { + prompts.note("No scope was chosen, so the guest grant is left as it is.") + + return nil + } + + for _, audience := range skipped { + prompts.note("No scope chosen for %s, so it gets no guest grant.", audience) + } + + return nil +} + +// poppyGuestScopesRequiredError lists the scopes on offer, with their hints, for +// a caller that cannot be asked. +func poppyGuestScopesRequiredError(state *setup.State, in setup.Inputs) error { + offers, err := setup.GuestOffers(state, in) + if err != nil { + return poppySetupError(err) + } + + type offered struct { + Name string `json:"name"` + Description string `json:"description"` + Hints *config.Hints `json:"hints,omitempty"` + } + type offer struct { + Audience string `json:"audience"` + Scopes []offered `json:"scopes"` + } + + var message strings.Builder + message.WriteString("pass --guest-scopes or --guest-all-scopes to choose the scopes of the guest grant. Offered scopes:") + + offerList := make([]offer, len(offers)) + for i, o := range offers { + offerList[i] = offer{Audience: o.Audience, Scopes: make([]offered, len(o.Scopes))} + + names := make([]string, len(o.Scopes)) + for j, scope := range o.Scopes { + names[j] = scope.Name + offerList[i].Scopes[j] = offered{Name: scope.Name, Description: scope.Description, Hints: scope.Hints} + } + + fmt.Fprintf(&message, "\n %s: %s", o.Audience, strings.Join(names, ", ")) + } + + message.WriteString("\n FIX Pass --guest-scopes [,] to name them, or --guest-all-scopes to carry every scope.") + + details, _ := json.Marshal(map[string]any{"apis": offerList}) + + return validationError{err: errors.New(message.String()), details: details, reason: "scopes_required"} +} + +// poppySetupError turns the errors of the setup package into CLI errors. Other +// errors pass through unchanged. +func poppySetupError(err error) error { + var input *setup.InputError + var notReady *setup.NotReadyError + var missing *config.MissingFieldsError + + switch { + case errors.As(err, &input): + return validationError{err: err, reason: input.Reason} + case errors.As(err, ¬Ready): + return validationError{err: err, reason: "poppy_not_ready"} + case errors.As(err, &missing): + return validationError{ + err: fmt.Errorf("%w. Pass --api-url as =, or run auth0 poppy setup", err), + reason: "missing_config_values", + } + case errors.Is(err, config.ErrIssuerMismatch): + return validationError{err: fmt.Errorf("%w. Pass --force-config to overwrite it", err), reason: "issuer_mismatch"} + } + + return err +} + +func poppyConfigCmd(cli *cli) *cobra.Command { + var flags setup.Flags + + cmd := &cobra.Command{ + Use: "config", + Args: cobra.NoArgs, + Short: "Rebuild the Poppy config file from the tenant", + Long: poppyLong("Rebuilds the config file from the tenant without changing the tenant, so CI and deploys can " + + "refresh it safely. It reads the tenant, adds the values Auth0 does not store, and writes the file. " + + "If the tenant is not set up for Poppy, it exits with an error naming the missing setting. The config " + + "file is for one website, so with several company domains pass `--company-domain` to pick the one this " + + "app serves. The file is a protocol 0.4 `poppy.json` document and nothing else, so your web SDK can " + + "serve it as `/.well-known/poppy.json`. A file written for 0.3 is upgraded, and members of the file that " + + "the CLI does not use are kept. The file holds no API identifier and no web app, so without `--api` the " + + "APIs are the Poppy APIs of the tenant that the `apis` entries of the existing file are at, and without " + + "`--app` the web app is the only app of the tenant that has the browser join on. When that does not " + + "settle on one, the command stops and asks for `--api` or `--app`."), + Example: ` auth0 poppy config + auth0 poppy config --company-domain shop.example --out ./config/auth0-poppy.json + auth0 poppy config --company-domain shop.example --api https://api.shop.example --app "Shop Web" --json`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runPoppyConfig(cmd, cli, flags) + }, + } + + poppyCompanyDomain.RegisterString(cmd, &flags.CompanyDomain, "") + poppyAPI.RegisterStringArray(cmd, &flags.APIs, nil) + poppyAPIURL.RegisterStringArray(cmd, &flags.APIURLs, nil) + poppyApp.RegisterString(cmd, &flags.App, "") + poppyHint.RegisterStringArray(cmd, &flags.Hints, nil) + poppyName.RegisterString(cmd, &flags.Name, "") + poppyOut.RegisterString(cmd, &flags.Out, config.DefaultFileName) + poppyForceConfig.RegisterBool(cmd, &flags.ForceConfig, false) + registerPoppyOutputFlags(cmd, cli) + + return cmd +} + +func runPoppyConfig(cmd *cobra.Command, cli *cli, flags setup.Flags) error { + in, err := flags.Parse() + if err != nil { + return poppySetupError(err) + } + + screen := cli.newPoppyScreen("Poppy config") + screen.ForDomain(in.CompanyDomain) + + if err := cli.checkPoppyScopes(setup.ReadScopes); err != nil { + return err + } + + file, err := readPoppyConfigLenient(in.Out) + if err != nil { + return err + } + + apis, app, err := cli.resolvePoppyAPIsAndApp(cmd.Context(), in, file) + if err != nil { + return err + } + + state, err := cli.readPoppyState(cmd.Context(), apis, app, file) + if err != nil { + return err + } + + domain, choices := state.CompanyDomain(in) + if len(choices) > 1 { + if domain, err = askPoppyCompanyDomain(cmd, screen, choices); err != nil { + return err + } + } + in.CompanyDomain = domain + screen.ForDomain(domain) + + if err := setup.Ready(state, in); err != nil { + return poppySetupError(err) + } + + unhinted := 0 + if poppyAsk.canPrompt(cmd) { + if unhinted, err = askPoppyHints(screen, setup.HintScopes(state, in, false), &in); err != nil { + return err + } + } + + cfg, err := setup.Derive(state, in) + if err != nil { + return poppySetupError(err) + } + + before, readErr := os.ReadFile(in.Out) + + if err := config.Write(in.Out, cfg); err != nil { + return err + } + + if !cli.renderer.PoppyHuman() { + screen.DropHeader() + cli.renderer.PoppyJSON(cfg) + + return nil + } + + after, err := os.ReadFile(in.Out) + unchanged := readErr == nil && err == nil && bytes.Equal(before, after) + + cli.renderer.PoppyConfigResult(in, unchanged, screen.HeaderShown(), unhinted) + + return nil +} + +// askPoppyCompanyDomain picks one of the company domains the tenant lists. +func askPoppyCompanyDomain(cmd *cobra.Command, screen *poppyScreen, choices []string) (domain string, err error) { + if !poppyAsk.canPrompt(cmd) { + return "", validationError{ + err: errors.New("--company-domain is required: the tenant lists several company domains (" + strings.Join(choices, ", ") + ")"), + reason: "missing_flag", + } + } + + err = screen.ask("Company domain", "The tenant lists several company domains, and the config file is for the one website your app serves.", func() error { + picked, err := screen.asker.choose(screen, "Company domain this app serves", choices, 0) + if err == nil { + domain = choices[picked] + } + + return err + }) + + return domain, err +} + +// resolvePoppyAPIsAndApp returns the APIs and the web app to put in the config +// file: the ones given by flags, else the ones the tenant and the existing file +// settle on. The file names neither, so the APIs are the Poppy APIs of the +// tenant that its apis entries are at, and the app is the only app that has the +// browser join on. It stops with a FIX line when that is not one. +func (c *cli) resolvePoppyAPIsAndApp(ctx context.Context, in setup.Inputs, file *config.Config) (apis []string, app string, err error) { + apis, app = in.APIs, in.App + if len(apis) > 0 && app != "" { + return apis, app, nil + } + + reader := c.poppyReader() + + err = ansi.Waiting(func() error { + if len(apis) == 0 { + poppy, err := reader.ListPoppyAPIs(ctx) + if err != nil { + return err + } + + if apis, err = setup.ResolveAPIs(file, poppy); err != nil { + return err + } + } + + if app == "" { + joined, err := reader.BrowserJoinApps(ctx) + if err != nil { + return err + } + + if app, err = setup.ResolveApp(joined); err != nil { + return err + } + } + + return nil + }) + if err != nil { + return nil, "", c.poppyError(poppySetupError(err)) + } + + return apis, app, nil +} + +// poppyScreen is what a Poppy command shows while it asks questions in a +// terminal: the text and the mechanics of the screen are in display, and this +// holds the prompts that are asked on it. +type poppyScreen struct { + *display.PoppyScreen + // Asker holds the prompts. It is the one that was set when the screen started. + asker poppyAsker + // Erase tells whether the blocks can be taken back. Without it they stay. + erase bool +} + +// newPoppyScreen starts the screen of a command. The title of the run needs the +// company domain, see ForDomain. +func (c *cli) newPoppyScreen(title string) *poppyScreen { + w := c.renderer.ResultWriter + erase := poppyAsk.erasable(w) + + return &poppyScreen{ + PoppyScreen: c.renderer.NewPoppyScreen(title, c.tenant, display.PoppyLayoutOf(w), erase), + asker: poppyAsk, + erase: erase, + } +} + +// ask draws a block with a title and a reason, runs the questions in it, and takes +// the block back. Ctrl-C takes it back too, tells that nothing was changed, and +// ends the command the way the other prompts do. +func (s *poppyScreen) ask(title, reason string, questions func() error) error { + s.Begin(title, reason) + + err := questions() + eraseErr := s.End() + + if errors.Is(err, terminal.InterruptErr) { + s.DropHeader() + s.Cancelled() + + return s.asker.interrupt(err) + } + + return errors.Join(err, eraseErr) +} + +// askLine asks a question with a title, a reason and a place to type the answer, +// and asks again for an answer that is empty. +func (s *poppyScreen) askLine(title, reason, label, def string) (answer string, err error) { + err = s.ask(title, reason, func() error { + for { + var err error + if answer, err = s.asker.text(s, label, def); err != nil { + return err + } + + if strings.TrimSpace(answer) != "" { + return nil + } + + s.Invalid("A value is required.") + } + }) + + return answer, err +} + +// askText asks a question of the setup and stores the answer in the inputs. An +// answer the question refuses ends the command. +func (s *poppyScreen) askText(question setup.Question, in *setup.Inputs) error { + answer, err := s.askLine(question.Title, question.Reason, question.Prompt, question.Default) + if err != nil { + return err + } + + if err := question.Answer(in, answer); err != nil { + return poppySetupError(err) + } + + return nil +} + +// confirmAsk asks a question answered with yes or no, which is no unless the user +// says yes. +func (s *poppyScreen) confirmAsk(title, reason, message string) (yes bool, err error) { + err = s.ask(title, reason, func() (err error) { + yes, err = s.asker.confirm(s, message, false) + + return err + }) + + return yes, err +} + +// The templates of the prompts when the screen can take them back. They draw the +// question the same way as the lines around them, indented under the title, and +// draw nothing once the question is answered, so there is no echo of the answer +// to take back. +const ( + poppyInputTemplate = ` +{{- if not .ShowAnswer}} {{.Message}} {{if .Default}}{{color "black+h"}}({{.Default}}){{color "reset"}} {{end}}{{end}}` + + poppySelectTemplate = ` +{{- define "option"}} + {{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }} {{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}} + {{- .CurrentOpt.Value}} + {{- color "reset"}} +{{end}} +{{- if not .ShowAnswer}} {{.Message}}{{"\n"}} + {{- range $ix, $option := .PageEntries}} + {{- template "option" $.IterateOption $ix $option}} + {{- end}} +{{- end}}` + + poppyMultiSelectTemplate = ` +{{- define "option"}} + {{- " "}} + {{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}} + {{- " "}} + {{- if index .Checked .CurrentOpt.Index }}{{color .Config.Icons.MarkedOption.Format }}{{ .Config.Icons.MarkedOption.Text }}{{else}}{{color .Config.Icons.UnmarkedOption.Format }}{{ .Config.Icons.UnmarkedOption.Text }}{{end}} + {{- color "reset"}} + {{- " "}}{{- .CurrentOpt.Value}} +{{end}} +{{- if not .ShowAnswer}} + {{- range $ix, $option := .PageEntries}} + {{- template "option" $.IterateOption $ix $option}} + {{- end}} + {{- " "}}{{color "black+h"}}` + poppyMultiSelectKeys + `{{color "reset"}}{{"\n"}} +{{- end}}` + + // The keys of a list of choices that can be several. + poppyMultiSelectKeys = "space to toggle, right for all, left for none, enter to confirm" +) + +// quiet runs a prompt with the templates above when the screen can take its lines +// back. Otherwise the prompt keeps its own, which show the answer. +func (s *poppyScreen) quiet(run func() error) error { + if !s.erase { + return run() + } + + input, choice, multi := survey.InputQuestionTemplate, survey.SelectQuestionTemplate, survey.MultiSelectQuestionTemplate + defer func() { + survey.InputQuestionTemplate, survey.SelectQuestionTemplate, survey.MultiSelectQuestionTemplate = input, choice, multi + }() + + survey.InputQuestionTemplate = poppyInputTemplate + survey.SelectQuestionTemplate = poppySelectTemplate + survey.MultiSelectQuestionTemplate = poppyMultiSelectTemplate + + return run() +} + +// askPrompt runs one prompt. Ctrl-C comes back as terminal.InterruptErr, and any +// other failure as an unexpected error. +func (s *poppyScreen) askPrompt(question survey.Prompt, answer any) error { + err := s.quiet(func() error { return prompt.AskOne(&survey.Question{Prompt: question}, answer) }) + + switch { + case err == nil, errors.Is(err, terminal.InterruptErr): + return err + default: + return unexpectedError(err) + } +} + +// leftOnScreen counts the rows of a list that Ctrl-C leaves on the screen. A list +// does not take its rows back then, and the cursor is below them. +func (s *poppyScreen) leftOnScreen(rows ...string) { + for _, row := range rows { + s.AddRows(s.Rows(row)) + } +} + +// terminalPoppyText asks for a line of text, with a default that Enter accepts. +func terminalPoppyText(s *poppyScreen, label, def string) (string, error) { + var answer string + if err := s.askPrompt(&survey.Input{Message: label, Default: def}, &answer); err != nil { + return "", err + } + + // The prompt takes back the row it ends on. A typed answer that wrapped onto + // more rows than the question took leaves the others behind. + typed := answer + if typed == def { + typed = "" + } + + question := " " + label + " " + if def != "" { + question += "(" + def + ") " + } + + s.AddRows(s.Rows(question+typed) - s.Rows(question)) + + return answer, nil +} + +// terminalPoppyConfirm asks a question answered with yes or no. Enter takes the +// default. +func terminalPoppyConfirm(s *poppyScreen, message string, def bool) (bool, error) { + options := ansi.Faint("(y/N)") + if def { + options = ansi.Faint("(Y/n)") + } + + for { + answer, err := terminalPoppyText(s, message+" "+options, "") + if err != nil { + return false, err + } + + switch strings.ToLower(strings.TrimSpace(answer)) { + case "": + return def, nil + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + } + + s.Invalid("Answer y or n.") + } +} + +// terminalPoppyChoose asks to pick one of the options, which start on the one at +// index def. It returns the index picked. +func terminalPoppyChoose(s *poppyScreen, message string, options []string, def int) (int, error) { + var picked int + + err := s.askPrompt(&survey.Select{ + Message: message, + Options: options, + Default: def, + PageSize: len(options), + // Typing must not filter a short list that has no room to show it. + Filter: func(string, string, int) bool { return true }, + }, &picked) + + if errors.Is(err, terminal.InterruptErr) { + rows := []string{" " + message} + for _, option := range options { + rows = append(rows, " "+option) + } + + s.leftOnScreen(rows...) + } + + return picked, err +} + +// terminalPoppyChooseMany asks to pick any of the options, which start with the +// ones at the indexes in defaults chosen. It returns the indexes picked. +func terminalPoppyChooseMany(s *poppyScreen, message string, options []string, defaults []int) ([]int, error) { + var picked []int + + page := min(len(options), poppyMaxListRows) + + err := s.askPrompt(&survey.MultiSelect{ + Message: message, + Options: options, + Default: defaults, + PageSize: page, + Filter: func(string, string, int) bool { return true }, + }, &picked) + + if errors.Is(err, terminal.InterruptErr) { + var rows []string + for _, option := range options[:page] { + rows = append(rows, strings.Repeat(" ", display.PoppyListMarks)+option) + } + + s.leftOnScreen(append(rows, " "+poppyMultiSelectKeys)...) + } + + return picked, err +} + +// poppyMaxListRows is the most options a list shows at once before it scrolls. +const poppyMaxListRows = 12 + +// poppyChangeNotes explain each value of the changes hint. The last, the cautious +// one, is the one a question starts on. +var poppyChangeNotes = map[string]string{ + "none": "It only reads, nothing is changed", + "reversible": "It changes something that can be undone", + "irreversible": "It cannot be undone", +} + +const ( + poppyChangesQuestion = "Does it change anything?" + poppyPersonalDataQuestion = "Does it show or change personal data?" +) + +// askPoppyHints asks for the hints of the scopes that have none, in a terminal. +// The answers go into the inputs as if --hint had given them. It starts with one +// question that can leave them all for later, and returns how many scopes were +// left without hints. After the questions of the first scope it offers to give +// the same answers to the others. Every question starts on the cautious answer, +// so that pressing Enter is always safe. +func askPoppyHints(s *poppyScreen, scopes []setup.HintScope, in *setup.Inputs) (unhinted int, err error) { + if len(scopes) == 0 { + return 0, nil + } + + var set bool + + intro := fmt.Sprintf("%d scopes have no hints yet. Agents show them to the user before asking for approval.", len(scopes)) + question := "Set them now?" + + if len(scopes) == 1 { + intro = "1 scope has no hints yet. Agents show them to the user before asking for approval." + question = "Set it now?" + } + + err = s.ask("Hints for your scopes", intro, func() (err error) { + set, err = s.asker.confirm(s, question, true) + + return err + }) + if err != nil || !set { + return len(scopes), err + } + + if in.Hints == nil { + in.Hints = make(map[string]config.Hints, len(scopes)) + } + + for i := 0; i < len(scopes); i++ { + var ( + hint config.Hints + same bool + ) + + scope := scopes[i] + remaining := len(scopes) - i - 1 + title := fmt.Sprintf("Hints for %s on %s (%d of %d)", text.Line(scope.Name), text.Line(strings.Join(scope.APIs, ", ")), i+1, len(scopes)) + + err = s.ask(title, scope.Description, func() (err error) { + if hint, err = askPoppyScopeHint(s); err != nil { + return err + } + + if i > 0 || remaining == 0 { + return nil + } + + noun := "scopes" + if remaining == 1 { + noun = "scope" + } + + same, err = s.asker.confirm(s, fmt.Sprintf("Use the same answers for the remaining %d %s?", remaining, noun), false) + + return err + }) + if err != nil { + return 0, err + } + + in.Hints[scope.Name] = hint + + if same { + for _, other := range scopes[i+1:] { + in.Hints[other.Name] = hint + } + + break + } + } + + return 0, nil +} + +// askPoppyScopeHint asks the two questions about one scope, inside the block of +// the scope. The first answer stays in view while the second is asked. +func askPoppyScopeHint(s *poppyScreen) (config.Hints, error) { + var hint config.Hints + + width := 0 + for _, changes := range config.ValidChanges { + width = max(width, utf8.RuneCountInString(changes)) + } + + options := make([]string, len(config.ValidChanges)) + for i, changes := range config.ValidChanges { + options[i] = changes + strings.Repeat(" ", width-utf8.RuneCountInString(changes)+2) + poppyChangeNotes[changes] + } + + picked, err := s.asker.choose(s, poppyChangesQuestion, options, len(options)-1) + if err != nil { + return hint, err + } + + hint.Changes = config.ValidChanges[picked] + s.Answered(poppyChangesQuestion, hint.Changes) + + if hint.PersonalData, err = s.asker.confirm(s, poppyPersonalDataQuestion, true); err != nil { + return hint, err + } + + answer := "No" + if hint.PersonalData { + answer = "Yes" + } + s.Answered(poppyPersonalDataQuestion, answer) + + return hint, nil +} diff --git a/internal/cli/poppy_agents.go b/internal/cli/poppy_agents.go new file mode 100644 index 000000000..b6167dd98 --- /dev/null +++ b/internal/cli/poppy_agents.go @@ -0,0 +1,671 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "slices" + "strings" + + "github.com/auth0/go-auth0/v3/management/core" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/poppy/agents" + "github.com/auth0/auth0-cli/internal/poppy/config" + "github.com/auth0/auth0-cli/internal/poppy/setup" + "github.com/auth0/auth0-cli/internal/poppy/text" +) + +// poppyAgentArgument is an argument of the agents commands that a terminal asks +// for when it is not given. +type poppyAgentArgument struct { + // Title and reason are drawn above the question, and label is where the answer is + // typed. + title, reason, label string +} + +var ( + poppyAgentURL = poppyAgentArgument{ + title: "Agent URL", + reason: "URL of the agent's metadata document.", + label: "URL", + } + poppyAgentRef = poppyAgentArgument{ + title: "Agent URL or ID", + reason: "URL or agent ID of the agent.", + label: "URL or ID", + } +) + +// Management API scopes each agents command needs, checked before it changes anything. +var ( + poppyListScopes = []string{"read:agents", "read:clients", "read:client_grants", "read:resource_servers"} + poppyShowScopes = append(slices.Clone(poppyListScopes), "read:client_credentials") + poppyRegisterScopes = []string{ + "read:clients", "create:clients", "update:clients", "read:agents", "create:agents", + "read:client_grants", "create:client_grants", "read:resource_servers", + } + poppyRemoveScopes = []string{ + "read:agents", "read:clients", "read:client_grants", "read:resource_servers", + "delete:client_grants", "delete:agents", "delete:clients", + } +) + +func poppyAgentsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "agents", + Short: "Manage the agents your company allows", + Long: poppyLong("Manages the personal agents your company allows. In the beta, Auth0 accepts only agents " + + "your company registers, so the registered list is the allow list. A Poppy agent is an agent app " + + "with a grant to a Poppy API."), + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(poppyAgentsRegisterCmd(cli)) + cmd.AddCommand(poppyAgentsListCmd(cli)) + cmd.AddCommand(poppyAgentsShowCmd(cli)) + cmd.AddCommand(poppyAgentsRemoveCmd(cli)) + + return cmd +} + +type poppyRegisterInputs struct { + APIs []string + Name string + Out string + Yes bool + Scopes []string + AllScopes bool + RequireDPoP bool +} + +func poppyAgentsRegisterCmd(cli *cli) *cobra.Command { + var inputs poppyRegisterInputs + + cmd := &cobra.Command{ + Use: "register [agent-url]", + Args: cobra.MaximumNArgs(1), + Short: "Register an agent so it can sign in to your company", + Long: poppyLong("Registers an agent from the URL of its metadata document. The command previews the document, " + + "creates or updates the agent app, creates the agent record and grants the agent your Poppy APIs. " + + "It changes the tenant only: the config file is not written, and is read only to show the hints of the " + + "scopes on offer. By default it grants every Poppy API of the tenant. In a terminal it asks " + + "which scopes to grant; otherwise pass `--scopes` or `--all-scopes`. DPoP is required only with " + + "`--require-dpop`. Running it again refreshes the agent app from the current document and changes " + + "nothing else, which is also how you pick up keys the agent has rotated."), + Example: ` auth0 poppy agents register https://muse.example/agent.json + auth0 poppy agents register https://muse.example/agent.json --api https://api.shop.example --scopes orders:read --yes + auth0 poppy agents register https://muse.example/agent.json --all-scopes --require-dpop`, + RunE: func(cmd *cobra.Command, args []string) error { + return runPoppyAgentsRegister(cmd, cli, inputs, args) + }, + } + + poppyAPI.RegisterStringArray(cmd, &inputs.APIs, nil) + poppyName.RegisterString(cmd, &inputs.Name, "") + poppyOut.RegisterString(cmd, &inputs.Out, config.DefaultFileName) + poppyYes.RegisterBool(cmd, &inputs.Yes, false) + poppyGrantScopes.RegisterStringSlice(cmd, &inputs.Scopes, nil) + poppyAllScopes.RegisterBool(cmd, &inputs.AllScopes, false) + poppyRequireDPoP.RegisterBool(cmd, &inputs.RequireDPoP, false) + registerPoppyOutputFlags(cmd, cli) + cmd.MarkFlagsMutuallyExclusive(poppyGrantScopes.LongForm, poppyAllScopes.LongForm) + + return cmd +} + +func poppyAgentsListCmd(cli *cli) *cobra.Command { + var all bool + + cmd := &cobra.Command{ + Use: "list", + Args: cobra.NoArgs, + Short: "List the registered agents", + Long: poppyLong("Lists the Poppy agents registered with your company, with their metadata status and the APIs " + + "each one can use. By default it shows only agents that have a grant to a Poppy API; use `--all` to " + + "include the others."), + Example: ` auth0 poppy agents list + auth0 poppy agents list --all + auth0 poppy agents list --json`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runPoppyAgentsList(cmd, cli, all) + }, + } + + poppyAll.RegisterBool(cmd, &all, false) + registerPoppyOutputFlags(cmd, cli) + + return cmd +} + +func poppyAgentsShowCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "show [agent-url|agent-id]", + Args: cobra.MaximumNArgs(1), + Short: "Show the details of an agent", + Long: poppyLong("Shows one agent's details, given its URL or agent ID. It also checks the agent's metadata " + + "document live and tells you whether the keys Auth0 stored are still current, so you know when the " + + "agent needs to be registered again."), + Example: ` auth0 poppy agents show https://muse.example/agent.json + auth0 poppy agents show + auth0 poppy agents show https://muse.example/agent.json --json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runPoppyAgentsShow(cmd, cli, args) + }, + } + + registerPoppyOutputFlags(cmd, cli) + + return cmd +} + +type poppyRemoveInputs struct { + DryRun bool + RemoveAllGrants bool +} + +func poppyAgentsRemoveCmd(cli *cli) *cobra.Command { + var inputs poppyRemoveInputs + + cmd := &cobra.Command{ + Use: "remove [agent-url|agent-id]", + Aliases: []string{"unregister", "rm"}, + Args: cobra.MaximumNArgs(1), + Short: "Remove an agent's Poppy access", + Long: poppyLong("Removes the agent's Poppy access: its grants to your Poppy APIs. It changes the tenant only, " + + "and does not touch the config file. When the agent has no other grants it also removes the agent record and the app, so registering " + + "the agent again creates a new app and its users must sign in again. When the app has other grants, " + + "only the Poppy access is removed and the other grants are listed, unless you pass `--remove-all-grants`; " + + "in a terminal you are asked which to do. This command does not revoke tokens already issued: access " + + "tokens stay valid until they expire. Use `--dry-run` to print what would be removed, and `--force` " + + "to skip the confirmation."), + Example: ` auth0 poppy agents remove https://muse.example/agent.json + auth0 poppy agents remove https://muse.example/agent.json --dry-run + auth0 poppy agents remove --remove-all-grants --force`, + RunE: func(cmd *cobra.Command, args []string) error { + return runPoppyAgentsRemove(cmd, cli, inputs, args) + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + poppyDryRun.RegisterBool(cmd, &inputs.DryRun, false) + poppyRemoveAllGrants.RegisterBool(cmd, &inputs.RemoveAllGrants, false) + registerPoppyOutputFlags(cmd, cli) + + return cmd +} + +// poppyAgents builds the agents service over the Management API clients. +func (c *cli) poppyAgents() *agents.Service { + return &agents.Service{ + Clients: c.apiv3.ClientV3, + Agents: c.apiv3.Agent, + Grants: c.apiv3.ClientGrant, + ResourceServers: c.apiv3.ResourceServerV3, + Credentials: c.apiv3.ClientCredential, + } +} + +// checkPoppyScopes fails before any change when the saved login lacks a scope +// the command needs. In env auth mode the saved login is not the one in use, so +// it is not consulted, and neither is it when there is none to inspect: the +// scope error of the API itself is mapped instead (see poppyError). +func (c *cli) checkPoppyScopes(required []string) error { + if envAuthEnabled(os.Getenv) { + return nil + } + + tenant, err := c.Config.GetTenant(c.tenant) + if err != nil { + return nil + } + + granted := scopesFromToken(tenant.GetAccessToken()) + if granted == nil { + return nil + } + + if missing := missingScopes(granted, required); len(missing) > 0 { + return authError{ + err: fmt.Errorf("missing scope %s. Run: auth0 login --scopes %s", strings.Join(missing, ", "), strings.Join(missing, ",")), + reason: "missing_scopes", + } + } + + return nil +} + +// poppyUnavailableError reports a 403 that is not a missing scope: the tenant +// does not have a feature the Poppy commands need. +type poppyUnavailableError struct { + err error + debug bool +} + +func (e poppyUnavailableError) Error() string { + message := "the tenant may lack Agent as Principal or the Poppy beta; contact support" + if e.debug { + message += fmt.Sprintf(" (status %d)", http.StatusForbidden) + } + + return message +} + +func (e poppyUnavailableError) Unwrap() error { return e.err } + +// poppyError maps a 403 from the Management API to the login command for a +// missing scope, or to the tenant lacking a Poppy feature. +func (c *cli) poppyError(err error) error { + var partial *agents.PartialError + if errors.As(err, &partial) { + partial.Err = c.poppyError(partial.Err) + + return err + } + + var setupPartial *setup.PartialError + if errors.As(err, &setupPartial) { + setupPartial.Err = c.poppyError(setupPartial.Err) + + return err + } + + var apiErr *core.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusForbidden { + return err + } + + var body []byte + if inner := apiErr.Unwrap(); inner != nil { + body = []byte(inner.Error()) + } + + if scopeErr := isInsufficientScopeError(apiErr.StatusCode, body); scopeErr != nil { + return scopeErr + } + + return poppyUnavailableError{err: err, debug: c.debug} +} + +// poppyRef returns the argument given, or asks for it in a terminal. +func poppyRef(cmd *cobra.Command, screen *poppyScreen, argument poppyAgentArgument, args []string) (string, error) { + if len(args) == 1 { + return args[0], nil + } + + if !poppyAsk.canPrompt(cmd) { + return "", usageError{err: fmt.Errorf("missing a required argument: %s", argument.title), reason: "missing_required_args"} + } + + return screen.askLine(argument.title, argument.reason, argument.label, "") +} + +// readPoppyConfig reads the config file, or returns nil when there is none. A +// file that exists but is not valid stops the command before it changes anything. +func readPoppyConfig(path string) (*config.Config, error) { + cfg, err := config.Read(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + + return cfg, err +} + +func poppyScopeHints(cfg *config.Config, scope string) *config.Hints { + if cfg == nil { + return nil + } + + return cfg.Scopes[scope].Hints +} + +func runPoppyAgentsRegister(cmd *cobra.Command, cli *cli, inputs poppyRegisterInputs, args []string) error { + screen := cli.newPoppyScreen("") + + agentURL, err := poppyRef(cmd, screen, poppyAgentURL, args) + if err != nil { + return err + } + + if !strings.HasPrefix(agentURL, "https://") { + return validationError{err: fmt.Errorf("the agent URL must start with https://: %s", agentURL), reason: "invalid_agent_url"} + } + + if err := cli.checkPoppyScopes(poppyRegisterScopes); err != nil { + return err + } + + cfg, err := readPoppyConfig(inputs.Out) + if err != nil { + return err + } + + ctx := cmd.Context() + svc := cli.poppyAgents() + + preview, err := svc.Preview(ctx, agentURL) + if err != nil { + var invalid *agents.InvalidDocumentError + if errors.As(err, &invalid) { + details, _ := json.Marshal(map[string]any{"violations": invalid.Violations}) + + return validationError{err: err, details: details, reason: "agent_invalid"} + } + + return cli.poppyError(err) + } + + apis, err := svc.PoppyAPIs(ctx, inputs.APIs) + if err != nil { + return cli.poppyError(err) + } + + grants, err := agents.ChooseScopes(apis, inputs.Scopes, inputs.AllScopes) + + // With --json, only a person at a terminal who is asked something sees the + // metadata of the agent. + asking := poppyAsk.canPrompt(cmd) && (!inputs.Yes || errors.Is(err, agents.ErrScopesRequired)) + if cli.renderer.PoppyHuman() || asking { + cli.renderer.PoppyAgentPreview(preview) + } + + switch { + case errors.Is(err, agents.ErrScopesRequired) && poppyAsk.canPrompt(cmd): + grants, err = promptPoppyGrants(screen, apis, cfg) + case errors.Is(err, agents.ErrScopesRequired): + return scopesRequiredError(apis, cfg) + case err != nil: + return validationError{err: err, reason: "unknown_scope"} + } + if err != nil { + return err + } + + if poppyAsk.canPrompt(cmd) && !inputs.Yes { + cli.renderer.PoppyRegisterPlan(preview, inputs.Name, inputs.RequireDPoP, grants) + + proceed, err := screen.confirmAsk("Register the agent", "This changes your Auth0 tenant: it creates or updates the agent app and grants it your APIs.", "Register this agent?") + if err != nil { + return err + } + + if !proceed { + cli.renderer.PoppyDeclined("Nothing was registered.") + + return nil + } + } + + reg, err := svc.Register(ctx, agents.RegisterRequest{ + Preview: preview, + Name: inputs.Name, + RequireDPoP: inputs.RequireDPoP, + Grants: grants, + }) + if err != nil { + return cli.poppyError(err) + } + + if cli.renderer.PoppyJSON(reg) { + return nil + } + + cli.renderer.PoppyRegistration(reg) + + return nil +} + +// scopesRequiredError lists the scopes on offer, with their hints, for a caller +// that cannot be asked. +func scopesRequiredError(apis []agents.API, cfg *config.Config) error { + type offered struct { + Name string `json:"name"` + Description string `json:"description"` + Hints *config.Hints `json:"hints,omitempty"` + } + type offer struct { + Audience string `json:"audience"` + Scopes []offered `json:"scopes"` + } + + var message strings.Builder + message.WriteString("pass --scopes or --all-scopes to choose the scopes to grant. Offered scopes:") + + offers := make([]offer, len(apis)) + for i, api := range apis { + offers[i] = offer{Audience: api.Identifier, Scopes: make([]offered, len(api.Scopes))} + + names := make([]string, len(api.Scopes)) + for j, scope := range api.Scopes { + names[j] = scope.Name + offers[i].Scopes[j] = offered{Name: scope.Name, Description: scope.Description, Hints: poppyScopeHints(cfg, scope.Name)} + } + + fmt.Fprintf(&message, "\n %s: %s", api.Identifier, strings.Join(names, ", ")) + } + + details, _ := json.Marshal(map[string]any{"apis": offers}) + + return validationError{err: errors.New(message.String()), details: details, reason: "scopes_required"} +} + +// promptPoppyGrants asks which scopes to grant on each API. Scopes that only +// read are selected to begin with. +func promptPoppyGrants(screen *poppyScreen, apis []agents.API, cfg *config.Config) ([]agents.Grant, error) { + grants := make([]agents.Grant, len(apis)) + + for i, api := range apis { + choices := make([]display.PoppyScopeChoice, len(api.Scopes)) + + var defaults []int + for j, scope := range api.Scopes { + hints := poppyScopeHints(cfg, scope.Name) + choices[j] = display.PoppyScopeChoice{Name: scope.Name, Description: scope.Description, Hints: hints} + + if hints != nil && hints.Changes == "none" { + defaults = append(defaults, j) + } + } + + reason := "Choose what the agent may do on " + text.Line(api.Identifier) + "." + if len(defaults) > 0 { + reason += " The scopes your hints say change nothing are selected to begin with." + } + + title := "Scopes to grant on " + text.Line(api.Identifier) + + var picked []int + + err := screen.ask(title, reason, func() (err error) { + picked, err = screen.asker.chooseMany(screen, title, screen.ScopeRows(choices), defaults) + + return err + }) + if err != nil { + return nil, err + } + + if len(picked) == 0 { + return nil, validationError{err: fmt.Errorf("choose at least one scope to grant on %s", api.Identifier), reason: "scopes_required"} + } + + grants[i] = agents.Grant{Audience: api.Identifier, Scopes: make([]string, len(picked))} + for j, index := range picked { + grants[i].Scopes[j] = api.Scopes[index].Name + } + } + + return grants, nil +} + +func runPoppyAgentsList(cmd *cobra.Command, cli *cli, all bool) error { + if err := cli.checkPoppyScopes(poppyListScopes); err != nil { + return err + } + + var list []agents.Agent + err := ansi.Waiting(func() (err error) { + list, err = cli.poppyAgents().List(cmd.Context(), all) + + return err + }) + if err != nil { + return cli.poppyError(err) + } + + cli.renderer.PoppyAgentList(list) + + return nil +} + +func runPoppyAgentsShow(cmd *cobra.Command, cli *cli, args []string) error { + ref, err := poppyRef(cmd, cli.newPoppyScreen(""), poppyAgentRef, args) + if err != nil { + return err + } + + if err := cli.checkPoppyScopes(poppyShowScopes); err != nil { + return err + } + + var detail *agents.Detail + err = ansi.Waiting(func() (err error) { + detail, err = cli.poppyAgents().Show(cmd.Context(), ref) + + return err + }) + if err != nil { + return cli.poppyError(err) + } + + cli.renderer.PoppyAgentShow(detail) + + return nil +} + +func runPoppyAgentsRemove(cmd *cobra.Command, cli *cli, inputs poppyRemoveInputs, args []string) error { + screen := cli.newPoppyScreen("") + + ref, err := poppyRef(cmd, screen, poppyAgentRef, args) + if err != nil { + return err + } + + if err := cli.checkPoppyScopes(poppyRemoveScopes); err != nil { + return err + } + + ctx := cmd.Context() + svc := cli.poppyAgents() + + var removal *agents.Removal + err = ansi.Waiting(func() (err error) { + removal, err = svc.PlanRemoval(ctx, ref) + + return err + }) + if err != nil { + var notPoppy *agents.NotPoppyAgentError + if errors.As(err, ¬Poppy) { + return validationError{err: err, reason: "not_poppy_agent"} + } + + return cli.poppyError(err) + } + + decision := agents.Decide(removal, inputs.RemoveAllGrants, cli.force, poppyAsk.canPrompt(cmd)) + opts := agents.RemoveOptions{Everything: decision.Everything} + plan := svc.Plan(removal, opts) + + if removal.Gone && cli.renderer.PoppyHuman() { + cli.renderer.PoppyRemovalGone(ref) + } + + if inputs.DryRun { + if !cli.renderer.PoppyJSON(plan) { + cli.renderer.PoppyRemoval(display.PoppyRemovalWould, plan) + } + + return nil + } + + switch decision.Approval { + case agents.Refuse: + if cli.renderer.PoppyHuman() { + cli.renderer.PoppyRemoval(display.PoppyRemovalWill, plan) + } + + details, _ := json.Marshal(plan) + + return validationError{ + err: errors.New("confirmation required: re-run with --force to remove the agent"), + details: details, + reason: "confirmation_required", + } + case agents.Ask, agents.Choose: + cli.renderer.PoppyRemoval(display.PoppyRemovalWill, plan) + + proceed, everything, err := askPoppyRemoval(screen, decision.Approval == agents.Choose) + if err != nil { + return err + } + + if !proceed { + cli.renderer.PoppyDeclined("Nothing was removed.") + + return nil + } + opts.Everything = everything + } + + result, err := svc.Remove(ctx, removal, opts) + if err != nil { + return cli.poppyError(err) + } + + if cli.renderer.PoppyJSON(result) { + return nil + } + + cli.renderer.PoppyRemoved(result, removal.Gone) + + return nil +} + +// askPoppyRemoval asks the user to confirm a removal. With choose set, the app +// has other grants, so it offers to remove only the Poppy access. +func askPoppyRemoval(screen *poppyScreen, choose bool) (proceed, everything bool, err error) { + if !choose { + proceed, err = screen.confirmAsk("Remove the agent", + "This removes the grants of the agent, and its record and app. Tokens already issued stay valid until they expire.", "Remove this agent?") + + return proceed, true, err + } + + const ( + onlyPoppy = "Remove only the Poppy access" + all = "Remove everything" + cancelling = "Cancel" + ) + + options := []string{onlyPoppy, all, cancelling} + + var picked int + + err = screen.ask("Other grants of the agent app", + "The agent app has grants to APIs other than Poppy ones. Removing only the Poppy access leaves the app and those grants.", func() (err error) { + picked, err = screen.asker.choose(screen, "What should be removed?", options, 0) + + return err + }) + if err != nil { + return false, false, err + } + + return options[picked] != cancelling, options[picked] == all, nil +} diff --git a/internal/cli/poppy_agents_test.go b/internal/cli/poppy_agents_test.go new file mode 100644 index 000000000..0266656b4 --- /dev/null +++ b/internal/cli/poppy_agents_test.go @@ -0,0 +1,874 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "path/filepath" + "strings" + "testing" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/auth0/mock" + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/poppy/agents" + "github.com/auth0/auth0-cli/internal/poppy/config" +) + +const ( + poppyTestAgentURL = "https://muse.example/agent.json" + poppyTestAPI = "https://api.shop.example" + poppyTestClientID = "client_muse" + poppyTestAgentID = "agt_muse" +) + +type poppyAgentsHarness struct { + clients *mock.MockClientAPIV3 + agents *mock.MockAgentAPIV3 + grants *mock.MockClientGrantAPIV3 + servers *mock.MockResourceServerAPIV3 + credentials *mock.MockClientCredentialAPIV3 + out *bytes.Buffer + messages *bytes.Buffer + json bool + agentMode bool + renderer *display.Renderer + root func(args ...string) error +} + +func newPoppyAgentsHarness(t *testing.T) *poppyAgentsHarness { + t.Helper() + + ctrl := gomock.NewController(t) + h := &poppyAgentsHarness{ + clients: mock.NewMockClientAPIV3(ctrl), + agents: mock.NewMockAgentAPIV3(ctrl), + grants: mock.NewMockClientGrantAPIV3(ctrl), + servers: mock.NewMockResourceServerAPIV3(ctrl), + credentials: mock.NewMockClientCredentialAPIV3(ctrl), + out: &bytes.Buffer{}, + messages: &bytes.Buffer{}, + } + + renderer := display.NewRenderer() + renderer.ResultWriter = h.out + renderer.MessageWriter = h.messages + + testCLI := &cli{ + renderer: renderer, + apiv3: &auth0.APIV3{ + ClientV3: h.clients, + Agent: h.agents, + ClientGrant: h.grants, + ResourceServerV3: h.servers, + ClientCredential: h.credentials, + }, + } + + h.renderer = renderer + + h.root = func(args ...string) error { + root := buildRootCmd(testCLI) + root.PersistentFlags().BoolVar(&testCLI.noInput, "no-input", false, "Disable interactivity.") + testCLI.agentMode = h.agentMode + root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + applyAgentModeDefaults(testCLI, cmd) + testCLI.configureRenderer() + + return nil + } + root.SilenceErrors = true + root.SilenceUsage = true + addSubCommands(root, testCLI) + // Registering the flags resets the output options to their defaults. + testCLI.json = h.json + root.SetArgs(append([]string{"poppy", "agents"}, args...)) + root.SetContext(context.Background()) + + return root.Execute() + } + + return h +} + +func testPage[C comparable, T, R any](items ...T) *core.Page[C, T, R] { + return &core.Page[C, T, R]{ + Results: items, + NextPageFunc: func(context.Context) (*core.Page[C, T, R], error) { return nil, core.ErrNoPages }, + } +} + +func testServerPage(items ...*managementv3.ResourceServer) *auth0.ResourceServerPage { + return testPage[*int, *managementv3.ResourceServer, *managementv3.ListResourceServerOffsetPaginatedResponseContent](items...) +} + +func testClientPage(items ...*managementv3.Client) *auth0.ClientListPage { + return testPage[*int, *managementv3.Client, *managementv3.ListClientsOffsetPaginatedResponseContent](items...) +} + +func testGrantPage(items ...*managementv3.ClientGrantResponseContent) *auth0.ClientGrantPage { + return testPage[*string, *managementv3.ClientGrantResponseContent, *managementv3.ListClientGrantPaginatedResponseContent](items...) +} + +func testAgentPage(items ...*managementv3.AgentResponseContent) *auth0.AgentPage { + return testPage[*string, *managementv3.AgentResponseContent, *managementv3.ListAgentsResponseContent](items...) +} + +func testPtr[T any](value T) *T { return &value } + +func testShopServer() *managementv3.ResourceServer { + return &managementv3.ResourceServer{ + ID: testPtr("rs_shop"), + Identifier: testPtr(poppyTestAPI), + UseForPersonalAgentProtocol: testPtr(true), + Scopes: []*managementv3.ResourceServerScope{ + {Value: "orders:read", Description: testPtr("See your orders")}, + {Value: "checkout", Description: testPtr("Place orders")}, + }, + } +} + +func testAgentApp() *managementv3.Client { + return &managementv3.Client{ + ClientID: testPtr(poppyTestClientID), + Name: testPtr("Meta Muse"), + ExternalClientID: testPtr(poppyTestAgentURL), + ExternalMetadataType: testPtr(managementv3.ClientExternalMetadataTypeEnumCimd), + ExternalMetadataStatus: testPtr(managementv3.ClientExternalMetadataStatusEnumAvailable), + AgentID: testPtr(poppyTestAgentID), + } +} + +func testGrant(id, audience string, scopes ...string) *managementv3.ClientGrantResponseContent { + return &managementv3.ClientGrantResponseContent{ID: testPtr(id), Audience: testPtr(audience), Scope: scopes} +} + +func validPreview() *managementv3.PreviewCimdMetadataResponseContent { + return &managementv3.PreviewCimdMetadataResponseContent{ + Validation: &managementv3.CimdValidationResult{Valid: true}, + MappedFields: &managementv3.CimdMappedClientFields{Name: testPtr("Meta Muse")}, + } +} + +// expectRemovalLookup sets the calls that plan the removal of the test agent. +func (h *poppyAgentsHarness) expectRemovalLookup(grants ...*managementv3.ClientGrantResponseContent) { + h.clients.EXPECT().List(gomock.Any(), gomock.Any()).Return(testClientPage(testAgentApp()), nil) + h.agents.EXPECT().Read(gomock.Any(), poppyTestAgentID).Return(&managementv3.AgentResponseContent{AgentID: poppyTestAgentID, Name: "Meta Muse", ExternalAgentID: testPtr(poppyTestAgentURL)}, nil) + h.servers.EXPECT().List(gomock.Any(), gomock.Any()).Return(testServerPage(testShopServer()), nil) + h.grants.EXPECT().List(gomock.Any(), gomock.Any()).Return(testGrantPage(grants...), nil) +} + +func decodeCLIError(t *testing.T, err error) (reason string, details map[string]any) { + t.Helper() + + require.Error(t, err) + reason = errorReason(err) + + if raw := errorDetails(err); raw != nil { + require.NoError(t, json.Unmarshal(raw, &details)) + } + + return reason, details +} + +func TestPoppyAgentsRegisterErrors(t *testing.T) { + t.Run("the URL must be https", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + + err := h.root("register", "http://muse.example/agent.json") + + reason, _ := decodeCLIError(t, err) + assert.Equal(t, "invalid_agent_url", reason) + assert.Contains(t, err.Error(), "must start with https://") + }) + + t.Run("an invalid document lists its violations", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.clients.EXPECT().PreviewCimdMetadata(gomock.Any(), gomock.Any()).Return(&managementv3.PreviewCimdMetadataResponseContent{ + Validation: &managementv3.CimdValidationResult{Violations: []string{"redirect_uris is empty"}}, + }, nil) + + err := h.root("register", poppyTestAgentURL, "--out", filepath.Join(t.TempDir(), "none.json")) + + reason, details := decodeCLIError(t, err) + assert.Equal(t, "agent_invalid", reason) + assert.Equal(t, []any{"redirect_uris is empty"}, details["violations"]) + }) + + t.Run("scopes are required without a terminal", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.clients.EXPECT().PreviewCimdMetadata(gomock.Any(), gomock.Any()).Return(validPreview(), nil) + h.servers.EXPECT().List(gomock.Any(), gomock.Any()).Return(testServerPage(testShopServer()), nil) + + err := h.root("register", poppyTestAgentURL, "--out", filepath.Join(t.TempDir(), "none.json")) + + reason, details := decodeCLIError(t, err) + assert.Equal(t, "scopes_required", reason) + assert.Contains(t, err.Error(), "pass --scopes or --all-scopes") + assert.Contains(t, err.Error(), poppyTestAPI+": orders:read, checkout") + + offers, ok := details["apis"].([]any) + require.True(t, ok) + require.Len(t, offers, 1) + assert.Equal(t, poppyTestAPI, offers[0].(map[string]any)["audience"]) + }) + + t.Run("an unknown scope", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.clients.EXPECT().PreviewCimdMetadata(gomock.Any(), gomock.Any()).Return(validPreview(), nil) + h.servers.EXPECT().List(gomock.Any(), gomock.Any()).Return(testServerPage(testShopServer()), nil) + + err := h.root("register", poppyTestAgentURL, "--scopes", "nope", "--out", filepath.Join(t.TempDir(), "none.json")) + + reason, _ := decodeCLIError(t, err) + assert.Equal(t, "unknown_scope", reason) + }) + + t.Run("a registration with named scopes", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.clients.EXPECT().PreviewCimdMetadata(gomock.Any(), gomock.Any()).Return(validPreview(), nil) + h.servers.EXPECT().List(gomock.Any(), gomock.Any()).Return(testServerPage(testShopServer()), nil) + h.clients.EXPECT().RegisterCimdClient(gomock.Any(), gomock.Any()).Return(&managementv3.RegisterCimdClientResponseContent{ClientID: poppyTestClientID}, nil) + h.agents.EXPECT().Create(gomock.Any(), gomock.Any()).Return(&managementv3.AgentResponseContent{AgentID: poppyTestAgentID, Name: "Meta Muse"}, nil) + h.grants.EXPECT().List(gomock.Any(), gomock.Any()).Return(testGrantPage(), nil) + h.grants.EXPECT().Create(gomock.Any(), gomock.Any()).Return(&managementv3.CreateClientGrantResponseContent{ID: testPtr("cgr_new")}, nil) + + err := h.root("register", poppyTestAgentURL, "--scopes", "orders:read", "--json", "--out", filepath.Join(t.TempDir(), "none.json")) + + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(h.out.Bytes(), &got)) + assert.Equal(t, poppyTestClientID, got["client_id"]) + assert.Equal(t, poppyTestAgentID, got["agent_id"]) + assert.NotContains(t, got, "config_updated", "register changes the tenant only") + assert.Empty(t, h.messages.String(), "nothing but the JSON") + }) + + t.Run("the output flags leave nothing on either stream but the JSON", func(t *testing.T) { + for _, flag := range []string{"--json", "--json-compact"} { + h := newPoppyAgentsHarness(t) + h.allowRegistration() + + require.NoError(t, h.root("register", poppyTestAgentURL, "--scopes", "orders:read", flag, "--out", filepath.Join(t.TempDir(), "none.json")), flag) + + assert.Empty(t, h.messages.String(), flag) + assert.True(t, json.Valid(h.out.Bytes()), "%s: %s", flag, h.out.String()) + } + }) + + t.Run("the text lists the metadata, what was done and what to do next", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.allowRegistration() + h.clients.EXPECT().Update(gomock.Any(), poppyTestClientID, gomock.Any()).Return(&managementv3.UpdateClientResponseContent{}, nil) + + require.NoError(t, h.root("register", poppyTestAgentURL, "--scopes", "orders:read", "--require-dpop", "--out", filepath.Join(t.TempDir(), "none.json"))) + + out := h.out.String() + assert.True(t, strings.HasPrefix(out, "Agent metadata "+poppyTestAgentURL+"\n"), out) + assert.Contains(t, out, " name Meta Muse\n") + assert.Contains(t, out, " keys 0 ()\n") + assert.Contains(t, out, " ok Agent app created "+poppyTestClientID+"\n") + assert.Contains(t, out, " ok DPoP required\n") + assert.Contains(t, out, " ok Agent record created "+poppyTestAgentID+" (in tokens as "+poppyTestAgentURL+")\n") + assert.Contains(t, out, " ok Grant "+poppyTestAPI+": orders:read\n") + assert.True(t, strings.HasSuffix(out, "\nNext\n auth0 poppy agents show "+poppyTestAgentURL+" to check the agent.\n"), out) + assert.Empty(t, h.messages.String()) + }) + + t.Run("a grant the app already has is said so", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.clients.EXPECT().PreviewCimdMetadata(gomock.Any(), gomock.Any()).Return(validPreview(), nil) + h.servers.EXPECT().List(gomock.Any(), gomock.Any()).Return(testServerPage(testShopServer()), nil) + h.clients.EXPECT().RegisterCimdClient(gomock.Any(), gomock.Any()).Return(&managementv3.RegisterCimdClientResponseContent{ClientID: poppyTestClientID}, nil) + h.agents.EXPECT().Create(gomock.Any(), gomock.Any()).Return(&managementv3.AgentResponseContent{AgentID: poppyTestAgentID, Name: "Meta Muse"}, nil) + + existing := testGrant("cgr_old", poppyTestAPI, "orders:read") + existing.SubjectType = testPtr(managementv3.ClientGrantSubjectTypeEnumUser) + h.grants.EXPECT().List(gomock.Any(), gomock.Any()).Return(testGrantPage(existing), nil) + + require.NoError(t, h.root("register", poppyTestAgentURL, "--scopes", "orders:read", "--out", filepath.Join(t.TempDir(), "none.json"))) + + assert.Contains(t, h.out.String(), "Grant "+poppyTestAPI+": orders:read (already granted)\n") + assert.NotContains(t, h.out.String(), "FIX") + }) + + t.Run("a grant with other scopes than the ones requested says what differs and how to set them", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.clients.EXPECT().PreviewCimdMetadata(gomock.Any(), gomock.Any()).Return(validPreview(), nil) + h.servers.EXPECT().List(gomock.Any(), gomock.Any()).Return(testServerPage(testShopServer()), nil) + h.clients.EXPECT().RegisterCimdClient(gomock.Any(), gomock.Any()).Return(&managementv3.RegisterCimdClientResponseContent{ClientID: poppyTestClientID}, nil) + h.agents.EXPECT().Create(gomock.Any(), gomock.Any()).Return(&managementv3.AgentResponseContent{AgentID: poppyTestAgentID, Name: "Meta Muse"}, nil) + + existing := testGrant("cgr_old", poppyTestAPI, "orders:read") + existing.SubjectType = testPtr(managementv3.ClientGrantSubjectTypeEnumUser) + h.grants.EXPECT().List(gomock.Any(), gomock.Any()).Return(testGrantPage(existing), nil) + + require.NoError(t, h.root("register", poppyTestAgentURL, "--scopes", "orders:read,checkout", "--out", filepath.Join(t.TempDir(), "none.json"))) + + assert.Contains(t, h.out.String(), "(already granted)\n") + assert.Contains(t, h.out.String(), "! the existing grant for "+poppyTestAPI+" was left as it is and lacks checkout\n") + assert.Contains(t, h.out.String(), "FIX auth0 api patch client-grants/cgr_old --data '{\"scope\":[\"orders:read\",\"checkout\"]}'\n") + }) +} + +func TestPoppyAgentsRegisterPlanText(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.allowRegistration() + h.clients.EXPECT().Update(gomock.Any(), poppyTestClientID, gomock.Any()).Return(&managementv3.UpdateClientResponseContent{}, nil) + + run := h.runPromptMode(t, modeTerminal, []string{"register", poppyTestAgentURL, "--scopes", "orders:read", "--require-dpop", "--out", filepath.Join(t.TempDir(), "none.json")}) + + require.NoError(t, run.err) + assert.Equal(t, []string{"confirm"}, run.asked) + assert.Contains(t, run.out, "\nThis will\n") + assert.Contains(t, run.out, " + Agent app create, for "+poppyTestAgentURL+"\n") + assert.Contains(t, run.out, " + DPoP required on the agent app\n") + assert.Contains(t, run.out, ` + Agent record "Meta Muse"`+"\n") + assert.Contains(t, run.out, " + Grant "+poppyTestAPI+": orders:read (skipped if the agent app already has a grant)\n") + assert.Empty(t, run.errOut) +} + +func TestPoppyAgentsList(t *testing.T) { + setup := func(h *poppyAgentsHarness) { + h.agents.EXPECT().List(gomock.Any(), gomock.Any()).Return(testAgentPage(&managementv3.AgentResponseContent{AgentID: poppyTestAgentID, Name: "Meta Muse", ExternalAgentID: testPtr(poppyTestAgentURL)}), nil) + h.servers.EXPECT().List(gomock.Any(), gomock.Any()).Return(testServerPage(testShopServer()), nil) + h.clients.EXPECT().List(gomock.Any(), gomock.Any()).Return(testClientPage(testAgentApp()), nil) + h.grants.EXPECT().List(gomock.Any(), gomock.Any()).Return(testGrantPage(testGrant("cgr_shop", poppyTestAPI, "orders:read")), nil) + } + + t.Run("json", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + setup(h) + h.renderer.Format = display.OutputFormatJSON + + require.NoError(t, h.root("list", "--json")) + + var got []map[string]any + require.NoError(t, json.Unmarshal(h.out.Bytes(), &got)) + require.Len(t, got, 1) + assert.Equal(t, poppyTestAgentURL, got[0]["url"]) + assert.Equal(t, "available", got[0]["metadata_status"]) + assert.Equal(t, false, got[0]["dpop_required"]) + }) + + t.Run("table", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + setup(h) + + require.NoError(t, h.root("list")) + + assert.Contains(t, h.out.String(), "Meta Muse") + assert.Contains(t, h.out.String(), poppyTestAgentURL) + }) +} + +func TestPoppyAgentsRemove(t *testing.T) { + t.Run("not a Poppy agent", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.expectRemovalLookup(testGrant("cgr_other", "https://other.example/api", "refund")) + + err := h.root("remove", poppyTestAgentURL, "--force") + + reason, _ := decodeCLIError(t, err) + assert.Equal(t, "not_poppy_agent", reason) + }) + + t.Run("confirmation is required without a terminal", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.expectRemovalLookup(testGrant("cgr_shop", poppyTestAPI, "orders:read")) + + err := h.root("remove", poppyTestAgentURL) + + reason, details := decodeCLIError(t, err) + assert.Equal(t, "confirmation_required", reason) + assert.Equal(t, true, details["dry_run"]) + assert.Equal(t, poppyTestAgentURL, details["url"]) + assert.Contains(t, h.out.String(), "This will remove:") + assert.Contains(t, h.out.String(), "client grant cgr_shop ("+poppyTestAPI+")") + assert.Empty(t, h.messages.String()) + }) + + t.Run("a dry run changes nothing", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.expectRemovalLookup(testGrant("cgr_shop", poppyTestAPI, "orders:read"), testGrant("cgr_other", "https://other.example/api", "refund")) + + h.json = true + h.renderer.Format = display.OutputFormatJSON + + err := h.root("remove", poppyTestAgentURL, "--dry-run", "--remove-all-grants") + + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(h.out.Bytes(), &got)) + assert.Equal(t, true, got["dry_run"]) + assert.Len(t, got["removed"], 4) + }) + + t.Run("the output flags print the result object", func(t *testing.T) { + for _, flag := range []string{"--json", "--json-compact"} { + h := newPoppyAgentsHarness(t) + h.expectRemovalLookup(testGrant("cgr_shop", poppyTestAPI, "orders:read"), testGrant("cgr_other", "https://other.example/api", "refund")) + + err := h.root("remove", poppyTestAgentURL, "--dry-run", flag) + + require.NoError(t, err, flag) + + var got map[string]any + require.NoError(t, json.Unmarshal(h.out.Bytes(), &got), flag) + assert.Equal(t, true, got["dry_run"]) + assert.Contains(t, got, "removed") + assert.Contains(t, got, "kept") + assert.Contains(t, got, "remaining_grants") + } + }) + + t.Run("the output flags exclude each other", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + + err := h.root("remove", poppyTestAgentURL, "--json", "--json-compact") + + require.Error(t, err) + assert.Contains(t, err.Error(), "none of the others can be") + }) + + t.Run("forced removal of the Poppy access only", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.expectRemovalLookup(testGrant("cgr_shop", poppyTestAPI, "orders:read"), testGrant("cgr_other", "https://other.example/api", "refund")) + h.grants.EXPECT().Delete(gomock.Any(), "cgr_shop").Return(nil) + + h.json = true + h.renderer.Format = display.OutputFormatJSON + + err := h.root("remove", poppyTestAgentURL, "--force") + + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(h.out.Bytes(), &got)) + assert.Equal(t, false, got["dry_run"]) + assert.Equal(t, []any{"client grant cgr_shop (" + poppyTestAPI + ")"}, got["removed"]) + assert.Len(t, got["remaining_grants"], 1) + }) + + t.Run("forced removal of everything", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.expectRemovalLookup(testGrant("cgr_shop", poppyTestAPI, "orders:read")) + h.grants.EXPECT().Delete(gomock.Any(), "cgr_shop").Return(nil) + h.agents.EXPECT().Delete(gomock.Any(), poppyTestAgentID).Return(nil) + h.clients.EXPECT().Delete(gomock.Any(), poppyTestClientID).Return(nil) + + err := h.root("remove", poppyTestAgentURL, "--force") + + require.NoError(t, err) + assert.Equal(t, "Removed:\n"+ + " ok client grant cgr_shop ("+poppyTestAPI+")\n"+ + " ok agent record "+poppyTestAgentID+"\n"+ + " ok agent app "+poppyTestClientID+"\n"+ + "\n"+ + " ! This command does not revoke tokens already issued: access tokens stay valid until they expire.\n"+ + " ! Registering the agent again creates a new app, so its users must sign in again.\n", + h.out.String()) + assert.Empty(t, h.messages.String()) + }) + + t.Run("a dry run says what would be removed and kept", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.expectRemovalLookup(testGrant("cgr_shop", poppyTestAPI, "orders:read"), testGrant("cgr_other", "https://other.example/api", "refund")) + + require.NoError(t, h.root("remove", poppyTestAgentURL, "--dry-run")) + + assert.Equal(t, "This would remove:\n"+ + " client grant cgr_shop ("+poppyTestAPI+")\n"+ + "\n"+ + "This would keep:\n"+ + " agent record "+poppyTestAgentID+"\n"+ + " agent app "+poppyTestClientID+"\n"+ + "\n"+ + "Other grants of the agent app stay. Remove them with:\n"+ + " auth0 client-grants delete cgr_other (https://other.example/api)\n", + h.out.String()) + assert.Empty(t, h.messages.String()) + }) + + t.Run("a removal that keeps the app says what was kept", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.expectRemovalLookup(testGrant("cgr_shop", poppyTestAPI, "orders:read"), testGrant("cgr_other", "https://other.example/api", "refund")) + h.grants.EXPECT().Delete(gomock.Any(), "cgr_shop").Return(nil) + + require.NoError(t, h.root("remove", poppyTestAgentURL, "--force")) + + out := h.out.String() + assert.Contains(t, out, "Removed:\n ok client grant cgr_shop") + assert.Contains(t, out, "Kept:\n - agent record "+poppyTestAgentID+"\n - agent app "+poppyTestClientID+"\n") + assert.Contains(t, out, "auth0 client-grants delete cgr_other (https://other.example/api)") + assert.NotContains(t, out, "Registering the agent again", "the app stays, so nothing is created again") + assert.Empty(t, h.messages.String()) + }) + + t.Run("an app that is gone is said so", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.clients.EXPECT().List(gomock.Any(), gomock.Any()).Return(testClientPage(), nil) + + require.NoError(t, h.root("remove", poppyTestAgentURL)) + + assert.Contains(t, h.out.String(), "! No agent app was found for "+poppyTestAgentURL+". It may already be removed.") + assert.Empty(t, h.messages.String()) + }) + + t.Run("the output flags leave nothing on either stream but the JSON", func(t *testing.T) { + for _, flag := range []string{"--json", "--json-compact"} { + gone := newPoppyAgentsHarness(t) + gone.clients.EXPECT().List(gomock.Any(), gomock.Any()).Return(testClientPage(), nil) + require.NoError(t, gone.root("remove", poppyTestAgentURL, flag), flag) + assert.Empty(t, gone.messages.String(), flag) + assert.True(t, json.Valid(gone.out.Bytes()), "%s: %s", flag, gone.out.String()) + + refused := newPoppyAgentsHarness(t) + refused.expectRemovalLookup(testGrant("cgr_shop", poppyTestAPI, "orders:read")) + err := refused.root("remove", poppyTestAgentURL, flag) + assert.Equal(t, "confirmation_required", errorReason(err), flag) + assert.Empty(t, refused.messages.String(), flag) + assert.Empty(t, refused.out.String(), flag) + + forced := newPoppyAgentsHarness(t) + forced.expectRemovalLookup(testGrant("cgr_shop", poppyTestAPI, "orders:read")) + forced.grants.EXPECT().Delete(gomock.Any(), "cgr_shop").Return(nil) + forced.agents.EXPECT().Delete(gomock.Any(), poppyTestAgentID).Return(nil) + forced.clients.EXPECT().Delete(gomock.Any(), poppyTestClientID).Return(nil) + require.NoError(t, forced.root("remove", poppyTestAgentURL, "--force", flag), flag) + assert.Empty(t, forced.messages.String(), flag) + assert.True(t, json.Valid(forced.out.Bytes()), "%s: %s", flag, forced.out.String()) + } + }) + + t.Run("a partial failure keeps its details", func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.expectRemovalLookup(testGrant("cgr_shop", poppyTestAPI, "orders:read")) + h.grants.EXPECT().Delete(gomock.Any(), "cgr_shop").Return(nil) + h.agents.EXPECT().Delete(gomock.Any(), poppyTestAgentID).Return(errors.New("boom")) + + err := h.root("remove", poppyTestAgentURL, "--force") + + var partial *agents.PartialError + require.ErrorAs(t, err, &partial) + assert.Equal(t, []string{"delete client grant cgr_shop (" + poppyTestAPI + ")"}, partial.Applied) + assert.Contains(t, err.Error(), "stopped partway") + }) +} + +func TestPoppyError(t *testing.T) { + forbidden := func(body string) error { return core.NewAPIError(http.StatusForbidden, nil, errors.New(body)) } + + t.Run("insufficient scope", func(t *testing.T) { + c := &cli{} + + err := c.poppyError(forbidden(`{"errorCode":"insufficient_scope","message":"Insufficient scope, expected any of: read:agents"}`)) + + assert.Equal(t, "insufficient_scope", errorReason(err)) + assert.Contains(t, err.Error(), "auth0 login --scopes read:agents") + }) + + t.Run("another 403", func(t *testing.T) { + c := &cli{} + + err := c.poppyError(forbidden(`{"errorCode":"poppy_beta_off"}`)) + + assert.EqualError(t, err, "the tenant may lack Agent as Principal or the Poppy beta; contact support") + }) + + t.Run("another 403 with debug", func(t *testing.T) { + c := &cli{debug: true} + + err := c.poppyError(forbidden(`{"errorCode":"poppy_beta_off"}`)) + + assert.EqualError(t, err, "the tenant may lack Agent as Principal or the Poppy beta; contact support (status 403)") + assert.ErrorAs(t, err, new(*core.APIError)) + }) + + t.Run("a partial failure is rewritten inside", func(t *testing.T) { + c := &cli{} + partial := &agents.PartialError{Applied: []string{"one"}, Remaining: []string{"two"}, Err: forbidden(`{"errorCode":"poppy_beta_off"}`)} + + err := c.poppyError(partial) + + assert.Same(t, partial, err) + assert.ErrorContains(t, err, "the tenant may lack Agent as Principal") + }) + + t.Run("other errors pass through", func(t *testing.T) { + c := &cli{} + boom := errors.New("boom") + + assert.Same(t, boom, c.poppyError(boom)) + + notFound := core.NewAPIError(http.StatusNotFound, nil, errors.New("missing")) + assert.Same(t, notFound, c.poppyError(notFound)) + }) +} + +// runPromptMode runs an agents command of the harness in a mode and returns what it did. +func (h *poppyAgentsHarness) runPromptMode(t *testing.T, mode poppyPromptMode, args []string) poppyPromptRun { + t.Helper() + + asked := installPoppyAsker(t, mode) + h.agentMode = mode.agentMode + + err := h.root(append(args, mode.flags...)...) + + return poppyPromptRun{asked: *asked, out: h.out.String(), errOut: h.messages.String(), err: err} +} + +// allowRegistration accepts every call that registering the test agent makes. +func (h *poppyAgentsHarness) allowRegistration() { + h.clients.EXPECT().PreviewCimdMetadata(gomock.Any(), gomock.Any()).Return(validPreview(), nil).AnyTimes() + h.servers.EXPECT().List(gomock.Any(), gomock.Any()).Return(testServerPage(testShopServer()), nil).AnyTimes() + h.clients.EXPECT().RegisterCimdClient(gomock.Any(), gomock.Any()).Return(&managementv3.RegisterCimdClientResponseContent{ClientID: poppyTestClientID}, nil).AnyTimes() + h.agents.EXPECT().Create(gomock.Any(), gomock.Any()).Return(&managementv3.AgentResponseContent{AgentID: poppyTestAgentID, Name: "Meta Muse"}, nil).AnyTimes() + h.grants.EXPECT().List(gomock.Any(), gomock.Any()).Return(testGrantPage(), nil).AnyTimes() + h.grants.EXPECT().Create(gomock.Any(), gomock.Any()).Return(&managementv3.CreateClientGrantResponseContent{ID: testPtr("cgr_new")}, nil).AnyTimes() +} + +func TestPoppyAgentsRegisterPromptRule(t *testing.T) { + asks := []string{"confirm"} + + tests := []poppyPromptRow{ + {name: "terminal", mode: modeTerminal, asked: asks, text: "Agent app"}, + {name: "terminal with --json", mode: modeTerminalJSON, asked: asks, text: "Agent metadata", json: true}, + {name: "terminal with --no-input", mode: modeTerminalNoInput, text: "Agent app"}, + {name: "piped", mode: modePiped, text: "Agent app"}, + {name: "piped with --json", mode: modePipedJSON, json: true}, + {name: "agent mode", mode: modeAgent, json: true}, + {name: "agent mode with --no-input=false", mode: modeAgentPrompting, asked: asks, text: "Agent metadata", json: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.allowRegistration() + + run := h.runPromptMode(t, tc.mode, []string{"register", poppyTestAgentURL, "--scopes", "orders:read", "--out", filepath.Join(t.TempDir(), "none.json")}) + + assertPoppyPromptRun(t, tc, run) + }) + } +} + +func TestPoppyAgentsRegisterPromptRuleScopes(t *testing.T) { + const missing = "pass --scopes or --all-scopes" + + // The question a terminal gets here is a survey prompt that no test can answer, + // so the modes that ask are left out. + tests := []poppyPromptRow{ + {name: "terminal with --no-input", mode: modeTerminalNoInput, text: "Agent metadata", reason: "scopes_required", errText: missing}, + {name: "piped", mode: modePiped, text: "Agent metadata", reason: "scopes_required", errText: missing}, + {name: "piped with --json", mode: modePipedJSON, reason: "scopes_required", errText: missing}, + {name: "agent mode", mode: modeAgent, reason: "scopes_required", errText: missing}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.allowRegistration() + + run := h.runPromptMode(t, tc.mode, []string{"register", poppyTestAgentURL, "--out", filepath.Join(t.TempDir(), "none.json")}) + + assertPoppyPromptRun(t, tc, run) + }) + } +} + +func TestPoppyAgentsRemovePromptRule(t *testing.T) { + const refused = "--force" + + asks := []string{"confirm"} + + tests := []poppyPromptRow{ + {name: "terminal", mode: modeTerminal, asked: asks, text: "Removed:"}, + {name: "terminal with --json", mode: modeTerminalJSON, asked: asks, text: "This will remove:", json: true}, + {name: "terminal with --no-input", mode: modeTerminalNoInput, text: "This will remove:", reason: "confirmation_required", errText: refused}, + {name: "piped", mode: modePiped, text: "This will remove:", reason: "confirmation_required", errText: refused}, + {name: "piped with --json", mode: modePipedJSON, reason: "confirmation_required", errText: refused}, + {name: "agent mode", mode: modeAgent, reason: "confirmation_required", errText: refused}, + {name: "agent mode with --no-input=false", mode: modeAgentPrompting, asked: asks, text: "This will remove:", json: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := newPoppyAgentsHarness(t) + h.expectRemovalLookup(testGrant("cgr_shop", poppyTestAPI, "orders:read")) + h.grants.EXPECT().Delete(gomock.Any(), "cgr_shop").Return(nil).AnyTimes() + h.agents.EXPECT().Delete(gomock.Any(), poppyTestAgentID).Return(nil).AnyTimes() + h.clients.EXPECT().Delete(gomock.Any(), poppyTestClientID).Return(nil).AnyTimes() + + assertPoppyPromptRun(t, tc, h.runPromptMode(t, tc.mode, []string{"remove", poppyTestAgentURL})) + }) + } +} + +func TestPromptPoppyGrants(t *testing.T) { + apis := []agents.API{{ + Identifier: poppyTestAPI, + Scopes: []agents.Scope{ + {Name: "orders:read", Description: "See your orders"}, + {Name: "checkout", Description: "Place orders"}, + {Name: "profile", Description: "See your profile"}, + }, + }} + + cfg := &config.Config{Scopes: map[string]config.Scope{ + "orders:read": {Hints: &config.Hints{Changes: "none"}}, + "checkout": {Hints: &config.Hints{Changes: "irreversible", PersonalData: true}}, + }} + + tests := []struct { + name string + cfg *config.Config + picked []int + wantDefaults []int + want []agents.Grant + wantReason string + }{ + { + name: "scopes that change nothing are selected to begin with", + cfg: cfg, + picked: []int{0, 2}, + wantDefaults: []int{0}, + want: []agents.Grant{{Audience: poppyTestAPI, Scopes: []string{"orders:read", "profile"}}}, + }, + { + name: "no hints, nothing selected to begin with", + picked: []int{1}, + want: []agents.Grant{{Audience: poppyTestAPI, Scopes: []string{"checkout"}}}, + wantReason: "", + }, + {name: "nothing chosen is an error", cfg: cfg, wantDefaults: []int{0}, wantReason: "scopes_required"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var ( + defaults []int + rows []string + ) + + screen, out := newTestPoppyScreen(t, poppyAsker{ + chooseMany: func(_ *poppyScreen, _ string, options []string, d []int) ([]int, error) { + defaults, rows = d, options + + return test.picked, nil + }, + }, false) + + grants, err := promptPoppyGrants(screen, apis, test.cfg) + + assert.Equal(t, test.wantDefaults, defaults) + assert.Len(t, rows, 3) + assert.Contains(t, out.String(), "Scopes to grant on "+poppyTestAPI) + + if test.wantReason != "" { + assert.Equal(t, test.wantReason, errorReason(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, test.want, grants) + }) + } +} + +func TestAskPoppyRemoval(t *testing.T) { + tests := []struct { + name string + choose bool + confirm bool + pick int + wantProceed bool + wantEverything bool + }{ + {name: "confirmed", confirm: true, wantProceed: true, wantEverything: true}, + {name: "declined", confirm: false}, + {name: "other grants, only the Poppy access", choose: true, pick: 0, wantProceed: true}, + {name: "other grants, everything", choose: true, pick: 1, wantProceed: true, wantEverything: true}, + {name: "other grants, cancelled", choose: true, pick: 2}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var def int + + screen, _ := newTestPoppyScreen(t, poppyAsker{ + confirm: func(*poppyScreen, string, bool) (bool, error) { return test.confirm, nil }, + choose: func(_ *poppyScreen, _ string, options []string, d int) (int, error) { + def = d + assert.Equal(t, []string{"Remove only the Poppy access", "Remove everything", "Cancel"}, options) + + return test.pick, nil + }, + }, false) + + proceed, everything, err := askPoppyRemoval(screen, test.choose) + + require.NoError(t, err) + assert.Equal(t, test.wantProceed, proceed) + + if proceed { + assert.Equal(t, test.wantEverything, everything) + } + + assert.Zero(t, def, "the list starts on the safest choice") + }) + } +} + +func TestPoppyRef(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("no-input", false, "") + + t.Run("an argument that is given is used", func(t *testing.T) { + installPoppyAsker(t, modePiped) + + ref, err := poppyRef(cmd, nil, poppyAgentURL, []string{"https://agent.example/agent.json"}) + + require.NoError(t, err) + assert.Equal(t, "https://agent.example/agent.json", ref) + }) + + t.Run("without a terminal it is required", func(t *testing.T) { + installPoppyAsker(t, modePiped) + + _, err := poppyRef(cmd, nil, poppyAgentURL, nil) + + assert.Equal(t, "missing_required_args", errorReason(err)) + assert.ErrorContains(t, err, "Agent URL") + }) + + t.Run("a terminal asks for it", func(t *testing.T) { + installPoppyAsker(t, modeTerminal) + + screen, out := newTestPoppyScreen(t, poppyAsker{ + text: func(_ *poppyScreen, label, _ string) (string, error) { + assert.Equal(t, "URL", label) + + return "https://agent.example/agent.json", nil + }, + }, false) + + ref, err := poppyRef(cmd, screen, poppyAgentURL, nil) + + require.NoError(t, err) + assert.Equal(t, "https://agent.example/agent.json", ref) + assert.Contains(t, out.String(), "Agent URL\nURL of the agent's metadata document.\n") + }) +} diff --git a/internal/cli/poppy_test.go b/internal/cli/poppy_test.go new file mode 100644 index 000000000..191fe384a --- /dev/null +++ b/internal/cli/poppy_test.go @@ -0,0 +1,2979 @@ +package cli + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "testing" + "time" + + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" + "go.uber.org/mock/gomock" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/auth" + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/auth0/mock" + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/poppy/config" + "github.com/auth0/auth0-cli/internal/poppy/doctor" + "github.com/auth0/auth0-cli/internal/poppy/setup" +) + +// newPoppyTestRoot builds the real command tree without the authentication +// gate, so a command can be executed without a login. +func newPoppyTestRoot() *cobra.Command { + testCLI := &cli{renderer: display.NewRenderer()} + root := buildRootCmd(testCLI) + root.PersistentPreRunE = nil + root.SetOut(io.Discard) + root.SetErr(io.Discard) + addSubCommands(root, testCLI) + + return root +} + +func TestPoppyCommands(t *testing.T) { + tests := []struct { + name string + path []string + aliases []string + flags []string + }{ + { + name: "poppy", + path: []string{"poppy"}, + }, + { + name: "poppy setup", + path: []string{"poppy", "setup"}, + flags: []string{ + "company-domain", "api", "api-url", "app", "scope", "hint", "name", "login-uri", "out", + "dry-run", "yes", "skip-doctor", "guest-api-access", "guest-scopes", "guest-all-scopes", "force-config", "json", "json-compact", + }, + }, + { + name: "poppy config", + path: []string{"poppy", "config"}, + flags: []string{ + "company-domain", "api", "api-url", "app", "hint", "name", "out", + "force-config", "json", "json-compact", + }, + }, + { + name: "poppy doctor", + path: []string{"poppy", "doctor"}, + flags: []string{"config", "json", "json-compact"}, + }, + { + name: "poppy agents", + path: []string{"poppy", "agents"}, + }, + { + name: "poppy agents register", + path: []string{"poppy", "agents", "register"}, + flags: []string{ + "api", "name", "out", "yes", "scopes", "all-scopes", "require-dpop", "json", "json-compact", + }, + }, + { + name: "poppy agents list", + path: []string{"poppy", "agents", "list"}, + flags: []string{"all", "json", "json-compact"}, + }, + { + name: "poppy agents show", + path: []string{"poppy", "agents", "show"}, + flags: []string{"json", "json-compact"}, + }, + { + name: "poppy agents remove", + path: []string{"poppy", "agents", "remove"}, + aliases: []string{"unregister", "rm"}, + flags: []string{"force", "dry-run", "remove-all-grants", "json", "json-compact"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := newPoppyTestRoot() + + cmd, _, err := root.Find(test.path) + require.NoError(t, err) + require.Equal(t, test.path[len(test.path)-1], cmd.Name()) + assert.ElementsMatch(t, test.aliases, cmd.Aliases) + assert.Contains(t, cmd.Long, "(Experimental)") + assert.Contains(t, cmd.Long, poppyExperimentalWarning) + + var flags []string + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { flags = append(flags, f.Name) }) + assert.ElementsMatch(t, test.flags, flags) + + for _, alias := range test.aliases { + byAlias, _, err := root.Find(append(append([]string{}, test.path[:len(test.path)-1]...), alias)) + require.NoError(t, err) + assert.Same(t, cmd, byAlias) + } + }) + } + + t.Run("auth0 test poppy is not registered", func(t *testing.T) { + testCmd, _, err := newPoppyTestRoot().Find([]string{"test"}) + require.NoError(t, err) + + for _, sub := range testCmd.Commands() { + assert.NotEqual(t, "poppy", sub.Name()) + } + }) + + t.Run("agents register rejects --scopes with --all-scopes", func(t *testing.T) { + root := newPoppyTestRoot() + root.SetArgs([]string{"poppy", "agents", "register", "--scopes", "orders:read", "--all-scopes"}) + + err := root.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "scopes") + assert.Contains(t, err.Error(), "all-scopes") + }) +} + +func TestPoppyDoctorSkipsAuthGate(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + tests := []struct { + path []string + wantErr bool + }{ + {path: []string{"poppy", "doctor"}}, + {path: []string{"poppy", "setup"}, wantErr: true}, + {path: []string{"poppy", "agents", "list"}, wantErr: true}, + } + + for _, test := range tests { + t.Run(strings.Join(test.path, " "), func(t *testing.T) { + testCLI := &cli{renderer: display.NewRenderer()} + root := buildRootCmd(testCLI) + addSubCommands(root, testCLI) + cmd, _, err := root.Find(test.path) + require.NoError(t, err) + + err = root.PersistentPreRunE(cmd, nil) + + if test.wantErr { + require.Error(t, err, "only doctor runs without a login") + return + } + require.NoError(t, err) + }) + } +} + +func TestPoppyDoctorDomain(t *testing.T) { + dir := t.TempDir() + writeConfig := func(t *testing.T, name, domain string) string { + t.Helper() + data, err := json.Marshal(config.Config{Organization: config.Organization{Domain: domain}}) + require.NoError(t, err) + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, data, 0o600)) + return path + } + broken := filepath.Join(dir, "broken.json") + require.NoError(t, os.WriteFile(broken, []byte("{"), 0o600)) + + tests := []struct { + name string + args []string + configFile string + want string + wantErr string + }{ + {name: "argument", args: []string{"Shop.Example"}, configFile: "missing.json", want: "shop.example"}, + {name: "argument as URL", args: []string{"https://shop.example/"}, want: "shop.example"}, + {name: "argument wins over the config file", args: []string{"a.example"}, configFile: writeConfig(t, "a.json", "b.example"), want: "a.example"}, + {name: "argument with a path", args: []string{"shop.example/orders"}, wantErr: "path"}, + {name: "argument with a port", args: []string{"shop.example:443"}, wantErr: "port"}, + {name: "config file", configFile: writeConfig(t, "c.json", "shop.example"), want: "shop.example"}, + {name: "no argument and no config file", configFile: filepath.Join(dir, "missing.json"), wantErr: "pass a domain"}, + {name: "config file without a domain", configFile: writeConfig(t, "d.json", ""), wantErr: "organization.domain"}, + {name: "unreadable config file", configFile: broken, wantErr: "failed to parse"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := poppyDoctorDomain(test.args, test.configFile) + + if test.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } +} + +// newDoctorTestOptions points every host name at a TLS server that answers 404. +func newDoctorTestOptions(t *testing.T) doctor.Options { + t.Helper() + + srv := httptest.NewTLSServer(http.NotFoundHandler()) + t.Cleanup(srv.Close) + + transport := srv.Client().Transport.(*http.Transport).Clone() + transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, srv.Listener.Addr().String()) + } + + return doctor.Options{HTTPClient: &http.Client{Transport: transport}} +} + +func TestRunPoppyDoctor(t *testing.T) { + tests := []struct { + name string + format display.OutputFormat + check func(t *testing.T, out string) + }{ + { + name: "text", + check: func(t *testing.T, out string) { + assert.Contains(t, out, "FAIL https://127.0.0.1/.well-known/poppy.json returned HTTP 404") + assert.Contains(t, out, "- skipped: poppy.json is not available") + assert.Contains(t, out, "failed") + }, + }, + { + name: "json", + format: display.OutputFormatJSON, + check: func(t *testing.T, out string) { + var report doctor.Report + require.NoError(t, json.Unmarshal([]byte(out), &report)) + assert.Equal(t, "127.0.0.1", report.Domain) + assert.Equal(t, 1, report.ExitCode()) + }, + }, + { + name: "json compact", + format: display.OutputFormatJSONCompact, + check: func(t *testing.T, out string) { + assert.Equal(t, 1, strings.Count(out, "\n"), "one line") + var report doctor.Report + require.NoError(t, json.Unmarshal([]byte(out), &report)) + assert.Equal(t, "127.0.0.1", report.Domain) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var out bytes.Buffer + testCLI := &cli{renderer: &display.Renderer{ResultWriter: &out, MessageWriter: io.Discard, Format: test.format}} + + err := runPoppyDoctor(t.Context(), testCLI, "127.0.0.1", newDoctorTestOptions(t)) + + require.ErrorIs(t, err, errPoppyDoctorFailed) + test.check(t, out.String()) + }) + } +} + +// poppySessionToken is a well-formed JWT: only its shape is checked. +const poppySessionToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgwLmF1dGgwLmNvbS8iLCJpYXQiOjE2ODExNDcwNjAsImV4cCI6OTY4MTgzMzQ2MH0.DsEpQkL0MIWcGJOIfEY8vr3MVS_E0GYsachNLQwBu5Q" + +// saveSession writes a saved login to the config file under a new home directory. +func saveSession(t *testing.T, defaultTenant string, tenants map[string]any) { + t.Helper() + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv(authModeEnvVar, "") + keyring.MockInit() + + saved, err := json.Marshal(map[string]any{"default_tenant": defaultTenant, "tenants": tenants}) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "auth0"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "auth0", "config.json"), saved, 0o600)) +} + +func TestOptionalPoppyTenant(t *testing.T) { + const domain = "tenant.us.auth0.com" + + valid := func(extra map[string]any) map[string]any { + tenant := map[string]any{ + "domain": domain, + "access_token": poppySessionToken, + "scopes": auth.RequiredScopes, + "expires_at": time.Now().Add(time.Hour).Format(time.RFC3339), + } + maps.Copy(tenant, extra) + + return map[string]any{domain: tenant} + } + + tests := []struct { + name string + setup func(t *testing.T) + tenant string + wantTenant bool + wantErr string + wantTenantSet string + }{ + { + name: "no saved login", + setup: func(t *testing.T) { t.Setenv("HOME", t.TempDir()); t.Setenv(authModeEnvVar, "") }, + }, + { + name: "a usable saved login", + setup: func(t *testing.T) { saveSession(t, domain, valid(nil)) }, + + wantTenant: true, + wantTenantSet: domain, + }, + { + name: "the tenant flag picks another saved login", + setup: func(t *testing.T) { saveSession(t, "other.us.auth0.com", valid(nil)) }, + tenant: domain, + + wantTenant: true, + wantTenantSet: domain, + }, + { + name: "no default tenant", + setup: func(t *testing.T) { saveSession(t, "", valid(nil)) }, + }, + { + name: "the tenant flag names a tenant that is not saved", + setup: func(t *testing.T) { saveSession(t, domain, valid(nil)) }, + tenant: "missing.us.auth0.com", + }, + { + name: "an expired device login, which a command would answer with a device-code login", + setup: func(t *testing.T) { + saveSession(t, domain, valid(map[string]any{"expires_at": time.Now().Add(-time.Hour).Format(time.RFC3339)})) + }, + }, + { + name: "an expired client-credentials login, which a command would refresh", + setup: func(t *testing.T) { + saveSession(t, domain, valid(map[string]any{"client_id": "client", "expires_at": time.Now().Add(-time.Hour).Format(time.RFC3339)})) + }, + }, + { + name: "a device login without the required scopes, which a command would answer with a login", + setup: func(t *testing.T) { saveSession(t, domain, valid(map[string]any{"scopes": []string{"openid"}})) }, + }, + { + name: "a login with a corrupted token", + setup: func(t *testing.T) { saveSession(t, domain, valid(map[string]any{"access_token": "not-a-token"})) }, + }, + { + name: "a login whose token cannot be read", + setup: func(t *testing.T) { + saveSession(t, domain, valid(map[string]any{"access_token": ""})) + }, + }, + { + name: "environment credentials", + setup: func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envDomain, domain) + t.Setenv(envAPIToken, "token") + }, + wantTenant: true, + wantTenantSet: domain, + }, + { + name: "environment credentials that are incomplete", + setup: func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envDomain, "") + }, + wantErr: "requires", + }, + { + name: "an unknown auth mode", + setup: func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv(authModeEnvVar, "evn") + }, + wantErr: "unrecognized", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.setup(t) + + var messages bytes.Buffer + testCLI := &cli{renderer: &display.Renderer{MessageWriter: &messages, ResultWriter: io.Discard}, tenant: test.tenant} + + got, err := testCLI.optionalPoppyTenant(t.Context()) + + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + assert.Nil(t, got) + return + } + + require.NoError(t, err) + assert.Empty(t, messages.String(), "nothing is printed, asked or warned about") + + if !test.wantTenant { + assert.Nil(t, got) + assert.Nil(t, testCLI.apiv3, "no client is built") + return + } + + require.NotNil(t, got) + assert.NotNil(t, got.State) + assert.NotNil(t, got.Agents) + assert.NotNil(t, got.Policies) + assert.Equal(t, test.wantTenantSet, testCLI.tenant) + }) + } +} + +func TestOptionalPoppyTenantDoesNotChangeTheConfig(t *testing.T) { + // A saved login without a default tenant is repaired by a command that needs + // a login, which writes the config. The doctor leaves the file alone. + saveSession(t, "", map[string]any{"a.us.auth0.com": map[string]any{"domain": "a.us.auth0.com", "access_token": poppySessionToken}}) + + path := filepath.Join(os.Getenv("HOME"), ".config", "auth0", "config.json") + before, err := os.ReadFile(path) + require.NoError(t, err) + + got, err := (&cli{renderer: display.NewRenderer()}).optionalPoppyTenant(t.Context()) + + require.NoError(t, err) + assert.Nil(t, got) + + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(before), string(after)) +} + +func TestPoppyDoctorCommandWithoutLogin(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv(authModeEnvVar, "") + + var out bytes.Buffer + testCLI := &cli{ + renderer: &display.Renderer{ResultWriter: &out, MessageWriter: io.Discard}, + poppyDoctor: newDoctorTestOptions(t), + } + + root := buildRootCmd(testCLI) + root.PersistentPreRunE = nil + root.SilenceErrors, root.SilenceUsage = true, true + addSubCommands(root, testCLI) + root.SetArgs([]string{"poppy", "doctor", "shop.example", "--config", filepath.Join(t.TempDir(), "missing.json")}) + + err := root.ExecuteContext(t.Context()) + + require.ErrorIs(t, err, errPoppyDoctorFailed, "the website cannot be reached") + text := out.String() + assert.Contains(t, text, "FAIL https://shop.example/.well-known/poppy.json could not be fetched") + assert.Equal(t, 1, strings.Count(text, "skipped (6 checks): no usable tenant login; run auth0 login to check the tenant"), "the same reason is said once") + assert.Nil(t, testCLI.apiv3, "no client is built") +} + +// poppyPromptMode is one way to run a Poppy command: where it runs and the +// flags it gets. +type poppyPromptMode struct { + terminal bool + agentMode bool + flags []string +} + +var ( + modeTerminal = poppyPromptMode{terminal: true} + modeTerminalJSON = poppyPromptMode{terminal: true, flags: []string{"--json"}} + modeTerminalNoInput = poppyPromptMode{terminal: true, flags: []string{"--no-input"}} + modePiped = poppyPromptMode{} + modePipedJSON = poppyPromptMode{flags: []string{"--json"}} + modeAgent = poppyPromptMode{terminal: true, agentMode: true} + modeAgentPrompting = poppyPromptMode{terminal: true, agentMode: true, flags: []string{"--no-input=false"}} +) + +// poppyPromptRow is what a command is expected to do in a mode. +type poppyPromptRow struct { + name string + mode poppyPromptMode + // The questions asked, in order. + asked []string + // Text on stdout before any JSON document, empty when there is none. + text string + // Text on stderr, empty when it is not checked. + message string + // Whether stdout ends with a JSON document. + json bool + // The reason of the error, empty when there is none. + reason string + // Text of the error message. + errText string +} + +// poppyPromptRun is what a command did in a mode. +type poppyPromptRun struct { + asked []string + out string + errOut string + err error +} + +// installPoppyAsker makes the Poppy commands behave as if run in the terminal +// of the mode, or without one. A person answers yes to every question, except +// the one that offers to set the hints of the scopes, which is turned down. The +// questions are kept in the order they were asked. +func installPoppyAsker(t *testing.T, mode poppyPromptMode) *[]string { + t.Helper() + + var asked []string + + previous := poppyAsk + t.Cleanup(func() { poppyAsk = previous }) + + poppyAsk.canPrompt = func(cmd *cobra.Command) bool { + noInput, err := cmd.Root().Flags().GetBool("no-input") + + return err == nil && mode.terminal && !noInput + } + poppyAsk.text = func(_ *poppyScreen, label, _ string) (string, error) { + asked = append(asked, "text "+label) + + return "Reads the orders of the signed-in user.", nil + } + poppyAsk.confirm = func(_ *poppyScreen, message string, _ bool) (bool, error) { + if strings.HasPrefix(message, "Set ") { + asked = append(asked, "hints") + + return false, nil + } + + asked = append(asked, "confirm") + + return true, nil + } + poppyAsk.guest = func(*poppyScreen) poppyGuestPrompts { + return poppyGuestPrompts{ + allow: func() (bool, error) { + asked = append(asked, "guest access") + + return true, nil + }, + all: func() (bool, error) { + asked = append(asked, "guest scopes") + + return true, nil + }, + note: func(string, ...any) {}, + } + } + + return &asked +} + +// newTestPoppyScreen starts a screen that writes to a buffer, as if it were a +// terminal 80 cells wide when erase is set, and asks with the asker given. An +// interrupt ends the command with the error itself instead of the process. +func newTestPoppyScreen(t *testing.T, asker poppyAsker, erase bool) (*poppyScreen, *bytes.Buffer) { + t.Helper() + + out := &bytes.Buffer{} + renderer := &display.Renderer{ResultWriter: out, MessageWriter: io.Discard} + + if asker.interrupt == nil { + asker.interrupt = func(err error) error { return err } + } + + return &poppyScreen{ + PoppyScreen: renderer.NewPoppyScreen("Poppy setup", "mock.auth0.test", display.PoppyLayout{Width: 80, Unicode: true}, erase), + asker: asker, + erase: erase, + }, out +} + +// splitPoppyJSON splits the output at the JSON document it ends with, which +// starts a line. The document is empty when the output does not end with one. +func splitPoppyJSON(out string) (text, document string) { + for start := 0; start < len(out); start++ { + if out[start] == '{' && (start == 0 || out[start-1] == '\n') && json.Valid([]byte(out[start:])) { + return out[:start], out[start:] + } + } + + return out, "" +} + +func assertPoppyPromptRun(t *testing.T, row poppyPromptRow, run poppyPromptRun) { + t.Helper() + + text, document := splitPoppyJSON(run.out) + + assert.Equal(t, row.asked, run.asked, "questions asked") + assert.Equal(t, row.json, document != "", "JSON document on stdout:\n%s", run.out) + + if row.text == "" { + assert.Empty(t, text, "no human text on stdout") + } else { + assert.Contains(t, text, row.text) + } + + assert.Contains(t, run.errOut, row.message) + + if row.reason == "" { + require.NoError(t, run.err) + + return + } + + require.Error(t, run.err) + assert.Equal(t, row.reason, errorReason(run.err)) + assert.Contains(t, run.err.Error(), row.errText) +} + +const ( + poppySetupTenant = "tenant.us.auth0.com" + poppySetupAppID = "W1x9aBcD2eFgH3iJ" +) + +// poppyTenantState is the part of a tenant that the Poppy setup commands read. +type poppyTenantState struct { + settings *managementv3.GetTenantSettingsResponseContent + domains []*management.CustomDomain + server *managementv3.ResourceServer + app *managementv3.GetClientResponseContent + guest bool +} + +// freshPoppyTenant is a tenant that has never been set up for Poppy. +func freshPoppyTenant() poppyTenantState { + return poppyTenantState{ + settings: &managementv3.GetTenantSettingsResponseContent{ + ResourceParameterProfile: testPtr(managementv3.TenantSettingsResourceParameterProfileAudience), + FriendlyName: testPtr("Shop"), + }, + server: &managementv3.ResourceServer{ + ID: testPtr("rs_shop"), + Identifier: testPtr(poppyTestAPI), + Name: testPtr("Shop API"), + Scopes: []*managementv3.ResourceServerScope{ + {Value: "orders:read", Description: testPtr("See your orders and their status. Read only.")}, + {Value: "checkout", Description: testPtr("Place orders and pay with your saved card. Can't be undone.")}, + }, + }, + app: &managementv3.GetClientResponseContent{ + ClientID: testPtr(poppySetupAppID), + Name: testPtr("Shop Web"), + IsFirstParty: testPtr(true), + InitiateLoginURI: testPtr("https://shop.example/login"), + }, + } +} + +// configuredPoppyTenant is a tenant that setup has already been run against. +func configuredPoppyTenant() poppyTenantState { + state := freshPoppyTenant() + + state.settings = &managementv3.GetTenantSettingsResponseContent{ + ClientIDMetadataDocumentSupported: testPtr(true), + AuthorizationResponseIssParameterSupported: testPtr(true), + ResourceParameterProfile: testPtr(managementv3.TenantSettingsResourceParameterProfileCompatibility), + DefaultAudience: testPtr(poppyTestAPI), + FriendlyName: testPtr("Shop"), + PersonalAgentProtocolCompanyDomains: []string{"https://shop.example"}, + } + state.domains = []*management.CustomDomain{{Domain: testPtr("auth.shop.example"), Status: testPtr("ready"), IsDefault: testPtr(true)}} + state.server.Scopes = append(state.server.Scopes, &managementv3.ResourceServerScope{Value: setup.BrowserSignInScope, Description: testPtr(setup.BrowserSignInDescription)}) + state.server.AllowOfflineAccess = testPtr(true) + state.server.UseForPersonalAgentProtocol = testPtr(true) + state.server.AgentSubjectClaims = testPtr(managementv3.ResourceServerAgentSubjectClaimsResponseEnumAuth0V1) + state.server.SubjectTypeAuthorization = &managementv3.ResourceServerSubjectTypeAuthorization{ + AnonymousUser: &managementv3.ResourceServerSubjectTypeAuthorizationAnonymousUser{ + Policy: testPtr(managementv3.ResourceServerSubjectTypeAuthorizationAnonymousUserPolicyEnumRequireClientGrant), + }, + } + state.app.AllowPersonalAgentBrowserJoin = testPtr(true) + state.guest = true + + return state +} + +type poppySetupHarness struct { + out *bytes.Buffer + messages *bytes.Buffer + tenant *mock.MockTenantSettingsAPIV3 + domains *mock.MockCustomDomainAPI + servers *mock.MockResourceServerAPIV3 + clients *mock.MockClientAPIV3 + grants *mock.MockClientGrantAPIV3 + agents *mock.MockAgentAPIV3 + policies *mock.MockGuardianPolicyAPIV3 + run func(args ...string) error + + // Runs the command in agent mode when set. + agentMode bool + + // The Management API writes made, in order, and the error that a write of a + // kind (tenant, api, app or grant) returns. + calls []string + fail map[string]error + // The last guest grant created. + grant *managementv3.CreateClientGrantRequestContent +} + +// allowWrites accepts the Management API writes of setup and records them in +// calls. +func (h *poppySetupHarness) allowWrites() { + record := func(kind string) error { + h.calls = append(h.calls, kind) + + return h.fail[kind] + } + + h.tenant.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(context.Context, *managementv3.UpdateTenantSettingsRequestContent, ...any) (*managementv3.UpdateTenantSettingsResponseContent, error) { + return &managementv3.UpdateTenantSettingsResponseContent{}, record("tenant") + }).AnyTimes() + h.servers.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(context.Context, string, *managementv3.UpdateResourceServerRequestContent, ...any) (*managementv3.UpdateResourceServerResponseContent, error) { + return &managementv3.UpdateResourceServerResponseContent{}, record("api") + }).AnyTimes() + h.clients.EXPECT().Update(gomock.Any(), poppySetupAppID, gomock.Any()).DoAndReturn( + func(context.Context, string, *managementv3.UpdateClientRequestContent, ...any) (*managementv3.UpdateClientResponseContent, error) { + return &managementv3.UpdateClientResponseContent{}, record("app") + }).AnyTimes() + h.grants.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, request *managementv3.CreateClientGrantRequestContent, _ ...any) (*managementv3.CreateClientGrantResponseContent, error) { + h.grant = request + + return &managementv3.CreateClientGrantResponseContent{}, record("grant") + }).AnyTimes() +} + +// newPoppySetupHarness serves the tenant to the commands. The mocks have no +// write calls until allowWrites is called, so any attempt to change the tenant +// fails the test. The doctor checks that run at the end of setup see a website +// that answers 404. +func newPoppySetupHarness(t *testing.T, tenant poppyTenantState) *poppySetupHarness { + t.Helper() + + ctrl := gomock.NewController(t) + h := &poppySetupHarness{ + out: &bytes.Buffer{}, + messages: &bytes.Buffer{}, + tenant: mock.NewMockTenantSettingsAPIV3(ctrl), + domains: mock.NewMockCustomDomainAPI(ctrl), + servers: mock.NewMockResourceServerAPIV3(ctrl), + clients: mock.NewMockClientAPIV3(ctrl), + grants: mock.NewMockClientGrantAPIV3(ctrl), + agents: mock.NewMockAgentAPIV3(ctrl), + policies: mock.NewMockGuardianPolicyAPIV3(ctrl), + fail: map[string]error{}, + } + + // The doctor checks that run at the end of setup read the agents and the MFA policies too. + h.agents.EXPECT().List(gomock.Any(), gomock.Any()).Return(testAgentPage(), nil).AnyTimes() + h.policies.EXPECT().List(gomock.Any()).Return(managementv3.ListGuardianPoliciesResponseContent{}, nil).AnyTimes() + + h.tenant.EXPECT().Get(gomock.Any(), gomock.Any()).Return(tenant.settings, nil).AnyTimes() + h.domains.EXPECT().List(gomock.Any()).Return(tenant.domains, nil).AnyTimes() + h.servers.EXPECT().List(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, request *managementv3.ListResourceServerRequestParameters, _ ...any) (*auth0.ResourceServerPage, error) { + for _, identifier := range request.Identifiers { + if *identifier != tenant.server.GetIdentifier() { + return testServerPage(), nil + } + } + + return testServerPage(tenant.server), nil + }).AnyTimes() + h.clients.EXPECT().Get(gomock.Any(), poppySetupAppID, gomock.Any()).Return(tenant.app, nil).AnyTimes() + h.clients.EXPECT().List(gomock.Any(), gomock.Any()).DoAndReturn( + func(context.Context, *managementv3.ListClientsRequestParameters, ...any) (*auth0.ClientListPage, error) { + return testClientPage( + &managementv3.Client{ + ClientID: testPtr("client_muse"), + ExternalClientID: testPtr(poppyTestAgentURL), + ExternalMetadataType: testPtr(managementv3.ClientExternalMetadataTypeEnumCimd), + }, + &managementv3.Client{ + ClientID: tenant.app.ClientID, + Name: tenant.app.Name, + IsFirstParty: tenant.app.IsFirstParty, + InitiateLoginURI: tenant.app.InitiateLoginURI, + AllowPersonalAgentBrowserJoin: tenant.app.AllowPersonalAgentBrowserJoin, + }, + ), nil + }).AnyTimes() + h.grants.EXPECT().List(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, request *managementv3.ListClientGrantsRequestParameters, _ ...any) (*auth0.ClientGrantPage, error) { + if request.ClientID == nil { + return testGrantPage(&managementv3.ClientGrantResponseContent{ClientID: testPtr("client_muse")}), nil + } + + if !tenant.guest { + return testGrantPage(), nil + } + + return testGrantPage(&managementv3.ClientGrantResponseContent{SubjectType: testPtr(managementv3.ClientGrantSubjectTypeEnumAnonymousUser)}), nil + }).AnyTimes() + + renderer := display.NewRenderer() + renderer.ResultWriter = h.out + renderer.MessageWriter = h.messages + + testCLI := &cli{ + renderer: renderer, + poppyDoctor: newDoctorTestOptions(t), + tenant: poppySetupTenant, + api: &auth0.API{CustomDomain: h.domains}, + apiv3: &auth0.APIV3{ + TenantSettings: h.tenant, + ResourceServerV3: h.servers, + ClientV3: h.clients, + ClientGrant: h.grants, + Agent: h.agents, + GuardianPolicy: h.policies, + }, + } + + h.run = func(args ...string) error { + root := buildRootCmd(testCLI) + root.PersistentFlags().BoolVar(&testCLI.noInput, "no-input", false, "Disable interactivity.") + testCLI.agentMode = h.agentMode + root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + applyAgentModeDefaults(testCLI, cmd) + testCLI.configureRenderer() + + return nil + } + root.SilenceErrors = true + root.SilenceUsage = true + addSubCommands(root, testCLI) + root.SetArgs(append([]string{"poppy"}, args...)) + root.SetContext(context.Background()) + + return root.Execute() + } + + return h +} + +// goldenPoppyConfig copies the config file that setup writes for the configured +// tenant into a new directory and returns its path. +func goldenPoppyConfig(t *testing.T) string { + t.Helper() + + data, err := os.ReadFile(filepath.Join("..", "poppy", "config", "testdata", "auth0-poppy.golden.json")) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "auth0-poppy.json") + require.NoError(t, os.WriteFile(path, data, 0o600)) + + return path +} + +func freshSetupArgs(t *testing.T, extra ...string) []string { + t.Helper() + + return append([]string{ + "setup", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--api-url", poppyTestAPI + "=" + poppyTestAPI + "/openapi.json", + "--out", filepath.Join(t.TempDir(), "auth0-poppy.json"), + }, extra...) +} + +func TestPoppySetupPlan(t *testing.T) { + t.Run("dry run prints the plan and applies nothing", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + + err := h.run(freshSetupArgs(t, "--dry-run", "--guest-api-access", "--guest-all-scopes")...) + + require.NoError(t, err) + text := h.out.String() + assert.Contains(t, text, "Poppy setup for shop.example") + assert.Contains(t, text, "tenant "+poppySetupTenant) + assert.Contains(t, text, "Dry run: nothing will be changed.") + assert.Contains(t, text, "The tenant has no verified custom domain") + assert.Contains(t, text, "affects all apps") + assert.Contains(t, text, "CIMD support") + assert.Contains(t, text, "off -> on") + assert.Contains(t, text, "API "+poppyTestAPI+"\n") + assert.Contains(t, text, "App Shop Web\n") + assert.Contains(t, text, "browser join") + assert.Contains(t, text, "Guest grant\n") + assert.Contains(t, text, "File\n") + assert.Contains(t, text, "To apply: auth0 poppy setup --company-domain shop.example --api "+poppyTestAPI) + assert.NotContains(t, text, "--dry-run", "the command that applies the plan is not a dry run") + }) + + t.Run("the guest grant is left out unless asked for", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + + require.NoError(t, h.run(freshSetupArgs(t, "--dry-run")...)) + + assert.NotContains(t, h.out.String(), "for guests") + }) + + t.Run("dry run as JSON", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + + require.NoError(t, h.run(freshSetupArgs(t, "--dry-run", "--json")...)) + + var plan setup.Plan + require.NoError(t, json.Unmarshal(h.out.Bytes(), &plan)) + assert.Equal(t, poppySetupTenant, plan.Tenant) + require.NotEmpty(t, plan.Changes) + assert.Equal(t, setup.KindTenant, plan.Changes[0].Kind) + assert.False(t, plan.Empty()) + }) + + t.Run("a tenant that is already set up has nothing to change", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + + err := h.run("setup", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--guest-api-access", "--guest-all-scopes", "--out", goldenPoppyConfig(t)) + + require.NoError(t, err, "no write call is set on the mocks") + assert.Contains(t, h.out.String(), "Nothing to change") + }) +} + +func TestPoppySetupInputErrors(t *testing.T) { + tests := []struct { + name string + args []string + tenant poppyTenantState + wantReason string + wantText string + }{ + { + name: "the company domain is required without a terminal", + args: []string{"setup", "--api", poppyTestAPI, "--app", poppySetupAppID}, + tenant: freshPoppyTenant(), + wantReason: "missing_flag", + wantText: "--company-domain is required", + }, + { + name: "the API is required without a terminal", + args: []string{"setup", "--company-domain", "shop.example", "--app", poppySetupAppID}, + tenant: freshPoppyTenant(), + wantReason: "missing_flag", + wantText: "--api is required", + }, + { + name: "the app is required without a terminal", + args: []string{"setup", "--company-domain", "shop.example", "--api", poppyTestAPI}, + tenant: freshPoppyTenant(), + wantReason: "missing_flag", + wantText: "--app is required", + }, + { + name: "the API does not exist", + args: []string{"setup", "--company-domain", "shop.example", "--api", "https://missing.example", "--app", poppySetupAppID}, + tenant: freshPoppyTenant(), + wantReason: "api_not_found", + wantText: "https://missing.example", + }, + { + name: "the company domain must be a host", + args: []string{"setup", "--company-domain", "https://shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID}, + tenant: freshPoppyTenant(), + wantReason: "invalid_flag", + wantText: "--company-domain", + }, + { + name: "the OpenAPI URL is required without a terminal", + args: []string{"setup", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, "--out", filepath.Join(t.TempDir(), "none.json")}, + tenant: freshPoppyTenant(), + wantReason: "missing_flag", + wantText: "--api-url " + poppyTestAPI + "= is required", + }, + { + name: "the app must be first-party", + args: []string{"setup", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID}, + tenant: func() poppyTenantState { + state := freshPoppyTenant() + state.app.IsFirstParty = testPtr(false) + + return state + }(), + wantReason: "app_not_first_party", + wantText: "Shop Web is not first-party", + }, + { + name: "a scope without a description", + args: freshSetupArgs(t), + tenant: func() poppyTenantState { + state := freshPoppyTenant() + state.server.Scopes[1].Description = nil + + return state + }(), + wantReason: "scope_description_required", + wantText: "checkout", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + h := newPoppySetupHarness(t, test.tenant) + + err := h.run(test.args...) + + reason, _ := decodeCLIError(t, err) + assert.Equal(t, test.wantReason, reason) + assert.Contains(t, err.Error(), test.wantText) + }) + } +} + +func TestPoppyConfig(t *testing.T) { + t.Run("writes the file for a tenant that is set up", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + out := filepath.Join(t.TempDir(), "auth0-poppy.json") + + err := h.run("config", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--api-url", poppyTestAPI+"="+poppyTestAPI+"/openapi.json", "--out", out) + + require.NoError(t, err) + assert.Contains(t, h.out.String(), out+" written") + assert.Contains(t, h.out.String(), "Next\n auth0 poppy doctor shop.example to check the live site.") + assert.Empty(t, h.messages.String(), "the result is on stdout") + + cfg, err := config.Read(out) + require.NoError(t, err) + assert.Equal(t, "shop.example", cfg.Organization.Domain) + assert.Equal(t, "https://auth.shop.example/", cfg.Issuer) + assert.Equal(t, "https://auth.shop.example/poppy/"+poppySetupAppID+"/browser-session", cfg.Web.BrowserSessionEndpoint) + assert.Equal(t, "Shop API", cfg.APIs[0].Description) + assert.Equal(t, poppyTestAPI+"/openapi.json", cfg.APIs[0].URL) + }) + + t.Run("a file that is already right is said to be unchanged", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "auth0-poppy.json") + args := []string{"config", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--api-url", poppyTestAPI + "=" + poppyTestAPI + "/openapi.json", "--out", out} + + first := newPoppySetupHarness(t, configuredPoppyTenant()) + require.NoError(t, first.run(args...)) + assert.Contains(t, first.out.String(), out+" written") + + second := newPoppySetupHarness(t, configuredPoppyTenant()) + require.NoError(t, second.run(args...)) + assert.Contains(t, second.out.String(), out+" unchanged") + assert.NotContains(t, second.out.String(), "written") + assert.Contains(t, second.out.String(), "Next\n") + }) + + t.Run("the API is told by the file and the web app by the tenant", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + path := goldenPoppyConfig(t) + before, err := os.ReadFile(path) + require.NoError(t, err) + + require.NoError(t, h.run("config", "--out", path)) + + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.JSONEq(t, string(before), string(after), "rebuilding from a matching tenant changes nothing") + }) + + t.Run("--api alone is enough when the tenant has one app with the browser join", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + out := filepath.Join(t.TempDir(), "auth0-poppy.json") + + require.NoError(t, h.run("config", "--company-domain", "shop.example", "--api", poppyTestAPI, + "--api-url", poppyTestAPI+"="+poppyTestAPI+"/openapi.json", "--out", out)) + + cfg, err := config.Read(out) + require.NoError(t, err) + assert.Equal(t, "https://auth.shop.example/poppy/"+poppySetupAppID+"/browser-session", cfg.Web.BrowserSessionEndpoint) + }) + + t.Run("prints the file as JSON", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + path := goldenPoppyConfig(t) + + require.NoError(t, h.run("config", "--out", path, "--json")) + + var cfg config.Config + require.NoError(t, json.Unmarshal(h.out.Bytes(), &cfg)) + assert.Equal(t, "https://auth.shop.example/", cfg.Issuer) + assert.Empty(t, h.messages.String()) + + var doc map[string]json.RawMessage + require.NoError(t, json.Unmarshal(h.out.Bytes(), &doc)) + assert.Contains(t, doc, "auth", "the JSON has the shape of the file") + assert.NotContains(t, doc, "auth0", "the CLI adds no member of its own") + }) + + t.Run("members of an existing file that the CLI does not use are kept", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + path := goldenPoppyConfig(t) + + data, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, []byte(strings.Replace(string(data), `"apis": [`, `"feedback": {"endpoint": "https://shop.example/feedback"}, + "apis": [`, 1)), 0o600)) + + require.NoError(t, h.run("config", "--out", path)) + + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(after), `"endpoint": "https://shop.example/feedback"`) + }) + + t.Run("a file in the old layout is replaced by the new layout", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + path := filepath.Join(t.TempDir(), "auth0-poppy.json") + require.NoError(t, os.WriteFile(path, []byte(`{"protocol_version":"0.4","issuer":"https://auth.shop.example/","scopes":{},"apis":[],"web":{"client_id":"x"},"agents":[]}`), 0o600)) + + require.NoError(t, h.run("config", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--api-url", poppyTestAPI+"="+poppyTestAPI+"/openapi.json", "--out", path)) + + cfg, err := config.Read(path) + require.NoError(t, err) + assert.Equal(t, "https://auth.shop.example/", cfg.Issuer) + }) + + t.Run("the company domain is picked from several without a terminal", func(t *testing.T) { + tenant := configuredPoppyTenant() + tenant.settings.PersonalAgentProtocolCompanyDomains = []string{"https://shop.example", "https://shop.test"} + h := newPoppySetupHarness(t, tenant) + + err := h.run("config", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--api-url", poppyTestAPI+"="+poppyTestAPI+"/openapi.json", "--out", filepath.Join(t.TempDir(), "none.json")) + + reason, _ := decodeCLIError(t, err) + assert.Equal(t, "missing_flag", reason) + assert.Contains(t, err.Error(), "--company-domain is required: the tenant lists several company domains (shop.example, shop.test)") + }) +} + +func TestPoppyConfigErrors(t *testing.T) { + missingURL := []string{"config", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, "--out"} + + tests := []struct { + name string + tenant poppyTenantState + args func(t *testing.T) []string + wantReason string + wantText string + }{ + { + name: "no API anywhere", + tenant: configuredPoppyTenant(), + args: func(t *testing.T) []string { + return []string{"config", "--app", poppySetupAppID, "--out", filepath.Join(t.TempDir(), "none.json")} + }, + wantReason: "api_not_found", + wantText: "There is no config file, so no API of the tenant is known.\n FIX Pass --api to name the API, or create the file with auth0 poppy setup.", + }, + { + name: "no app anywhere", + tenant: func() poppyTenantState { + state := configuredPoppyTenant() + state.app.AllowPersonalAgentBrowserJoin = nil + return state + }(), + args: func(t *testing.T) []string { + return []string{"config", "--api", poppyTestAPI, "--out", filepath.Join(t.TempDir(), "none.json")} + }, + wantReason: "app_not_found", + wantText: "No app of the tenant has allow_personal_agent_browser_join on.\n FIX Pass --app ", + }, + { + name: "the OpenAPI URL is not stored anywhere", + tenant: configuredPoppyTenant(), + args: func(t *testing.T) []string { + return append(missingURL, filepath.Join(t.TempDir(), "none.json")) + }, + wantReason: "missing_config_values", + wantText: "apis[" + poppyTestAPI + "].url. Pass --api-url as =", + }, + { + name: "the company domain is not listed", + tenant: func() poppyTenantState { + state := configuredPoppyTenant() + state.settings.PersonalAgentProtocolCompanyDomains = nil + return state + }(), + args: func(t *testing.T) []string { + return append(missingURL, goldenPoppyConfig(t)) + }, + wantReason: "poppy_not_ready", + wantText: "tenant setting personal_agent_protocol_company_domains does not list https://shop.example", + }, + { + name: "the API does not use the protocol", + tenant: func() poppyTenantState { + state := configuredPoppyTenant() + state.server.UseForPersonalAgentProtocol = nil + return state + }(), + args: func(t *testing.T) []string { + return append(missingURL, goldenPoppyConfig(t)) + }, + wantReason: "poppy_not_ready", + wantText: "API " + poppyTestAPI + ": use_for_personal_agent_protocol is off", + }, + { + name: "the browser join is off", + tenant: func() poppyTenantState { + state := configuredPoppyTenant() + state.app.AllowPersonalAgentBrowserJoin = nil + return state + }(), + args: func(t *testing.T) []string { + return append(missingURL, goldenPoppyConfig(t)) + }, + wantReason: "poppy_not_ready", + wantText: "app Shop Web: allow_personal_agent_browser_join is off\n FIX Run: auth0 poppy setup --company-domain shop.example", + }, + { + name: "the file belongs to another issuer", + tenant: func() poppyTenantState { state := configuredPoppyTenant(); state.domains = nil; return state }(), + args: func(t *testing.T) []string { + return append(missingURL, goldenPoppyConfig(t)) + }, + wantReason: "issuer_mismatch", + wantText: "Pass --force-config to overwrite it", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + h := newPoppySetupHarness(t, test.tenant) + args := test.args(t) + + before := map[string][]byte{} + if path := args[len(args)-1]; filepath.Ext(path) == ".json" { + if data, err := os.ReadFile(path); err == nil { + before[path] = data + } + } + + err := h.run(args...) + + reason, _ := decodeCLIError(t, err) + assert.Equal(t, test.wantReason, reason) + assert.Contains(t, err.Error(), test.wantText) + assert.Empty(t, h.out.String()) + + for path, data := range before { + after, readErr := os.ReadFile(path) + require.NoError(t, readErr) + assert.Equal(t, data, after, "a refused command leaves the file alone") + } + }) + } + + t.Run("--force-config overwrites a file of another issuer", func(t *testing.T) { + tenant := configuredPoppyTenant() + tenant.domains = nil + h := newPoppySetupHarness(t, tenant) + path := goldenPoppyConfig(t) + + require.NoError(t, h.run(append(missingURL, path, "--force-config")...)) + + cfg, err := config.Read(path) + require.NoError(t, err) + assert.Equal(t, "https://"+poppySetupTenant+"/", cfg.Issuer) + }) +} + +func TestPoppySetupError(t *testing.T) { + plain := errors.New("plain") + + tests := []struct { + name string + err error + wantReason string + wantText string + }{ + { + name: "an input error keeps its reason", + err: &setup.InputError{Message: "bad", Reason: "invalid_flag", Fix: "do this"}, + wantReason: "invalid_flag", + wantText: "bad\n FIX do this", + }, + { + name: "an unready tenant", + err: &setup.NotReadyError{Problems: []string{"a"}, Fix: "b"}, + wantReason: "poppy_not_ready", + wantText: "the tenant is not set up for Poppy:\n - a\n FIX b", + }, + { + name: "missing values", + err: &config.MissingFieldsError{Fields: []string{"apis[x].url"}}, + wantReason: "missing_config_values", + wantText: "apis[x].url. Pass --api-url as =, or run auth0 poppy setup", + }, + { + name: "a wrapped issuer mismatch", + err: errors.Join(config.ErrIssuerMismatch, errors.New("detail")), + wantReason: "issuer_mismatch", + wantText: "Pass --force-config to overwrite it", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := poppySetupError(test.err) + + assert.Equal(t, test.wantReason, errorReason(err)) + assert.Contains(t, err.Error(), test.wantText) + assert.ErrorIs(t, err, test.err) + }) + } + + t.Run("other errors pass through", func(t *testing.T) { + assert.Same(t, plain, poppySetupError(plain)) + assert.NoError(t, poppySetupError(nil)) + }) +} + +// outOf returns the config file path in the arguments. +func outOf(args []string) string { + return args[slices.Index(args, "--out")+1] +} + +func TestPoppySetupApply(t *testing.T) { + t.Run("applies the plan, writes the file last and runs doctor", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + args := freshSetupArgs(t, "--yes", "--guest-api-access", "--guest-all-scopes") + + err := h.run(args...) + + require.NoError(t, err, "a doctor failure does not fail setup") + assert.Equal(t, []string{"tenant", "api", "app", "grant"}, h.calls) + + cfg, err := config.Read(outOf(args)) + require.NoError(t, err) + assert.Equal(t, "shop.example", cfg.Organization.Domain) + + text := h.out.String() + assert.Contains(t, text, "Poppy setup for shop.example") + assert.Contains(t, text, "Applying 13 changes\n") + assert.Contains(t, text, "FAIL https://shop.example/.well-known/poppy.json could not be fetched") + // Setup has a login, so doctor reads the tenant too. The mocks serve the + // tenant as it was before the plan, which fails 4 of its checks, besides + // the 2 checks of the website. No app has the browser join on and no API + // has Poppy on yet, so the web app check is skipped and the file cannot + // be compared with the tenant. + assert.Contains(t, text, "FAIL client_id_metadata_document_supported is off") + assert.NotContains(t, text, "- skipped", "the checks that did not run are not listed after setup") + assert.NotContains(t, text, "skipped (") + assert.Contains(t, text, "WARN cannot compare") + assert.Contains(t, text, "6 failed") + assert.Contains(t, text, "Checking your setup (auth0 poppy doctor)") + assert.Contains(t, text, "written\n") + assert.Contains(t, text, "6 checks failed. Deploy the config file with your app and run `auth0 poppy doctor shop.example` again.") + assert.Contains(t, text, "Run auth0 poppy doctor shop.example to see every check.") + assert.Contains(t, text, "Next\n 1. Deploy ") + assert.Empty(t, h.messages.String(), "the result is on stdout") + }) + + t.Run("the guest grant is made only when asked for", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + require.NoError(t, h.run(freshSetupArgs(t, "--yes", "--skip-doctor")...)) + + assert.Equal(t, []string{"tenant", "api", "app"}, h.calls) + }) + + t.Run("skip doctor", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + require.NoError(t, h.run(freshSetupArgs(t, "--yes", "--skip-doctor")...)) + + assert.NotContains(t, h.out.String(), "Checking your setup") + assert.NotContains(t, h.out.String(), ".well-known") + assert.NotContains(t, h.out.String(), "Deploy the config file") + }) + + t.Run("a tenant that is set up has nothing to change and still runs doctor", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + h.allowWrites() + args := []string{"setup", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, "--guest-api-access", "--guest-all-scopes", "--out", goldenPoppyConfig(t)} + + require.NoError(t, h.run(args...)) + + assert.Empty(t, h.calls) + assert.Contains(t, h.out.String(), "Nothing to change") + assert.NotContains(t, h.out.String(), "Applying", "nothing is applied") + assert.Contains(t, h.out.String(), "Checking your setup") + assert.Contains(t, h.out.String(), ".well-known/poppy.json") + + h = newPoppySetupHarness(t, configuredPoppyTenant()) + require.NoError(t, h.run(append(args, "--skip-doctor")...)) + assert.NotContains(t, h.out.String(), "Checking your setup") + }) + + t.Run("an empty plan asks for nothing", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + args := []string{"setup", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, "--guest-api-access", "--guest-all-scopes", "--out", goldenPoppyConfig(t), "--skip-doctor"} + + require.NoError(t, h.run(args...), "no terminal and no --yes") + }) +} + +func TestPoppySetupConfirmation(t *testing.T) { + t.Run("without a terminal and without --yes nothing changes", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + args := freshSetupArgs(t) + + err := h.run(args...) + + reason, details := decodeCLIError(t, err) + assert.Equal(t, "confirmation_required", reason) + assert.Contains(t, err.Error(), "--yes") + assert.Equal(t, poppySetupTenant, details["tenant"]) + assert.NotEmpty(t, details["changes"], "the plan travels with the error") + assert.Contains(t, h.out.String(), "Plan: ", "the plan is printed") + assert.NoFileExists(t, outOf(args)) + }) + + t.Run("JSON output prints no plan before the error", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + + err := h.run(freshSetupArgs(t, "--json")...) + + reason, details := decodeCLIError(t, err) + assert.Equal(t, "confirmation_required", reason) + assert.NotEmpty(t, details["changes"]) + assert.Empty(t, h.out.String()) + }) + + t.Run("dry run never writes, even with --yes", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + args := freshSetupArgs(t, "--dry-run", "--yes") + + require.NoError(t, h.run(args...), "the mocks have no write calls") + + assert.NoFileExists(t, outOf(args)) + assert.NotContains(t, h.messages.String(), "Doctor") + }) +} + +func TestPoppySetupJSON(t *testing.T) { + t.Run("plan, applied and doctor", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + require.NoError(t, h.run(freshSetupArgs(t, "--yes", "--json")...)) + + var result struct { + Plan setup.Plan `json:"plan"` + Applied []string `json:"applied"` + Doctor *doctor.Report `json:"doctor"` + } + require.NoError(t, json.Unmarshal(h.out.Bytes(), &result), "one object") + require.Len(t, result.Plan.Changes, 4) + require.Len(t, result.Applied, 4) + assert.Equal(t, result.Plan.Changes[3].Description, result.Applied[3]) + require.NotNil(t, result.Doctor) + assert.Equal(t, "shop.example", result.Doctor.Domain) + assert.Equal(t, 1, result.Doctor.ExitCode()) + }) + + t.Run("skip doctor leaves the doctor out", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + require.NoError(t, h.run(freshSetupArgs(t, "--yes", "--json-compact", "--skip-doctor")...)) + + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal(h.out.Bytes(), &result)) + assert.Contains(t, result, "plan") + assert.Contains(t, result, "applied") + assert.NotContains(t, result, "doctor") + }) + + t.Run("an empty plan applies nothing", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + + require.NoError(t, h.run("setup", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--guest-api-access", "--guest-all-scopes", "--out", goldenPoppyConfig(t), "--skip-doctor", "--json")) + + assert.JSONEq(t, `[]`, string(jsonField(t, h.out.Bytes(), "applied"))) + }) +} + +func jsonField(t *testing.T, data []byte, name string) json.RawMessage { + t.Helper() + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &fields)) + + return fields[name] +} + +func TestPoppySetupFailures(t *testing.T) { + forbidden := func(body string) error { return core.NewAPIError(http.StatusForbidden, nil, errors.New(body)) } + + tests := []struct { + name string + fail string + err error + wantCalls []string + wantPartial bool + wantMessage string + wantReason string + }{ + {name: "the first change fails", fail: "tenant", err: errors.New("boom"), wantCalls: []string{"tenant"}, wantMessage: "failed to update the tenant settings: boom"}, + {name: "the API fails", fail: "api", err: errors.New("boom"), wantCalls: []string{"tenant", "api"}, wantPartial: true, wantMessage: "failed to update the API"}, + { + name: "a 400 is printed as the platform sends it", fail: "app", err: core.NewAPIError(http.StatusBadRequest, nil, errors.New("invalid_personal_agent_browser_join_client")), + wantCalls: []string{"tenant", "api", "app"}, wantPartial: true, wantMessage: "invalid_personal_agent_browser_join_client", + }, + { + name: "a 403 for a missing scope", fail: "api", err: forbidden(`{"errorCode":"insufficient_scope","message":"Insufficient scope, expected any of: update:resource_servers"}`), + wantCalls: []string{"tenant", "api"}, wantPartial: true, wantMessage: "auth0 login --scopes update:resource_servers", + }, + { + name: "any other 403", fail: "grant", err: forbidden(`{"errorCode":"poppy_beta_off"}`), + wantCalls: []string{"tenant", "api", "app", "grant"}, wantPartial: true, wantMessage: "the tenant may lack Agent as Principal or the Poppy beta", + }, + { + name: "a 403 on the first change", fail: "tenant", err: forbidden(`{"errorCode":"poppy_beta_off"}`), + wantCalls: []string{"tenant"}, wantMessage: "the tenant may lack Agent as Principal or the Poppy beta", + }, + } + + for _, test := range tests { + for _, mode := range []struct { + name string + flags []string + }{{name: "human"}, {name: "JSON", flags: []string{"--json"}}} { + t.Run(test.name+" ("+mode.name+")", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + h.fail[test.fail] = test.err + args := freshSetupArgs(t, append([]string{"--yes", "--guest-api-access", "--guest-all-scopes"}, mode.flags...)...) + + err := h.run(args...) + + require.Error(t, err) + assert.Equal(t, test.wantCalls, h.calls, "stops at the first failure") + assert.Contains(t, err.Error(), test.wantMessage) + assert.NoFileExists(t, outOf(args), "the file is written last") + assert.NotContains(t, h.out.String(), "Checking your setup", "doctor runs only after a full apply") + + var partial *setup.PartialError + isPartial := errors.As(err, &partial) + + switch { + case !test.wantPartial: + assert.False(t, isPartial) + case len(mode.flags) > 0: + require.True(t, isPartial, "the JSON error keeps the whole account") + assert.Contains(t, err.Error(), "stopped partway") + assert.Contains(t, err.Error(), "Running the command again continues") + assert.Equal(t, "partial", decodeDetails(t, err)["code"]) + default: + // The step list says what was applied and how to continue, so the error is + // only the cause and the account is not printed a second time. + assert.False(t, isPartial, "a person gets the cause only") + assert.NotContains(t, err.Error(), "Running the command again continues") + assert.NotContains(t, err.Error(), "stopped partway") + assert.Equal(t, 1, strings.Count(h.out.String(), "Running the command again continues from here.")) + assert.Contains(t, h.out.String(), "To rerun:") + assert.Contains(t, h.out.String(), "not run") + } + }) + } + } +} + +func TestPoppyJSONLeavesNoText(t *testing.T) { + for _, flag := range []string{"--json", "--json-compact"} { + tests := map[string]struct { + tenant func() poppyTenantState + args func(t *testing.T) []string + }{ + "setup": {freshPoppyTenant, func(t *testing.T) []string { return freshSetupArgs(t, flag, "--yes", "--skip-doctor") }}, + "setup dry run": {freshPoppyTenant, func(t *testing.T) []string { return freshSetupArgs(t, flag, "--dry-run") }}, + "setup refused": {freshPoppyTenant, func(t *testing.T) []string { return freshSetupArgs(t, flag) }}, + "config": {configuredPoppyTenant, func(t *testing.T) []string { + return []string{"config", flag, "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--api-url", poppyTestAPI + "=" + poppyTestAPI + "/openapi.json", "--out", filepath.Join(t.TempDir(), "auth0-poppy.json")} + }}, + } + + for name, test := range tests { + t.Run(flag+" "+name, func(t *testing.T) { + h := newPoppySetupHarness(t, test.tenant()) + h.allowWrites() + + _ = h.run(test.args(t)...) + + assert.Empty(t, h.messages.String(), "nothing on stderr") + + if h.out.Len() > 0 { + assert.True(t, json.Valid(h.out.Bytes()), "nothing but the JSON on stdout:\n%s", h.out.String()) + } + }) + } + } +} + +func TestPoppySetupFailureText(t *testing.T) { + t.Run("the steps made, the one that failed and the ones not run", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + h.fail["app"] = errors.New("boom") + + err := h.run(freshSetupArgs(t, "--yes", "--guest-api-access", "--guest-all-scopes")...) + + require.Error(t, err) + + out := h.out.String() + assert.Contains(t, out, "Applying 13 changes\n") + assert.Contains(t, out, " ok Tenant settings") + assert.Contains(t, out, "5 changes") + assert.Contains(t, out, " x App Shop Web") + assert.Contains(t, out, "failed\n") + assert.Contains(t, out, "boom") + assert.Contains(t, out, " - Guest grant "+poppyTestAPI) + assert.Contains(t, out, "not run") + assert.Contains(t, out, "Running the command again continues from here.\nTo rerun: auth0 poppy setup --company-domain shop.example --api "+poppyTestAPI) + assert.Contains(t, out, "--guest-all-scopes --yes") + assert.NotContains(t, out, "--dry-run") + assert.Empty(t, h.messages.String()) + }) + + t.Run("the output flags print no block", func(t *testing.T) { + for _, flag := range []string{"--json", "--json-compact"} { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + h.fail["app"] = errors.New("boom") + + err := h.run(freshSetupArgs(t, "--yes", flag)...) + + require.Error(t, err, flag) + assert.Empty(t, h.out.String(), flag) + assert.Empty(t, h.messages.String(), flag) + } + }) +} + +func decodeDetails(t *testing.T, err error) map[string]any { + t.Helper() + + var details map[string]any + require.NoError(t, json.Unmarshal(errorDetails(err), &details)) + + return details +} + +func TestPoppySetupPreflight(t *testing.T) { + keyring.MockInit() + + home := t.TempDir() + t.Setenv("HOME", home) + + // The login holds every read scope but no scope to write. + payload, err := json.Marshal(map[string]string{"scope": strings.Join(setup.ReadScopes, " ")}) + require.NoError(t, err) + token := "e30." + base64.RawURLEncoding.EncodeToString(payload) + ".sig" + + saved, err := json.Marshal(map[string]any{ + "default_tenant": poppySetupTenant, + "tenants": map[string]any{poppySetupTenant: map[string]any{"domain": poppySetupTenant, "access_token": token}}, + }) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "auth0"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "auth0", "config.json"), saved, 0o600)) + + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + args := freshSetupArgs(t, "--yes") + + err = h.run(args...) + + require.Error(t, err) + assert.Equal(t, "missing_scopes", errorReason(err)) + assert.Contains(t, err.Error(), "auth0 login --scopes ") + assert.Contains(t, err.Error(), "update:tenant_settings") + assert.Empty(t, h.calls, "nothing is changed") + assert.NoFileExists(t, outOf(args)) + + h = newPoppySetupHarness(t, freshPoppyTenant()) + require.NoError(t, h.run(freshSetupArgs(t, "--dry-run")...), "a dry run needs only the read scopes") +} + +func TestCheckPoppyScopesModes(t *testing.T) { + keyring.MockInit() + + home := t.TempDir() + t.Setenv("HOME", home) + + // The saved login holds no scope the commands need. + payload, err := json.Marshal(map[string]string{"scope": "openid"}) + require.NoError(t, err) + token := "e30." + base64.RawURLEncoding.EncodeToString(payload) + ".sig" + + saved, err := json.Marshal(map[string]any{ + "default_tenant": poppySetupTenant, + "tenants": map[string]any{poppySetupTenant: map[string]any{"domain": poppySetupTenant, "access_token": token}}, + }) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "auth0"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "auth0", "config.json"), saved, 0o600)) + + t.Run("a saved login is checked", func(t *testing.T) { + t.Setenv(authModeEnvVar, "") + + err := (&cli{tenant: poppySetupTenant}).checkPoppyScopes([]string{"read:clients"}) + + require.Error(t, err) + assert.Equal(t, "missing_scopes", errorReason(err)) + }) + + t.Run("the saved login is not consulted in env auth mode", func(t *testing.T) { + t.Setenv(authModeEnvVar, authModeEnv) + + assert.NoError(t, (&cli{tenant: poppySetupTenant}).checkPoppyScopes([]string{"read:clients"})) + }) +} + +func TestPoppySetupGuestScopes(t *testing.T) { + t.Run("the scopes are required without a terminal and nothing changes", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + args := freshSetupArgs(t, "--yes", "--guest-api-access") + + err := h.run(args...) + + reason, details := decodeCLIError(t, err) + assert.Equal(t, "scopes_required", reason) + assert.Contains(t, err.Error(), "pass --guest-scopes or --guest-all-scopes") + assert.Contains(t, err.Error(), poppyTestAPI+": orders:read, checkout") + assert.Contains(t, err.Error(), "\n FIX ") + assert.NotEmpty(t, details["apis"], "the offer travels with the error") + assert.Empty(t, h.calls) + assert.NoFileExists(t, outOf(args)) + }) + + t.Run("JSON output fails the same way", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + + err := h.run(freshSetupArgs(t, "--yes", "--json", "--guest-api-access")...) + + reason, _ := decodeCLIError(t, err) + assert.Equal(t, "scopes_required", reason) + }) + + t.Run("a missing description is reported before the scopes", func(t *testing.T) { + state := freshPoppyTenant() + state.server.Scopes[1].Description = nil + h := newPoppySetupHarness(t, state) + + err := h.run(freshSetupArgs(t, "--yes", "--guest-api-access")...) + + reason, _ := decodeCLIError(t, err) + assert.Equal(t, "scope_description_required", reason) + }) + + t.Run("named scopes are the only ones in the plan and the grant", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + require.NoError(t, h.run(freshSetupArgs(t, "--dry-run", "--guest-api-access", "--guest-scopes", "orders:read")...)) + assert.Empty(t, h.calls) + assert.Contains(t, h.out.String(), "scopes: orders:read\n") + + h = newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + require.NoError(t, h.run(freshSetupArgs(t, "--yes", "--skip-doctor", "--guest-api-access", "--guest-scopes", "orders:read")...)) + assert.Equal(t, []string{"tenant", "api", "app", "grant"}, h.calls) + assert.Equal(t, []string{"orders:read"}, h.grant.Scope) + }) + + t.Run("the plan shows exactly the named scopes", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + + require.NoError(t, h.run(freshSetupArgs(t, "--dry-run", "--json", "--guest-api-access", "--guest-scopes", "orders:read")...)) + + var plan setup.Plan + require.NoError(t, json.Unmarshal(h.out.Bytes(), &plan)) + var scope string + for _, change := range plan.Changes { + if change.Kind == setup.KindGrant { + for _, field := range change.Fields { + if field.Name == "scope" { + scope = field.Value + } + } + } + } + assert.Equal(t, "orders:read", scope) + }) + + t.Run("every scope", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + require.NoError(t, h.run(freshSetupArgs(t, "--yes", "--skip-doctor", "--guest-api-access", "--guest-all-scopes")...)) + + assert.Equal(t, []string{"orders:read", "checkout", setup.BrowserSignInScope}, h.grant.Scope) + }) + + t.Run("a scope the API lacks", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + + err := h.run(freshSetupArgs(t, "--yes", "--guest-api-access", "--guest-scopes", "nope")...) + + reason, _ := decodeCLIError(t, err) + assert.Equal(t, "unknown_scope", reason) + assert.Contains(t, err.Error(), `scope "nope" is not defined on `+poppyTestAPI) + }) + + t.Run("the scope flags need the guest grant", func(t *testing.T) { + for _, flag := range [][]string{{"--guest-scopes", "orders:read"}, {"--guest-all-scopes"}} { + h := newPoppySetupHarness(t, freshPoppyTenant()) + + err := h.run(freshSetupArgs(t, append([]string{"--yes"}, flag...)...)...) + + reason, _ := decodeCLIError(t, err) + assert.Equal(t, "invalid_flag", reason, flag[0]) + assert.Contains(t, err.Error(), "\n FIX Add --guest-api-access.", flag[0]) + assert.Empty(t, h.calls) + } + }) + + t.Run("the two scope flags exclude each other", func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + + err := h.run(freshSetupArgs(t, "--guest-api-access", "--guest-scopes", "orders:read", "--guest-all-scopes")...) + + require.Error(t, err) + assert.Contains(t, err.Error(), "none of the others can be") + }) + + t.Run("a rerun on a configured tenant needs no scope flag and changes nothing", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + args := []string{"setup", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, "--guest-api-access", "--out", goldenPoppyConfig(t), "--skip-doctor"} + + require.NoError(t, h.run(args...), "no write call is set on the mocks") + + assert.Contains(t, h.out.String(), "Nothing to change") + }) +} + +// guestPromptScript answers the guest questions in order and records what was +// asked. +type guestPromptScript struct { + allow bool + all bool + picks map[string][]string + failOn string + + asked []string + notes []string +} + +func (s *guestPromptScript) prompts() poppyGuestPrompts { + return poppyGuestPrompts{ + allow: func() (bool, error) { + s.asked = append(s.asked, "allow") + + return s.allow, nil + }, + all: func() (bool, error) { + s.asked = append(s.asked, "all") + if s.failOn == "all" { + return false, errors.New("interrupted") + } + + return s.all, nil + }, + pick: func(audience string, _ []setup.GuestScope) ([]string, error) { + s.asked = append(s.asked, "pick "+audience) + + return s.picks[audience], nil + }, + note: func(format string, args ...any) { s.notes = append(s.notes, fmt.Sprintf(format, args...)) }, + } +} + +func guestTestState(guest map[string]bool) *setup.State { + scopes := []setup.Scope{ + {Name: "orders:read", Description: "See your orders and their status. Read only."}, + {Name: "checkout", Description: "Place orders and pay with your saved card."}, + } + + return &setup.State{ + APIs: []setup.API{ + {Identifier: poppyTestAPI, Scopes: scopes}, + {Identifier: "https://other.example", Scopes: scopes[:1]}, + }, + HasGuestGrant: guest, + } +} + +func TestChoosePoppyGuestScopes(t *testing.T) { + const other = "https://other.example" + + tests := []struct { + name string + interactive bool + in setup.Inputs + guest map[string]bool + script guestPromptScript + wantAsked []string + wantAccess bool + wantAll bool + wantScopes map[string][]string + wantReason string + wantNotes int + }{ + {name: "without a terminal and without the guest grant nothing is asked", in: setup.Inputs{}, wantAsked: nil}, + { + name: "in a terminal the question about guests is asked first, default no", + interactive: true, + wantAsked: []string{"allow"}, + }, + { + name: "no guests, no more questions", + interactive: true, + script: guestPromptScript{allow: false, all: true}, + wantAsked: []string{"allow"}, + }, + { + name: "a guest grant on only one API still asks the question", + interactive: true, + guest: map[string]bool{other: true}, + wantAsked: []string{"allow"}, + }, + { + name: "a guest grant on every API means the question is not asked", + interactive: true, + guest: map[string]bool{poppyTestAPI: true, other: true}, + wantAsked: nil, + }, + { + name: "yes, then all scopes", + interactive: true, + script: guestPromptScript{allow: true, all: true}, + wantAsked: []string{"allow", "all"}, + wantAccess: true, + wantAll: true, + }, + { + name: "yes, then a selection on each API", + interactive: true, + script: guestPromptScript{allow: true, picks: map[string][]string{poppyTestAPI: {"orders:read"}, other: {"orders:read"}}}, + wantAsked: []string{"allow", "all", "pick " + poppyTestAPI, "pick " + other}, + wantAccess: true, + wantScopes: map[string][]string{poppyTestAPI: {"orders:read"}, other: {"orders:read"}}, + }, + { + name: "the guest grant flag skips the first question", + interactive: true, + in: setup.Inputs{GuestAPIAccess: true}, + script: guestPromptScript{all: true}, + wantAsked: []string{"all"}, + wantAccess: true, + wantAll: true, + }, + { + name: "an API with nothing chosen gets no guest grant", + interactive: true, + in: setup.Inputs{GuestAPIAccess: true}, + script: guestPromptScript{picks: map[string][]string{poppyTestAPI: {"checkout"}}}, + wantAsked: []string{"all", "pick " + poppyTestAPI, "pick " + other}, + wantAccess: true, + wantScopes: map[string][]string{poppyTestAPI: {"checkout"}}, + wantNotes: 1, + }, + { + name: "nothing chosen anywhere", + interactive: true, + in: setup.Inputs{GuestAPIAccess: true}, + wantAsked: []string{"all", "pick " + poppyTestAPI, "pick " + other}, + wantAccess: true, + wantScopes: map[string][]string{}, + wantNotes: 1, + }, + { + name: "an API that has a guest grant is not listed", + interactive: true, + in: setup.Inputs{GuestAPIAccess: true}, + guest: map[string]bool{other: true}, + script: guestPromptScript{picks: map[string][]string{poppyTestAPI: {"orders:read"}}}, + wantAsked: []string{"all", "pick " + poppyTestAPI}, + wantAccess: true, + wantScopes: map[string][]string{poppyTestAPI: {"orders:read"}}, + }, + { + name: "named scopes skip both scope questions", + interactive: true, + in: setup.Inputs{GuestAPIAccess: true, GuestScopeNames: []string{"orders:read"}}, + wantAccess: true, + wantScopes: map[string][]string{poppyTestAPI: {"orders:read"}, other: {"orders:read"}}, + }, + { + name: "every scope skips the scope questions", + interactive: true, + in: setup.Inputs{GuestAPIAccess: true, GuestAllScopes: true}, + wantAccess: true, + wantAll: true, + }, + { + name: "an interrupted question stops", + interactive: true, + in: setup.Inputs{GuestAPIAccess: true}, + script: guestPromptScript{failOn: "all"}, + wantAsked: []string{"all"}, + wantReason: "interrupted", + wantAccess: true, + }, + { + name: "without a terminal the scopes are required", + in: setup.Inputs{GuestAPIAccess: true}, + wantReason: "scopes_required", + wantAccess: true, + }, + { + name: "without a terminal a scope that one API lacks is refused", + in: setup.Inputs{GuestAPIAccess: true, GuestScopeNames: []string{"checkout"}}, + wantReason: "unknown_scope", + wantAccess: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + in := test.in + + err := choosePoppyGuestScopes(test.interactive, test.script.prompts(), guestTestState(test.guest), &in) + + assert.Equal(t, test.wantAsked, test.script.asked) + assert.Len(t, test.script.notes, test.wantNotes) + + if test.wantReason != "" { + require.Error(t, err) + if test.wantReason == "interrupted" { + assert.ErrorContains(t, err, "interrupted") + } else { + assert.Equal(t, test.wantReason, errorReason(err)) + } + + return + } + + require.NoError(t, err) + assert.Equal(t, test.wantAccess, in.GuestAPIAccess) + assert.Equal(t, test.wantAll, in.GuestAllScopes) + assert.Equal(t, test.wantScopes, in.GuestScopes) + }) + } +} + +func TestPoppySetupScopeDescriptionsWithoutATerminal(t *testing.T) { + undescribed := func() poppyTenantState { + state := freshPoppyTenant() + state.server.Scopes[1].Description = nil + + return state + } + + tests := []struct { + name string + args func(t *testing.T) []string + }{ + {name: "no terminal", args: func(t *testing.T) []string { return freshSetupArgs(t) }}, + {name: "JSON output", args: func(t *testing.T) []string { return freshSetupArgs(t, "--json") }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + h := newPoppySetupHarness(t, undescribed()) + + err := h.run(test.args(t)...) + + reason, _ := decodeCLIError(t, err) + assert.Equal(t, "scope_description_required", reason) + assert.Contains(t, err.Error(), "These have none: checkout ("+poppyTestAPI+").") + assert.Contains(t, err.Error(), "\n FIX Pass --scope 'checkout='.") + assert.Empty(t, h.calls) + }) + } + + t.Run("--scope answers it", func(t *testing.T) { + h := newPoppySetupHarness(t, undescribed()) + + require.NoError(t, h.run(freshSetupArgs(t, "--dry-run", "--scope", "checkout=Place orders")...)) + assert.Contains(t, h.out.String(), `~ scope checkout`) + assert.Contains(t, h.out.String(), `none -> "Place orders"`) + assert.Contains(t, h.out.String(), `+ scope poppy:browser-sign-in`) + }) +} + +func TestIsPoppyScopeDescriptionRequired(t *testing.T) { + assert.True(t, isPoppyScopeDescriptionRequired(&setup.InputError{Reason: setup.ReasonScopeDescriptionRequired})) + assert.True(t, isPoppyScopeDescriptionRequired(fmt.Errorf("wrapped: %w", &setup.InputError{Reason: setup.ReasonScopeDescriptionRequired}))) + assert.False(t, isPoppyScopeDescriptionRequired(&setup.InputError{Reason: "app_not_first_party"})) + assert.False(t, isPoppyScopeDescriptionRequired(errors.New("other"))) + assert.False(t, isPoppyScopeDescriptionRequired(nil)) +} + +func TestDescribePoppyScopes(t *testing.T) { + state := configuredPoppyStateForScopes() + + const ( + ordersTitle = "Description of orders:read" + checkoutTitle = "Description of checkout" + ) + + tests := []struct { + name string + answers []string + askErr error + wantLabels []string + wantScopes map[string]string + wantInvalid int + }{ + { + name: "each scope is asked about and stored as if --scope had been passed", + answers: []string{"See orders", "Place orders"}, + wantLabels: []string{"Text", "Text"}, + wantScopes: map[string]string{"orders:read": "See orders", "checkout": "Place orders"}, + }, + { + name: "an empty answer asks again", + answers: []string{"", " ", "See orders", "Place orders"}, + wantLabels: []string{"Text", "Text", "Text", "Text"}, + wantScopes: map[string]string{"orders:read": "See orders", "checkout": "Place orders"}, + wantInvalid: 2, + }, + { + name: "an interrupted prompt stops", + askErr: errors.New("interrupt"), + wantLabels: []string{"Text"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + in := setup.Inputs{} + + var labels []string + next := 0 + + screen, out := newTestPoppyScreen(t, poppyAsker{ + text: func(_ *poppyScreen, label, _ string) (string, error) { + labels = append(labels, label) + if test.askErr != nil { + return "", test.askErr + } + + answer := test.answers[next] + next++ + + return answer, nil + }, + }, false) + + err := describePoppyScopes(screen, setup.ScopeQuestions(state, in), &in) + + assert.Equal(t, test.wantLabels, labels) + assert.Contains(t, out.String(), ordersTitle) + assert.Equal(t, test.wantInvalid, strings.Count(out.String(), "A value is required.")) + + if test.askErr != nil { + assert.EqualError(t, err, test.askErr.Error()) + + return + } + + require.NoError(t, err) + assert.Contains(t, out.String(), checkoutTitle) + assert.Equal(t, test.wantScopes, in.Scopes) + assert.Empty(t, setup.ScopeQuestions(state, in), "nothing is left to ask") + }) + } +} + +// configuredPoppyStateForScopes is a state whose two scopes have no description. +func configuredPoppyStateForScopes() *setup.State { + return &setup.State{ + APIs: []setup.API{{ + ID: "rs_shop", + Identifier: poppyTestAPI, + Scopes: []setup.Scope{{Name: "orders:read"}, {Name: "checkout"}}, + }}, + } +} + +// runPromptMode runs a setup command of the harness in a mode and returns what it did. +func (h *poppySetupHarness) runPromptMode(t *testing.T, mode poppyPromptMode, args []string) poppyPromptRun { + t.Helper() + + asked := installPoppyAsker(t, mode) + h.agentMode = mode.agentMode + + err := h.run(append(args, mode.flags...)...) + + return poppyPromptRun{asked: *asked, out: h.out.String(), errOut: h.messages.String(), err: err} +} + +// checkPoppySetupPromptRows runs setup with the extra flags against the tenant +// in each mode and compares it with the row of the mode. +func checkPoppySetupPromptRows(t *testing.T, tenant poppyTenantState, extra []string, tests []poppyPromptRow) { + t.Helper() + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := newPoppySetupHarness(t, tenant) + h.allowWrites() + + assertPoppyPromptRun(t, tc, h.runPromptMode(t, tc.mode, freshSetupArgs(t, extra...))) + }) + } +} + +func TestPoppySetupPromptRule(t *testing.T) { + const plan = "Poppy setup for shop.example" + + asks := []string{"hints", "guest access", "guest scopes", "confirm"} + + checkPoppySetupPromptRows(t, freshPoppyTenant(), []string{"--skip-doctor"}, []poppyPromptRow{ + {name: "terminal", mode: modeTerminal, asked: asks, text: "Applying"}, + {name: "terminal with --json", mode: modeTerminalJSON, asked: asks, text: plan, json: true}, + {name: "terminal with --no-input", mode: modeTerminalNoInput, text: plan, reason: "confirmation_required", errText: "--yes"}, + {name: "piped", mode: modePiped, text: plan, reason: "confirmation_required", errText: "--yes"}, + {name: "piped with --json", mode: modePipedJSON, reason: "confirmation_required", errText: "--yes"}, + {name: "agent mode", mode: modeAgent, reason: "confirmation_required", errText: "--yes"}, + {name: "agent mode with --no-input=false", mode: modeAgentPrompting, asked: asks, text: plan, json: true}, + }) +} + +func TestPoppySetupPromptRuleScopeDescription(t *testing.T) { + const ( + plan = "Poppy setup for shop.example" + missing = "FIX Pass --scope 'checkout='." + ) + + asks := []string{"text Text", "hints", "confirm"} + + tenant := freshPoppyTenant() + tenant.server.Scopes[1].Description = nil + + checkPoppySetupPromptRows(t, tenant, []string{"--skip-doctor", "--guest-api-access", "--guest-all-scopes"}, []poppyPromptRow{ + {name: "terminal", mode: modeTerminal, asked: asks, text: "Applying"}, + {name: "terminal with --json", mode: modeTerminalJSON, asked: asks, text: plan, json: true}, + {name: "terminal with --no-input", mode: modeTerminalNoInput, reason: "scope_description_required", errText: missing}, + {name: "piped", mode: modePiped, reason: "scope_description_required", errText: missing}, + {name: "piped with --json", mode: modePipedJSON, reason: "scope_description_required", errText: missing}, + {name: "agent mode", mode: modeAgent, reason: "scope_description_required", errText: missing}, + {name: "agent mode with --no-input=false", mode: modeAgentPrompting, asked: asks, text: plan, json: true}, + }) +} + +func TestPoppySetupPromptRuleGuestScopes(t *testing.T) { + const ( + plan = "Poppy setup for shop.example" + missing = "pass --guest-scopes or --guest-all-scopes" + ) + + asks := []string{"hints", "guest scopes"} + + checkPoppySetupPromptRows(t, freshPoppyTenant(), []string{"--skip-doctor", "--yes", "--guest-api-access"}, []poppyPromptRow{ + {name: "terminal", mode: modeTerminal, asked: asks, text: "Applying"}, + {name: "terminal with --json", mode: modeTerminalJSON, asked: asks, text: plan, json: true}, + {name: "terminal with --no-input", mode: modeTerminalNoInput, reason: "scopes_required", errText: missing}, + {name: "piped", mode: modePiped, reason: "scopes_required", errText: missing}, + {name: "piped with --json", mode: modePipedJSON, reason: "scopes_required", errText: missing}, + {name: "agent mode", mode: modeAgent, reason: "scopes_required", errText: missing}, + {name: "agent mode with --no-input=false", mode: modeAgentPrompting, asked: asks, text: plan, json: true}, + }) +} + +func TestPoppyConfigPromptRule(t *testing.T) { + // A terminal is asked about the hints of the scopes, and the test terminal + // cannot take the question back, so it stays on the screen. + const question = "Hints for your scopes" + + asks := []string{"hints"} + + tests := []poppyPromptRow{ + {name: "terminal", mode: modeTerminal, asked: asks, text: "written"}, + {name: "terminal with --json", mode: modeTerminalJSON, asked: asks, text: question, json: true}, + {name: "terminal with --no-input", mode: modeTerminalNoInput, text: "written"}, + {name: "piped", mode: modePiped, text: "written"}, + {name: "piped with --json", mode: modePipedJSON, json: true}, + {name: "agent mode", mode: modeAgent, json: true}, + {name: "agent mode with --no-input=false", mode: modeAgentPrompting, asked: asks, text: question, json: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + args := []string{"config", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--api-url", poppyTestAPI + "=" + poppyTestAPI + "/openapi.json", "--out", filepath.Join(t.TempDir(), "auth0-poppy.json")} + + assertPoppyPromptRun(t, tc, h.runPromptMode(t, tc.mode, args)) + }) + } +} + +func TestPoppyConfigPromptRuleCompanyDomain(t *testing.T) { + const missing = "--company-domain is required" + + // The question a terminal gets here is a survey prompt that no test can answer, + // so the modes that ask are left out. + tests := []poppyPromptRow{ + {name: "terminal with --no-input", mode: modeTerminalNoInput, reason: "missing_flag", errText: missing}, + {name: "piped", mode: modePiped, reason: "missing_flag", errText: missing}, + {name: "piped with --json", mode: modePipedJSON, reason: "missing_flag", errText: missing}, + {name: "agent mode", mode: modeAgent, reason: "missing_flag", errText: missing}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tenant := configuredPoppyTenant() + tenant.settings.PersonalAgentProtocolCompanyDomains = []string{"https://shop.example", "https://shop.test"} + + h := newPoppySetupHarness(t, tenant) + args := []string{"config", "--api", poppyTestAPI, "--app", poppySetupAppID, "--out", filepath.Join(t.TempDir(), "auth0-poppy.json")} + + assertPoppyPromptRun(t, tc, h.runPromptMode(t, tc.mode, args)) + }) + } +} + +// poppyScript answers the prompts of a test in order and keeps what they asked. +// An answer is a bool for a question of yes or no, an int for a list, an error +// for Ctrl-C, and nil for Enter, which takes the default. +type poppyScript struct { + answers []any + asked []string +} + +func (s *poppyScript) next(t *testing.T, message string) any { + t.Helper() + + s.asked = append(s.asked, message) + require.NotEmpty(t, s.answers, "no answer was scripted for %q", message) + + answer := s.answers[0] + s.answers = s.answers[1:] + + return answer +} + +func (s *poppyScript) asker(t *testing.T) poppyAsker { + return poppyAsker{ + confirm: func(_ *poppyScreen, message string, def bool) (bool, error) { + switch answer := s.next(t, message).(type) { + case nil: + return def, nil + case bool: + return answer, nil + case error: + return false, answer + default: + require.Failf(t, "wrong answer", "%T for a question of yes or no", answer) + + return false, nil + } + }, + choose: func(_ *poppyScreen, message string, _ []string, def int) (int, error) { + switch answer := s.next(t, message).(type) { + case nil: + return def, nil + case int: + return answer, nil + case error: + return 0, answer + default: + require.Failf(t, "wrong answer", "%T for a list", answer) + + return 0, nil + } + }, + } +} + +var hintScopes = []setup.HintScope{ + {Name: "orders:read", Description: "See your orders and their status.", APIs: []string{"https://api.shop.example"}}, + {Name: "checkout", Description: "Place orders and pay with your saved card.", APIs: []string{"https://api.shop.example"}}, + {Name: "refund", Description: "Refund an order you placed.", APIs: []string{"https://api.shop.example", "https://other.example"}}, +} + +const ( + introQuestion = "Set them now?" + sameQuestion = "Use the same answers for the remaining 2 scopes?" + changeQuestion = poppyChangesQuestion + personalData = poppyPersonalDataQuestion +) + +func TestAskPoppyHints(t *testing.T) { + const ( + none = 0 + reversible = 1 + irreversible = 2 + ) + + cautious := config.Hints{Changes: "irreversible", PersonalData: true} + + tests := []struct { + name string + scopes []setup.HintScope + answers []any + wantAsked []string + wantHints map[string]config.Hints + wantUnhinted int + wantErr error + }{ + { + name: "Enter on every question is the cautious answer for every scope", + scopes: hintScopes, + answers: make([]any, 8), + wantAsked: []string{ + introQuestion, + changeQuestion, personalData, sameQuestion, + changeQuestion, personalData, + changeQuestion, personalData, + }, + wantHints: map[string]config.Hints{"orders:read": cautious, "checkout": cautious, "refund": cautious}, + }, + { + name: "mixed answers", + scopes: hintScopes, + answers: []any{nil, none, false, false, reversible, true, nil, false}, + wantAsked: []string{ + introQuestion, + changeQuestion, personalData, sameQuestion, + changeQuestion, personalData, + changeQuestion, personalData, + }, + wantHints: map[string]config.Hints{ + "orders:read": {Changes: "none"}, + "checkout": {Changes: "reversible", PersonalData: true}, + "refund": {Changes: "irreversible"}, + }, + }, + { + name: "the same answers for the remaining scopes", + scopes: hintScopes, + answers: []any{nil, none, false, true}, + wantAsked: []string{introQuestion, changeQuestion, personalData, sameQuestion}, + wantHints: map[string]config.Hints{"orders:read": {Changes: "none"}, "checkout": {Changes: "none"}, "refund": {Changes: "none"}}, + }, + { + name: "skipping all leaves the hints unset", + scopes: hintScopes, + answers: []any{false}, + wantAsked: []string{introQuestion}, + wantUnhinted: 3, + }, + { + name: "one scope has its own wording and no shortcut", + scopes: hintScopes[:1], + answers: []any{nil, irreversible, nil}, + wantAsked: []string{"Set it now?", changeQuestion, personalData}, + wantHints: map[string]config.Hints{"orders:read": cautious}, + }, + { + name: "two scopes offer the shortcut for the one that remains", + scopes: hintScopes[:2], + answers: []any{nil, none, false, nil, nil, nil}, + wantAsked: []string{introQuestion, changeQuestion, personalData, "Use the same answers for the remaining 1 scope?", changeQuestion, personalData}, + wantHints: map[string]config.Hints{"orders:read": {Changes: "none"}, "checkout": cautious}, + }, + { + name: "no scopes, no questions", + scopes: nil, + wantUnhinted: 0, + }, + { + name: "Ctrl-C at the first question", + scopes: hintScopes, + answers: []any{terminal.InterruptErr}, + wantAsked: []string{introQuestion}, + wantErr: terminal.InterruptErr, + }, + { + name: "Ctrl-C at the second question of a scope", + scopes: hintScopes, + answers: []any{nil, none, terminal.InterruptErr}, + wantAsked: []string{introQuestion, changeQuestion, personalData}, + wantErr: terminal.InterruptErr, + }, + { + name: "Ctrl-C at the shortcut", + scopes: hintScopes, + answers: []any{nil, none, false, terminal.InterruptErr}, + wantAsked: []string{introQuestion, changeQuestion, personalData, sameQuestion}, + wantErr: terminal.InterruptErr, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + script := &poppyScript{answers: test.answers} + screen, out := newTestPoppyScreen(t, script.asker(t), false) + screen.ForDomain("shop.example") + + in := setup.Inputs{} + unhinted, err := askPoppyHints(screen, test.scopes, &in) + + assert.Equal(t, test.wantAsked, script.asked) + + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + assert.True(t, strings.HasSuffix(out.String(), display.PoppyCancelled+"\n"), "the last line says nothing was changed:\n%s", out.String()) + + return + } + + require.NoError(t, err) + assert.Equal(t, test.wantUnhinted, unhinted) + assert.Equal(t, test.wantHints, in.Hints) + assert.NotContains(t, out.String(), display.PoppyCancelled) + assert.Empty(t, script.answers, "every scripted answer was used") + }) + } +} + +func TestAskPoppyHintsScreen(t *testing.T) { + script := &poppyScript{answers: []any{nil, 0, false, false, nil, nil}} + screen, out := newTestPoppyScreen(t, script.asker(t), false) + screen.ForDomain("shop.example") + + _, err := askPoppyHints(screen, hintScopes[:2], &setup.Inputs{}) + require.NoError(t, err) + + want := ansi.Bold("Poppy setup for shop.example") + strings.Repeat(" ", 30) + "tenant mock.auth0.test\n" + + "\n" + + "Hints for your scopes\n" + + "2 scopes have no hints yet. Agents show them to the user before asking for\n" + + "approval.\n" + + "\n" + + "Hints for orders:read on https://api.shop.example (1 of 2)\n" + + "See your orders and their status.\n" + + " ✓ Does it change anything? none\n" + + " ✓ Does it show or change personal data? No\n" + + "\n" + + "Hints for checkout on https://api.shop.example (2 of 2)\n" + + "Place orders and pay with your saved card.\n" + + " ✓ Does it change anything? irreversible\n" + + " ✓ Does it show or change personal data? Yes\n" + + assert.Equal(t, want, stripEscapes(out.String()), "the title of the run is printed once, above the first question") +} + +// stripEscapes removes the escape sequences of the colors and of the cursor. +var escapes = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]|\r`) + +func stripEscapes(s string) string { return escapes.ReplaceAllString(s, "") } + +func TestAskPoppyHintsErase(t *testing.T) { + t.Run("every block is taken back as soon as it is answered", func(t *testing.T) { + script := &poppyScript{answers: []any{nil, nil, nil, false, nil, nil}} + screen, out := newTestPoppyScreen(t, script.asker(t), true) + screen.ForDomain("shop.example") + + _, err := askPoppyHints(screen, hintScopes[:2], &setup.Inputs{}) + require.NoError(t, err) + + // The intro, and the block of each of the two scopes. + erases := regexp.MustCompile(`\r\x1b\[(\d+)A\x1b\[J`).FindAllStringSubmatch(out.String(), -1) + require.Len(t, erases, 3) + + // The intro takes a blank line, the title, and the reason on two rows. A scope + // takes the same, with a reason of one row, and the two answers that stay in + // view. The rows of the prompts themselves are counted by the prompts. + assert.Equal(t, "4", erases[0][1]) + assert.Equal(t, "5", erases[1][1]) + assert.Equal(t, "5", erases[2][1]) + + assert.Equal(t, 1, strings.Count(out.String(), "Poppy setup for shop.example"), "the title of the run is not taken back") + assert.NotContains(t, out.String(), display.PoppyCancelled) + }) + + t.Run("Ctrl-C takes the question back and says nothing was changed", func(t *testing.T) { + script := &poppyScript{answers: []any{nil, terminal.InterruptErr}} + screen, out := newTestPoppyScreen(t, script.asker(t), true) + screen.ForDomain("shop.example") + + _, err := askPoppyHints(screen, hintScopes, &setup.Inputs{}) + + require.ErrorIs(t, err, terminal.InterruptErr) + + got := out.String() + assert.Regexp(t, `\r\x1b\[\d+A\x1b\[J`+display.PoppyCancelled+`\n$`, got, "the question is erased, then one line is printed") + assert.Equal(t, 1, strings.Count(got, display.PoppyCancelled)) + assert.Equal(t, 3, strings.Count(got, "\x1b[J"), "the intro, the question, and the title of the run are taken back") + }) + + t.Run("a terminal that cannot take lines back leaves them", func(t *testing.T) { + script := &poppyScript{answers: make([]any, 4)} + screen, out := newTestPoppyScreen(t, script.asker(t), false) + + _, err := askPoppyHints(screen, hintScopes[:1], &setup.Inputs{}) + require.NoError(t, err) + + assert.NotContains(t, out.String(), "\x1b[") + assert.Contains(t, out.String(), "Hints for orders:read on https://api.shop.example (1 of 1)") + }) +} + +// hintFlow runs setup in a terminal against a fresh tenant with the answers +// scripted for the questions about hints, and the guest grant answered by the +// flags. The questions that are not about hints are not expected. +func runHintFlow(t *testing.T, answers []any, extra ...string) (poppyPromptRun, string) { + t.Helper() + + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + args := freshSetupArgs(t, append([]string{"--skip-doctor", "--guest-api-access", "--guest-all-scopes"}, extra...)...) + + script := &poppyScript{answers: answers} + run := h.runHintMode(t, script, args) + run.asked = script.asked + + return run, outOf(args) +} + +// runHintMode runs setup in a terminal that answers the hint questions from the +// script and every other question with yes. +func (h *poppySetupHarness) runHintMode(t *testing.T, script *poppyScript, args []string) poppyPromptRun { + t.Helper() + + installPoppyAsker(t, modeTerminal) + + scripted := script.asker(t) + previous := poppyAsk.confirm + poppyAsk.confirm = func(s *poppyScreen, message string, def bool) (bool, error) { + if strings.HasPrefix(message, "Set ") || strings.HasPrefix(message, "Use the same") || strings.HasPrefix(message, "Does it") { + return scripted.confirm(s, message, def) + } + + return previous(s, message, def) + } + poppyAsk.choose = scripted.choose + poppyAsk.interrupt = func(err error) error { return err } + + err := h.run(args...) + + return poppyPromptRun{out: h.out.String(), errOut: h.messages.String(), err: err} +} + +func TestPoppySetupHintAnswersEqualTheFlags(t *testing.T) { + // The cautious answer for orders:read is changed to none and no personal data, + // and checkout is left on Enter. + run, file := runHintFlow(t, []any{nil, 0, false, false, nil, nil}, "--dry-run") + require.NoError(t, run.err) + + flagged := newPoppySetupHarness(t, freshPoppyTenant()) + flagged.allowWrites() + require.NoError(t, flagged.run(freshSetupArgs(t, "--skip-doctor", "--guest-api-access", "--guest-all-scopes", "--dry-run", + "--hint", "orders:read=changes:none,personal_data:false", "--hint", "checkout=changes:irreversible,personal_data:true")...)) + + plan := func(out string) string { + _, after, found := strings.Cut(out, "\nChecked\n") + require.True(t, found, out) + + // The file is named by the temp directory of each run. + return regexp.MustCompile(`\S*auth0-poppy\.json`).ReplaceAllString(after, "FILE") + } + + assert.Equal(t, plan(flagged.out.String()), plan(run.out), "the plan, the answers and the command to apply are the same") + assert.Contains(t, run.out, "orders:read: none") + assert.Contains(t, run.out, "checkout: irreversible, personal data") + assert.Contains(t, run.out, "--hint orders:read=changes:none,personal_data:false") + assert.Contains(t, run.out, "--hint checkout=changes:irreversible,personal_data:true") + assert.Equal(t, []string{ + "Set them now?", + changeQuestion, personalData, "Use the same answers for the remaining 1 scope?", + changeQuestion, personalData, + }, run.asked) + assert.NoFileExists(t, file, "a dry run writes nothing") +} + +func TestPoppySetupHintAnswersWriteTheSameFile(t *testing.T) { + run, file := runHintFlow(t, []any{nil, 0, false, true}, "--yes") + require.NoError(t, run.err) + + answered, err := os.ReadFile(file) + require.NoError(t, err) + + flagged := newPoppySetupHarness(t, freshPoppyTenant()) + flagged.allowWrites() + + args := freshSetupArgs(t, "--skip-doctor", "--guest-api-access", "--guest-all-scopes", "--yes", + "--hint", "orders:read=changes:none,personal_data:false", "--hint", "checkout=changes:none,personal_data:false") + require.NoError(t, flagged.run(args...)) + + want, err := os.ReadFile(outOf(args)) + require.NoError(t, err) + + assert.JSONEq(t, string(want), string(answered)) + assert.Contains(t, string(answered), `"changes": "none"`) + assert.NotContains(t, run.out, "Set the hints of", "every scope has hints, so nothing is left to set") +} + +func TestPoppySetupSkippingHintsNamesTheFlag(t *testing.T) { + run, file := runHintFlow(t, []any{false}, "--yes") + require.NoError(t, run.err) + + assert.Equal(t, []string{"Set them now?"}, run.asked) + assert.Contains(t, run.out, "Set the hints of 2 scopes with --hint") + assert.Contains(t, run.out, "Agents show them before asking for approval.") + assert.NotContains(t, run.out, "Hints ", "no hints, no line in the answers") + + data, err := os.ReadFile(file) + require.NoError(t, err) + assert.NotContains(t, string(data), "orders:read\": {", "nothing is written for the skipped hints") +} + +func TestPoppySetupHintsAreNotAskedAgain(t *testing.T) { + t.Run("--hint answers a scope without a question", func(t *testing.T) { + run, _ := runHintFlow(t, []any{nil, nil, nil}, "--dry-run", "--hint", "checkout=changes:none,personal_data:false") + require.NoError(t, run.err) + + assert.Equal(t, []string{"Set it now?", changeQuestion, personalData}, run.asked, "only orders:read is asked about") + assert.Contains(t, run.out, "checkout: none") + assert.Contains(t, run.out, "orders:read: irreversible, personal data") + }) + + t.Run("--hint on every scope asks nothing", func(t *testing.T) { + run, _ := runHintFlow(t, nil, "--dry-run", "--hint", "checkout=changes:none,personal_data:false", "--hint", "orders:read=changes:none,personal_data:false") + require.NoError(t, run.err) + + assert.Empty(t, run.asked) + }) + + t.Run("a hint in the file of a configured tenant is not asked", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + args := []string{"setup", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--guest-api-access", "--guest-all-scopes", "--out", goldenPoppyConfig(t), "--skip-doctor", "--dry-run"} + + script := &poppyScript{} + run := h.runHintMode(t, script, args) + + require.NoError(t, run.err) + assert.Empty(t, script.asked, "the file hints every scope") + }) + + t.Run("the reserved sign-in scope is never asked", func(t *testing.T) { + run, _ := runHintFlow(t, make([]any, 6), "--dry-run") + require.NoError(t, run.err) + + assert.Contains(t, strings.Join(run.asked, "|"), "Set them now?", "two scopes are asked about, not three") + assert.NotContains(t, run.out, "poppy:browser-sign-in: ") + }) +} + +func TestPoppySetupHintsCtrlC(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + args := freshSetupArgs(t, "--skip-doctor", "--guest-api-access", "--guest-all-scopes") + script := &poppyScript{answers: []any{nil, terminal.InterruptErr}} + + run := h.runHintMode(t, script, args) + + require.ErrorIs(t, run.err, terminal.InterruptErr) + assert.Contains(t, run.out, display.PoppyCancelled) + assert.NotContains(t, run.out, "Plan:", "nothing is planned after the user cancelled") + assert.Empty(t, h.calls, "nothing was changed") + assert.NoFileExists(t, outOf(args)) +} + +func TestPoppyConfigAsksTheHints(t *testing.T) { + newArgs := func(t *testing.T) ([]string, string) { + out := filepath.Join(t.TempDir(), "auth0-poppy.json") + + return []string{"config", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--api-url", poppyTestAPI + "=" + poppyTestAPI + "/openapi.json", "--out", out}, out + } + + t.Run("the answers go to the file and are listed", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + args, out := newArgs(t) + + run := h.runHintMode(t, &poppyScript{answers: []any{nil, 0, false, false, nil, nil}}, args) + + require.NoError(t, run.err) + + data, err := os.ReadFile(out) + require.NoError(t, err) + assert.Contains(t, string(data), `"orders:read": {`) + assert.Regexp(t, `"changes": "none",\s+"personal_data": false`, string(data)) + assert.Contains(t, run.out, "Your answers") + assert.Contains(t, run.out, "orders:read: none") + assert.Contains(t, run.out, "checkout: irreversible, personal data") + assert.Equal(t, 1, strings.Count(run.out, "Poppy config for shop.example"), "the title is printed once") + }) + + t.Run("skipping them names the flag", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + args, _ := newArgs(t) + + run := h.runHintMode(t, &poppyScript{answers: []any{false}}, args) + + require.NoError(t, run.err) + assert.Contains(t, run.out, "auth0 poppy config --hint =changes:,personal_data:") + assert.Contains(t, run.out, "to set the hints of 2 scopes.") + }) + + t.Run("Ctrl-C writes no file", func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + args, out := newArgs(t) + + run := h.runHintMode(t, &poppyScript{answers: []any{terminal.InterruptErr}}, args) + + require.ErrorIs(t, run.err, terminal.InterruptErr) + assert.Contains(t, run.out, display.PoppyCancelled) + assert.NoFileExists(t, out) + }) +} + +func TestPoppyScreenAskLine(t *testing.T) { + t.Run("an empty answer asks again, and the error goes with the block", func(t *testing.T) { + script := []string{"", " ", "https://api.shop.example"} + screen, out := newTestPoppyScreen(t, poppyAsker{ + text: func(*poppyScreen, string, string) (string, error) { + answer := script[0] + script = script[1:] + + return answer, nil + }, + }, true) + + answer, err := screen.askLine("API", "The API.", "API", "") + + require.NoError(t, err) + assert.Equal(t, "https://api.shop.example", answer) + assert.Equal(t, 2, strings.Count(out.String(), "A value is required.")) + assert.Regexp(t, `\r\x1b\[5A\x1b\[J$`, out.String(), "blank, title, reason and the two errors, all taken back") + }) + + t.Run("Ctrl-C says nothing was changed", func(t *testing.T) { + screen, out := newTestPoppyScreen(t, poppyAsker{ + text: func(*poppyScreen, string, string) (string, error) { return "", terminal.InterruptErr }, + }, true) + + _, err := screen.askLine("API", "The API.", "API", "") + + require.ErrorIs(t, err, terminal.InterruptErr) + assert.True(t, strings.HasSuffix(out.String(), display.PoppyCancelled+"\n")) + }) + + t.Run("an answer the question refuses ends the command", func(t *testing.T) { + screen, _ := newTestPoppyScreen(t, poppyAsker{ + text: func(*poppyScreen, string, string) (string, error) { return "https://shop.example/path", nil }, + }, false) + + var in setup.Inputs + + err := screen.askText(setup.RequiredQuestions(in)[0], &in) + + require.Error(t, err) + assert.Equal(t, "invalid_flag", errorReason(err)) + assert.Empty(t, in.CompanyDomain) + }) + + t.Run("an answer the question takes is stored", func(t *testing.T) { + screen, _ := newTestPoppyScreen(t, poppyAsker{ + text: func(*poppyScreen, string, string) (string, error) { return "Shop.Example", nil }, + }, false) + + var in setup.Inputs + + require.NoError(t, screen.askText(setup.RequiredQuestions(in)[0], &in)) + assert.Equal(t, "shop.example", in.CompanyDomain) + }) +} + +func TestPoppyScreenConfirm(t *testing.T) { + tests := []struct { + name string + answer bool + def bool + }{ + {name: "yes", answer: true}, + {name: "no", answer: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var gotDefault bool + + screen, out := newTestPoppyScreen(t, poppyAsker{ + confirm: func(_ *poppyScreen, _ string, def bool) (bool, error) { + gotDefault = def + + return test.answer, nil + }, + }, false) + + yes, err := screen.confirmAsk("Apply the plan", "It changes your tenant.", "Apply 3 changes?") + + require.NoError(t, err) + assert.Equal(t, test.answer, yes) + assert.False(t, gotDefault, "Enter is no") + assert.Contains(t, out.String(), "Apply the plan\nIt changes your tenant.\n") + }) + } +} + +func TestPoppyScreenLeftOnScreen(t *testing.T) { + tests := []struct { + name string + rows []string + want int + }{ + {name: "one row each", rows: []string{" Question", " one", " two"}, want: 3}, + {name: "a long row wraps", rows: []string{" Question", " " + strings.Repeat("x", 100)}, want: 3}, + {name: "none", want: 0}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + screen, out := newTestPoppyScreen(t, poppyAsker{}, true) + + screen.leftOnScreen(test.rows...) + + assert.Equal(t, test.want, screen.BlockRows()) + assert.Empty(t, out.String(), "counting writes nothing") + }) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index eb4c65fcf..520652d77 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -488,6 +488,7 @@ func commandRequiresAuthentication(invokedCommandName string) bool { "auth0 help", "auth0 login", "auth0 logout", + "auth0 poppy doctor", "auth0 tenants use", "auth0 tenants list", "auth0 agent skills install", @@ -627,6 +628,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(guardianCmd(cli)) rootCmd.AddCommand(experimentationCmd(cli)) rootCmd.AddCommand(universalPortalsCmd(cli)) + rootCmd.AddCommand(poppyCmd(cli)) rootCmd.AddCommand(docsCmd(cli)) rootCmd.AddCommand(commandsCmd(cli)) @@ -682,7 +684,10 @@ func renderErrorMessage(display *display.Renderer, err error) { []rune{unicode.ToUpper(rawErrorMessage[0])}, rawErrorMessage[1:]..., ), - ) + "." + ) + if !strings.HasSuffix(humanReadableErrorMessage, ".") { + humanReadableErrorMessage += "." + } display.Errorf(humanReadableErrorMessage) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index b91403ebc..4e577e8bb 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -495,3 +495,28 @@ func TestApplyAgentModeDefaults(t *testing.T) { assert.False(t, c.json) // JSON not forced because --csv was explicit. }) } + +func TestRenderErrorMessageEndsWithOnePeriod(t *testing.T) { + tests := []struct { + name string + err string + want string + }{ + {name: "adds the period", err: "something failed", want: "Something failed."}, + {name: "keeps a period that is there", err: "pass a web app with --app.", want: "Pass a web app with --app."}, + {name: "after a fix line", err: "app not found.\n FIX Create it first: auth0 apps create.", want: "App not found.\n FIX Create it first: auth0 apps create."}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var messages strings.Builder + renderer := display.NewRenderer() + renderer.MessageWriter = &messages + + renderErrorMessage(renderer, errors.New(test.err)) + + assert.Contains(t, messages.String(), test.want+"\n") + assert.NotContains(t, messages.String(), "..") + }) + } +} diff --git a/internal/display/poppy.go b/internal/display/poppy.go new file mode 100644 index 000000000..98a65ef0c --- /dev/null +++ b/internal/display/poppy.go @@ -0,0 +1,1242 @@ +package display + +import ( + "fmt" + "io" + "maps" + "slices" + "strings" + "unicode/utf8" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/poppy/config" + "github.com/auth0/auth0-cli/internal/poppy/doctor" + "github.com/auth0/auth0-cli/internal/poppy/setup" + "github.com/auth0/auth0-cli/internal/poppy/text" +) + +const ( + // The most characters of a value from the tenant that the text prints. + poppyMaxValueRunes = 300 + + // The width of the label column of the Checked block. + poppyCheckedLabelWidth = 10 + + // The width of the label column of the answers block. + poppyAnswerLabelWidth = 14 + + // The most the column of the changes is wide before a note moves below. + poppyMaxChangeColumn = 24 + + // The indent of the lines that go under a labeled line. + poppyBelowIndent = " " + + poppyToApplyLabel = "To apply: " + poppyToRerunLabel = "To rerun: " + + // The most characters of a message that the text report of the doctor prints. + poppyMaxMessageRunes = 200 + + doctorIndent = " " + doctorFixLabel = "FIX " + doctorPlatformNote = " (Auth0 must fix this)" + doctorOtherGroup = "Other" + + // The most hint lines the answers block shows. Past it, only the first + // poppyMaxHintRowsShown are shown, then how many scopes are left out. + poppyMaxHintRows = 5 + poppyMaxHintRowsShown = 3 +) + +// PoppyHuman reports whether a Poppy command prints for a person: no JSON format +// is selected. +func (r *Renderer) PoppyHuman() bool { + return r.Format != OutputFormatJSON && r.Format != OutputFormatJSONCompact +} + +// PoppyJSON writes the result as JSON when a JSON output format is selected, and +// reports whether it did. +func (r *Renderer) PoppyJSON(result any) bool { + switch r.Format { + case OutputFormatJSONCompact: + r.JSONCompactResult(result) + case OutputFormatJSON: + r.JSONResult(result) + default: + return false + } + + return true +} + +// PoppyDeclined tells that the user said no to a question, under the text that +// came before it and after a blank line. +func (r *Renderer) PoppyDeclined(message string) { + _, _ = fmt.Fprintln(r.ResultWriter) + r.Infof("%s", message) +} + +// poppyWrite writes the text of a Poppy command, laid out for what the result +// writer is. +func (r *Renderer) poppyWrite(build func(l PoppyLayout) string) { + _, _ = io.WriteString(r.ResultWriter, build(PoppyLayoutOf(r.ResultWriter))) +} + +// poppyClean makes a value from the tenant or the user safe to print on one line. +func poppyClean(s string) string { + return text.Cap(text.Line(s), poppyMaxValueRunes) +} + +func poppyCleanAll(values []string) []string { + out := make([]string, len(values)) + for i, value := range values { + out[i] = poppyClean(value) + } + + return out +} + +func poppySortedKeys[V any](m map[string]V) []string { + return slices.Sorted(maps.Keys(m)) +} + +// PoppyChangeCount says how many changes there are, such as "3 changes". +func PoppyChangeCount(n int) string { + if n == 1 { + return "1 change" + } + + return fmt.Sprintf("%d changes", n) +} + +// poppyScopesNoun is "scope" for one and "scopes" otherwise. +func poppyScopesNoun(count int) string { + if count == 1 { + return "scope" + } + + return "scopes" +} + +// poppyNext lists the steps to take next, each a command and what it is for. +func poppyNext(l PoppyLayout, steps ...[2]string) string { + var b strings.Builder + + b.WriteString(ansi.Bold("Next") + "\n") + + for _, step := range steps { + b.WriteString(l.hang(" ", " ", step[0]+" "+step[1])) + } + + return b.String() +} + +// poppyHeader is the title of a Poppy run, with the tenant at the right edge. The +// plan starts with it, and so does a terminal run that asks questions before it. +func poppyHeader(l PoppyLayout, title, domain, tenant string) string { + if domain != "" { + title += " for " + poppyClean(domain) + } + + return l.header(title, "tenant "+poppyClean(tenant)) +} + +// The question screen. + +// PoppyCancelled is what a Poppy command says when the user ends a question with +// Ctrl-C. The questions come before anything is changed. +const PoppyCancelled = "Cancelled. Nothing was changed." + +// PoppyScreen is what a Poppy command shows while it asks questions in a +// terminal. It works like a wizard: a question draws its own block of lines, and +// the block is taken back once the answer is given, so only the question being +// answered is on the screen. The title of the run is printed once, above the +// first question. +type PoppyScreen struct { + out io.Writer + layout PoppyLayout + // Block holds the lines of the question on the screen. + block *poppyBlock + // Head holds the title line of the run. + head *poppyBlock + + title, domain, tenant string + // Shown tells that the title line is on the screen. Sticky tells that + // something else was printed below it, so it can no longer be taken back. + shown, sticky bool +} + +// NewPoppyScreen starts the screen of a command that writes to the result writer. +// The layout gives the width of the terminal, and erase tells whether the blocks +// can be taken back, see PoppyCanErase. The title of the run needs the company +// domain, see ForDomain. +func (r *Renderer) NewPoppyScreen(title, tenant string, l PoppyLayout, erase bool) *PoppyScreen { + w := r.ResultWriter + + return &PoppyScreen{ + out: w, + layout: l, + block: newPoppyBlock(w, l, erase), + head: newPoppyBlock(w, l, erase), + title: title, + tenant: tenant, + } +} + +// ForDomain says which company domain the run is for. The title is printed above +// the first question once it is known. +func (s *PoppyScreen) ForDomain(domain string) { s.domain = domain } + +// HeaderShown tells whether the title line of the run is on the screen, in which +// case the text that follows must not print it again. +func (s *PoppyScreen) HeaderShown() bool { return s.shown } + +// DropHeader takes the title line back, for a run that prints no text after its +// questions. It does nothing once something else was printed below it. +func (s *PoppyScreen) DropHeader() { + if !s.shown || s.sticky { + return + } + + _ = s.head.erase() + s.shown = false +} + +// Printed tells the screen that text was printed below the title of the run, such +// as the plan, which comes with the title when no question was asked first. From +// here the title stays, and a question goes below the text. +func (s *PoppyScreen) Printed() { s.shown, s.sticky = true, true } + +// Note prints a line that stays on the screen, such as a decision that was made +// for the user. +func (s *PoppyScreen) Note(format string, args ...any) { + s.sticky = true + + _, _ = io.WriteString(s.out, s.layout.entries([]poppyEntry{{mark: markWarning, value: text.Line(fmt.Sprintf(format, args...))}})) +} + +// Begin draws a block with a title and a reason, below the title of the run, which +// it prints first when it is not on the screen yet. +func (s *PoppyScreen) Begin(title, reason string) { + if !s.shown && s.domain != "" { + s.shown = true + + _, _ = io.WriteString(s.head, poppyHeader(s.layout, s.title, s.domain, s.tenant)) + } + + s.say("\n") + s.lines(ansi.Bold, title) + s.lines(ansi.Faint, reason) +} + +// End takes the block back that Begin drew. +func (s *PoppyScreen) End() error { return s.block.erase() } + +// Cancelled tells that the user ended the questions with Ctrl-C. +func (s *PoppyScreen) Cancelled() { + _, _ = fmt.Fprintln(s.out, PoppyCancelled) +} + +// say writes to the block. +func (s *PoppyScreen) say(line string) { + _, _ = io.WriteString(s.block, line) +} + +// lines writes a text to the block, wrapped, in a style. An empty text writes +// nothing. +func (s *PoppyScreen) lines(style func(string) string, line string) { + if line == "" { + return + } + + for _, wrapped := range s.layout.wrap(text.Line(line), 0) { + s.say(style(wrapped) + "\n") + } +} + +// Answered writes a line to the block that tells an answer to a question of the +// group, so that it stays in view while the next question is asked. +func (s *PoppyScreen) Answered(question, answer string) { + s.say(s.layout.entries([]poppyEntry{{mark: markDone, label: text.Line(question), value: text.Line(answer)}})) +} + +// Invalid writes a line to the block that says an answer was not accepted. +func (s *PoppyScreen) Invalid(reason string) { + s.say(s.layout.entries([]poppyEntry{{mark: markFailed, value: text.Line(reason)}})) +} + +// Rows is the number of screen rows that a line takes. +func (s *PoppyScreen) Rows(line string) int { return poppyLineRows(line, s.layout.Width) } + +// AddRows counts rows that a prompt left in the place of the block. +func (s *PoppyScreen) AddRows(rows int) { s.block.add(rows) } + +// BlockRows is the number of rows the block takes. +func (s *PoppyScreen) BlockRows() int { return s.block.rows } + +// PoppyListMarks is the width of what starts each row of a list to choose from +// several: the two spaces of the indent, the focus mark and the checkbox. +const PoppyListMarks = 8 + +// PoppyScopeChoice is a scope in a list to choose from. +type PoppyScopeChoice struct { + Name string + Description string + // Hints are the hints of the scope, nil when they are not known. + Hints *config.Hints +} + +// poppyHintNote says what the hints of a scope are, for a list of scopes: what the +// scope changes and whether it touches personal data. +func poppyHintNote(hints *config.Hints) string { + if hints == nil { + return "" + } + + note := "[changes: " + text.Line(hints.Changes) + if hints.PersonalData { + note += ", personal data" + } + + return note + "]" +} + +// ScopeRows lays out the scopes of a list to choose from, one row each: the name, +// the description in a column, and the hints when they are known. A description +// that does not fit the width of the terminal is cut with an ellipsis, so no row +// wraps. +func (s *PoppyScreen) ScopeRows(scopes []PoppyScopeChoice) []string { + nameWidth := 0 + for _, scope := range scopes { + nameWidth = max(nameWidth, utf8.RuneCountInString(text.Line(scope.Name))) + } + + // The room taken by the focus mark, the checkbox and the gaps. + room := s.layout.lineWidth() - PoppyListMarks - nameWidth - 2 + + rows := make([]string, len(scopes)) + + for i, scope := range scopes { + name := text.Line(scope.Name) + note := poppyHintNote(scope.Hints) + + description := text.Line(scope.Description) + if note != "" { + description = text.Cap(description, max(room-utf8.RuneCountInString(note)-1, poppyMinWidth/2)) + " " + note + } else { + description = text.Cap(description, max(room, poppyMinWidth/2)) + } + + rows[i] = name + strings.Repeat(" ", nameWidth-utf8.RuneCountInString(name)+2) + description + } + + return rows +} + +// The doctor. + +// PoppyDoctor writes the report of the doctor checks: as JSON when a JSON format +// is selected, else as text. +func (r *Renderer) PoppyDoctor(report doctor.Report) { + if r.PoppyJSON(report) { + return + } + + r.poppyWrite(func(l PoppyLayout) string { return doctorText(l, report) }) +} + +// doctorGroups lists the groups of the text report in the order they are printed, +// with the check IDs of each. A check that is in no group is printed under the +// last group, "Other". +var doctorGroups = []struct { + name string + ids []string +}{ + {"Website files", []string{"D1", "D2", "D17"}}, + {"Auth0 issuer", []string{"D3", "D4"}}, + {"Scopes", []string{"D5"}}, + {"Browser sign-in", []string{"D6", "D15", "D16"}}, + {"API", []string{"D7"}}, + {"Tenant", []string{"D8", "D9", "D10", "D11", "D12", "D13", "D14"}}, + {doctorOtherGroup, nil}, +} + +// doctorGroupOf returns the name of the group that prints the results of a check. +func doctorGroupOf(id string) string { + for _, g := range doctorGroups { + if slices.Contains(g.ids, id) { + return g.name + } + } + + return doctorOtherGroup +} + +// doctorMark returns the symbol of a level and what to print for it, colored when +// the output has colors. +func doctorMark(l PoppyLayout, level doctor.Level) (symbol, colored string) { + switch level { + case doctor.Pass: + return l.mark(markDone) + case doctor.Warn: + return "WARN", ansi.Yellow("WARN") + case doctor.Fail: + return "FAIL", ansi.Red("FAIL") + default: + return l.mark(markSkipped) + } +} + +// doctorText is the report as plain text: a header, the results in groups, and a +// summary line. The width and the symbols follow the layout: a terminal gets its +// width, and any other writer gets whole lines, which a script can search. +// Messages and fixes can hold text from the site or the tenant, so control +// characters are escaped and a message is kept to at most 200 characters. The +// JSON report is not changed: encoding/json already escapes it. +func doctorText(l PoppyLayout, r doctor.Report) string { + var b strings.Builder + + doctorHeader(&b, l, r) + doctorWriteGroups(&b, l, r, func(doctor.Result) bool { return true }) + + b.WriteByte('\n') + b.WriteString(r.Summary()) + b.WriteByte('\n') + + return b.String() +} + +// doctorProblems returns the warnings and failures of the report, with their +// fixes, in the groups of the text report, each line starting with the prefix. It +// is empty when nothing warned or failed. +func doctorProblems(l PoppyLayout, r doctor.Report, prefix string) string { + if l.Width > 0 { + l.Width = max(l.Width-len(prefix), poppyMinWidth) + } + + var b strings.Builder + doctorWriteGroups(&b, l, r, func(res doctor.Result) bool { return res.Level == doctor.Warn || res.Level == doctor.Fail }) + + if b.Len() == 0 { + return "" + } + + // The groups start with a blank line, which a block under a heading does not need. + lines := strings.Split(strings.TrimPrefix(b.String(), "\n"), "\n") + for i, line := range lines[:len(lines)-1] { + if line != "" { + lines[i] = prefix + line + } + } + + return strings.Join(lines, "\n") +} + +// doctorWriteGroups writes the results that keep accepts, under the heading of +// their group, each group after a blank line. +func doctorWriteGroups(b *strings.Builder, l PoppyLayout, r doctor.Report, keep func(doctor.Result) bool) { + for _, g := range doctorGroups { + var results []doctor.Result + for _, res := range r.Results { + if doctorGroupOf(res.ID) == g.name && keep(res) { + results = append(results, res) + } + } + if len(results) == 0 { + continue + } + + b.WriteByte('\n') + b.WriteString(ansi.Bold(g.name)) + b.WriteByte('\n') + for _, e := range doctorCollapse(results) { + doctorWriteResult(b, l, e) + } + } +} + +// doctorEntry is a result with the number of equal results it stands for. +type doctorEntry struct { + doctor.Result + count int +} + +// doctorCollapse merges runs of skipped results that give the same reason into +// one entry, so a login that is missing is said once, not for every check it +// stops. +func doctorCollapse(results []doctor.Result) []doctorEntry { + var entries []doctorEntry + + for _, res := range results { + if last := len(entries) - 1; last >= 0 && res.Level == doctor.Skipped && entries[last].Level == doctor.Skipped && + entries[last].Message == res.Message && entries[last].Fix == res.Fix && entries[last].PlatformDependency == res.PlatformDependency { + entries[last].count++ + + continue + } + + entries = append(entries, doctorEntry{Result: res, count: 1}) + } + + return entries +} + +// doctorHeader writes the domain with the protocol version at the right edge, +// then the issuer when it is known. +func doctorHeader(b *strings.Builder, l PoppyLayout, r doctor.Report) { + left := "Doctor for " + text.Cap(text.Line(r.Domain), poppyMaxMessageRunes) + b.WriteString(ansi.Bold(left)) + + if r.ProtocolVersion != "" { + right := "protocol " + text.Cap(text.Line(r.ProtocolVersion), poppyMaxMessageRunes) + gap := l.lineWidth() - utf8.RuneCountInString(left) - utf8.RuneCountInString(right) + b.WriteString(strings.Repeat(" ", max(gap, 1))) + b.WriteString(right) + } + b.WriteByte('\n') + + if r.Issuer != "" { + b.WriteString("Issuer " + text.Cap(text.Line(r.Issuer), poppyMaxMessageRunes) + "\n") + } +} + +// doctorWriteResult writes one result, its message wrapped under the symbol, and +// its fix under the message. +func doctorWriteResult(b *strings.Builder, l PoppyLayout, e doctorEntry) { + symbol, colored := doctorMark(l, e.Level) + message := text.Cap(text.Line(e.Message), poppyMaxMessageRunes) + if e.Level == doctor.Skipped { + label := "skipped" + if e.count > 1 { + label = fmt.Sprintf("skipped (%d checks)", e.count) + } + message = label + ": " + message + } + + pad := strings.Repeat(" ", len(doctorIndent)+utf8.RuneCountInString(symbol)+1) + lines := l.wrap(message, len(pad)) + + b.WriteString(doctorIndent + colored + " " + lines[0] + "\n") + for _, line := range lines[1:] { + b.WriteString(pad + line + "\n") + } + + if e.Fix == "" { + return + } + fix := text.Escape(e.Fix) + if e.PlatformDependency { + fix += doctorPlatformNote + } + b.WriteString(pad + doctorFixLabel) + b.WriteString(strings.ReplaceAll(fix, "\n", "\n"+pad+strings.Repeat(" ", len(doctorFixLabel)))) + b.WriteByte('\n') +} + +// The setup. + +// PoppySetupOptions says what the text of a setup is written for. +type PoppySetupOptions struct { + // Inputs are the flag values and the answers the plan was built from. They + // give the answers block and the command that applies the plan. + Inputs setup.Inputs + // DryRun adds the notice that nothing is changed and the command that applies + // the plan. + DryRun bool + // Command holds the flags of the command that the inputs do not. + Command setup.CommandOptions + // NoHeader leaves out the title line, which a run that asked questions has + // already printed above them. + NoHeader bool + // Unhinted is the number of scopes that were left without hints on purpose, + // which the next steps say how to set. + Unhinted int +} + +// PoppyPlan writes the plan for a person to read: what was checked, the answers +// given, then the changes grouped by target, tenant-wide ones first, each from its +// value now to its value after. The width follows the result writer. +func (r *Renderer) PoppyPlan(plan setup.Plan, opts PoppySetupOptions) { + r.poppyWrite(func(l PoppyLayout) string { return poppyPlanText(l, plan, opts) }) +} + +// PoppySetupFailure writes the steps of a setup that stopped partway: the ones +// made, the one that failed and the ones not run, and the command that continues. +func (r *Renderer) PoppySetupFailure(plan setup.Plan, outcome setup.Outcome, opts PoppySetupOptions) { + r.poppyWrite(func(l PoppyLayout) string { return "\n" + poppyApplyText(l, plan, outcome, opts) }) +} + +// PoppySetupResult writes a setup that went through: the changes made, what the +// doctor found, and what to do next. The report is nil when the doctor did not +// run. +func (r *Renderer) PoppySetupResult(plan setup.Plan, report *doctor.Report, opts PoppySetupOptions) { + r.poppyWrite(func(l PoppyLayout) string { + var b strings.Builder + + if !plan.Empty() { + b.WriteString("\n" + poppyApplyText(l, plan, setup.Outcome{Done: len(plan.Changes)}, opts)) + } + + if report != nil { + b.WriteString("\n" + poppyDoctorAfterSetup(l, *report)) + } + + b.WriteString("\n" + poppySetupNext(l, opts.Inputs.CompanyDomain, opts.Inputs.Out, opts.Unhinted)) + + return b.String() + }) +} + +// poppyDoctorAfterSetup is what setup says about the doctor checks: the counts, +// then only the warnings and failures. A failed check does not fail setup: the +// website may not serve the config file until the SDK is deployed. +func poppyDoctorAfterSetup(l PoppyLayout, report doctor.Report) string { + var b strings.Builder + + b.WriteString(ansi.Bold("Checking your setup (auth0 poppy doctor)") + "\n") + b.WriteString(" " + report.Summary() + "\n") + + if problems := doctorProblems(l, report, " "); problems != "" { + b.WriteString("\n" + problems) + } + + b.WriteString("\n") + + if failed := report.Count(doctor.Fail); failed > 0 { + noun := "checks" + if failed == 1 { + noun = "check" + } + + line := fmt.Sprintf("%d %s failed. Deploy the config file with your app and run `auth0 poppy doctor %s` again.", failed, noun, text.Line(report.Domain)) + b.WriteString(l.hang(" ", " ", line)) + } + + b.WriteString(l.hang(" ", " ", "Run auth0 poppy doctor "+text.Line(report.Domain)+" to see every check.")) + + return b.String() +} + +// poppyPlanText is the text of a plan for a layout. +func poppyPlanText(l PoppyLayout, p setup.Plan, opts PoppySetupOptions) string { + var b strings.Builder + + if !opts.NoHeader { + b.WriteString(poppyHeader(l, "Poppy setup", p.CompanyDomain, p.Tenant)) + } + + if p.Empty() { + b.WriteString("\n") + b.WriteString(poppyChecked(l, p, opts.Inputs)) + b.WriteString("\nNothing to change. Everything already matches.\n") + + return b.String() + } + + if opts.DryRun { + b.WriteString("Dry run: nothing will be changed.\n") + } + + b.WriteString("\n") + b.WriteString(poppyChecked(l, p, opts.Inputs)) + b.WriteString("\n") + b.WriteString(poppyAnswers(l, p, opts.Inputs)) + + b.WriteString("\n" + poppySummary(p) + "\n") + + for _, group := range poppyGroups(l, p) { + b.WriteString("\n") + b.WriteString(group) + } + + if len(p.Unchanged) > 0 { + noun := "settings" + if len(p.Unchanged) == 1 { + noun = "setting" + } + + line := fmt.Sprintf("Already correct: %d %s (%s)", len(p.Unchanged), noun, strings.Join(poppyCleanAll(p.Unchanged), ", ")) + b.WriteString("\n") + b.WriteString(poppyHangAfter(l, line, " ")) + } + + if opts.DryRun { + b.WriteString("\n" + poppyRerun(l, poppyToApplyLabel, opts) + "\n") + } + + return b.String() +} + +// poppyRerun returns the command that applies the plan after a label, wrapped to +// the layout. +func poppyRerun(l PoppyLayout, label string, opts PoppySetupOptions) string { + return setup.FormatCommand(label, setup.Command(opts.Inputs, opts.Command), l.Width) +} + +// poppyHangAfter wraps s, indenting the lines after the first. +func poppyHangAfter(l PoppyLayout, s, indent string) string { + lines := l.wrap(s, len(indent)) + + var b strings.Builder + b.WriteString(lines[0] + "\n") + for _, line := range lines[1:] { + b.WriteString(indent + line + "\n") + } + + return b.String() +} + +// poppySummary says how many changes there are, and how many of them reach every +// app. +func poppySummary(p setup.Plan) string { + total, wide := p.Count(), 0 + for _, change := range p.Changes { + if change.Impact == setup.ImpactTenantWide { + wide += change.Count() + } + } + + line := "Plan: " + PoppyChangeCount(total) + switch { + case wide == 1: + line += ", 1 of them affects every app on the tenant" + case wide > 1: + line += fmt.Sprintf(", %d of them affect every app on the tenant", wide) + } + + return line +} + +// poppyLabeled is a line of a block: a mark and a label, then a value that wraps +// in its own column. +type poppyLabeled struct { + mark poppyMark + label string + value string + // Rest are lines under the value, that sit in its column. + rest []string + // Below are lines under the whole line, indented a little, for a text that is + // too long for the column of the value. + below []string +} + +// poppyWriteLabeled writes the lines of a block, labels padded to one width. +func poppyWriteLabeled(b *strings.Builder, l PoppyLayout, labelWidth int, lines []poppyLabeled) { + for _, line := range lines { + symbol, colored := l.mark(line.mark) + pad := strings.Repeat(" ", max(l.markWidth()-utf8.RuneCountInString(symbol), 0)) + label := line.label + strings.Repeat(" ", max(labelWidth-utf8.RuneCountInString(line.label), 0)) + + plain := " " + symbol + pad + " " + label + b.WriteString(l.hang(plain, " "+colored+pad+" "+label, line.value)) + + indent := strings.Repeat(" ", utf8.RuneCountInString(plain)) + for _, rest := range line.rest { + for _, wrapped := range l.wrap(rest, len(indent)) { + b.WriteString(indent + wrapped + "\n") + } + } + + for _, below := range line.below { + for _, wrapped := range l.wrap(below, len(poppyBelowIndent)) { + b.WriteString(poppyBelowIndent + wrapped + "\n") + } + } + } +} + +// poppyChecked is the block of what the planner looked at: the API, the web app, +// the scopes, and each warning. +func poppyChecked(l PoppyLayout, p setup.Plan, in setup.Inputs) string { + var lines []poppyLabeled + + for _, api := range p.APIs { + lines = append(lines, poppyLabeled{mark: markDone, label: "API", value: poppyClean(api)}) + } + + if p.App != "" { + party := "first party" + if !p.AppFirstParty { + party = "third party" + } + lines = append(lines, poppyLabeled{mark: markDone, label: "Web app", value: poppyClean(p.App) + " (" + party + ")"}) + } + + scopes := "all described" + if names := poppySortedKeys(in.Scopes); len(names) > 0 { + scopes += " (" + strings.Join(poppyCleanAll(names), ", ") + " from your --scope)" + } + lines = append(lines, poppyLabeled{mark: markDone, label: "Scopes", value: scopes}) + + for i, warning := range p.Warnings { + switch p.WarningKind(i) { + case setup.WarningIssuer: + lines = append(lines, poppyLabeled{mark: markWarning, label: "Issuer", value: poppyClean(p.Issuer), rest: []string{poppyClean(warning)}}) + case setup.WarningAPI: + lines = append(lines, poppyLabeled{mark: markWarning, label: "API", value: poppyClean(warning)}) + default: + lines = append(lines, poppyLabeled{mark: markWarning, label: "Note", value: poppyClean(warning)}) + } + } + + var b strings.Builder + b.WriteString(ansi.Bold("Checked") + "\n") + poppyWriteLabeled(&b, l, poppyCheckedLabelWidth, lines) + + return b.String() +} + +// poppyAnswerRow is a line of the answers block: a label, a value, and lines +// under the value that sit in its column. +type poppyAnswerRow struct { + label string + value string + more []string +} + +// poppyAnswers is the block of what this run was asked or given. +func poppyAnswers(l PoppyLayout, p setup.Plan, in setup.Inputs) string { + var rows []poppyAnswerRow + + for i, identifier := range poppySortedKeys(in.APIURLs) { + label, value := "", poppyClean(in.APIURLs[identifier]) + if i == 0 { + label = "OpenAPI URL" + } + if len(in.APIURLs) > 1 { + value += " (" + poppyClean(identifier) + ")" + } + + rows = append(rows, poppyAnswerRow{label: label, value: value}) + } + + if in.LoginURI != "" { + rows = append(rows, poppyAnswerRow{label: "Login route", value: poppyClean(in.LoginURI)}) + } + + rows = append(rows, poppyGuestAnswers(p, in)...) + rows = append(rows, poppyHintAnswers(in)...) + + return poppyAnswersText(l, rows) +} + +// poppyHintsText is the answers block of a run that has no plan, with only the +// hints. It is empty when there are no hints. +func poppyHintsText(l PoppyLayout, in setup.Inputs) string { + rows := poppyHintAnswers(in) + if len(rows) == 0 { + return "" + } + + return poppyAnswersText(l, rows) +} + +func poppyAnswersText(l PoppyLayout, rows []poppyAnswerRow) string { + var b strings.Builder + b.WriteString(ansi.Bold("Your answers") + "\n") + + for _, row := range rows { + label := " " + row.label + strings.Repeat(" ", max(poppyAnswerLabelWidth-utf8.RuneCountInString(row.label), 0)) + b.WriteString(l.hang(label, label, row.value)) + + indent := strings.Repeat(" ", utf8.RuneCountInString(label)) + for _, more := range row.more { + b.WriteString(l.hang(indent, indent, more)) + } + } + + return b.String() +} + +// poppyHintAnswers lists the hints, one line for each scope, the ones that change +// nothing first and the ones that cannot be undone last. With many, it shows the +// first few and counts the rest. +func poppyHintAnswers(in setup.Inputs) []poppyAnswerRow { + var lines []string + + for _, changes := range config.ValidChanges { + for _, name := range poppySortedKeys(in.Hints) { + hint := in.Hints[name] + if hint.Changes != changes { + continue + } + + line := poppyClean(name) + ": " + changes + if hint.PersonalData { + line += ", personal data" + } + + lines = append(lines, line) + } + } + + if len(lines) == 0 { + return nil + } + + if len(lines) > poppyMaxHintRows { + more := fmt.Sprintf("(%d more scopes not shown here)", len(lines)-poppyMaxHintRowsShown) + lines = append(lines[:poppyMaxHintRowsShown], more) + } + + return []poppyAnswerRow{{label: "Hints", value: lines[0], more: lines[1:]}} +} + +// poppyGuestAnswers says what the run decided about the guest grant: the scopes of +// the grant to make on each API, or that there is none. +func poppyGuestAnswers(p setup.Plan, in setup.Inputs) []poppyAnswerRow { + if !in.GuestAPIAccess { + return []poppyAnswerRow{{label: "Guest access", value: "none"}} + } + + var rows []poppyAnswerRow + + for _, change := range p.Changes { + if change.Kind != setup.KindGrant { + continue + } + + scopes := poppyCleanAll(change.GrantScopes()) + + rows = append(rows, poppyAnswerRow{ + value: fmt.Sprintf("%d %s on %s", len(scopes), poppyScopesNoun(len(scopes)), poppyClean(change.Target())), + more: []string{strings.Join(scopes, ", ")}, + }) + } + + if len(rows) == 0 { + return []poppyAnswerRow{{label: "Guest access", value: "already set up"}} + } + + rows[0].label = "Guest access" + + return rows +} + +// poppyRow is a line of a group of changes. +type poppyRow struct { + mark poppyMark + label string + change string + note string +} + +// poppyGroups renders the changes, grouped by target, in the order of risk: the +// tenant, each API, the app, the guest grants and the file. +func poppyGroups(l PoppyLayout, p setup.Plan) []string { + var tenant, apis, apps, grants, files []string + + var grantRows []poppyRow + + for _, change := range p.Changes { + switch change.Kind { + case setup.KindTenant: + tenant = append(tenant, poppyGroup(l, "Tenant settings", poppyTenantNote(l, change), poppyChangeRows(l, change))) + case setup.KindAPI: + apis = append(apis, poppyGroup(l, "API "+poppyClean(change.Target()), "", poppyChangeRows(l, change))) + case setup.KindApp: + apps = append(apps, poppyGroup(l, "App "+poppyClean(p.App), "", poppyChangeRows(l, change))) + case setup.KindGrant: + grantRows = append(grantRows, poppyChangeRows(l, change)...) + case setup.KindFile: + rows := poppyChangeRows(l, change) + if !p.NewFile { + rows[0].mark, rows[0].change = markChanged, "updated" + } + files = append(files, poppyGroup(l, "File", "", rows)) + } + } + + if len(grantRows) > 0 { + grants = append(grants, poppyGroup(l, "Guest grant", "", grantRows)) + } + + var groups []string + for _, kind := range [][]string{tenant, apis, apps, grants, files} { + groups = append(groups, kind...) + } + + return groups +} + +// poppyTenantNote is what the heading of the tenant settings says about who is +// affected. +func poppyTenantNote(l PoppyLayout, c setup.Change) string { + if c.Impact != setup.ImpactTenantWide { + return "" + } + + return l.alert() + " affects all apps" +} + +// poppyGroup renders a heading and its rows. A note sits at the right edge of the +// heading. +func poppyGroup(l PoppyLayout, heading, note string, rows []poppyRow) string { + var b strings.Builder + + b.WriteString(ansi.Bold(heading)) + if note != "" { + gap := l.lineWidth() - utf8.RuneCountInString(heading) - utf8.RuneCountInString(note) + b.WriteString(strings.Repeat(" ", max(gap, 2)) + ansi.Yellow(note)) + } + b.WriteString("\n") + + poppyWriteRows(&b, l, rows) + + return b.String() +} + +// poppyWriteRows writes the rows of a group with the labels and the changes in +// columns. A note goes after the change when it fits, else under it. +func poppyWriteRows(b *strings.Builder, l PoppyLayout, rows []poppyRow) { + labelWidth, changeWidth := 0, 0 + for _, r := range rows { + labelWidth = max(labelWidth, utf8.RuneCountInString(r.label)) + changeWidth = max(changeWidth, min(utf8.RuneCountInString(r.change), poppyMaxChangeColumn)) + } + changeWidth += 2 + + for _, r := range rows { + symbol, colored := l.mark(r.mark) + pad := strings.Repeat(" ", max(l.markWidth()-utf8.RuneCountInString(symbol), 0)) + label := r.label + strings.Repeat(" ", labelWidth-utf8.RuneCountInString(r.label)+2) + + plain := " " + symbol + pad + " " + label + indent := strings.Repeat(" ", utf8.RuneCountInString(plain)) + lines := l.wrap(r.change, len(indent)) + + last := lines[len(lines)-1] + sameLine := r.note != "" && len(lines) == 1 && utf8.RuneCountInString(last) <= changeWidth-2 && + (l.Width == 0 || len(indent)+changeWidth+utf8.RuneCountInString(r.note) <= l.Width) + + for i, line := range lines { + prefix := indent + if i == 0 { + prefix = " " + colored + pad + " " + label + } + + if i == len(lines)-1 && sameLine { + line += strings.Repeat(" ", changeWidth-utf8.RuneCountInString(line)) + ansi.Faint(r.note) + } + + b.WriteString(prefix + line + "\n") + } + + if r.note != "" && !sameLine { + column := indent + if l.Width == 0 || len(indent)+changeWidth+utf8.RuneCountInString(r.note) <= l.Width { + column += strings.Repeat(" ", changeWidth) + } + + b.WriteString(column + ansi.Faint(r.note) + "\n") + } + } +} + +// poppyShown is a value of a field as the text prints it. +func poppyShown(f setup.Field, value string) string { + switch { + case f.IsToggle() && value == "true": + return "on" + case f.IsToggle() && value == "false": + return "off" + case value == "": + return "?" + } + + return poppyClean(value) +} + +// poppyBeforeAfter is the change of a field: the value before, an arrow, the value +// after. +func poppyBeforeAfter(l PoppyLayout, f setup.Field) string { + return poppyShown(f, f.Before) + " " + l.arrow() + " " + poppyShown(f, f.Value) +} + +// poppyChangeRows turns a change into the rows of its group. +func poppyChangeRows(l PoppyLayout, c setup.Change) []poppyRow { + switch c.Kind { + case setup.KindGrant: + return []poppyRow{{mark: markAdded, label: poppyClean(c.Target()), change: "scopes: " + strings.Join(poppyCleanAll(c.GrantScopes()), ", ")}} + case setup.KindFile: + return []poppyRow{{mark: markAdded, label: poppyClean(strings.TrimPrefix(c.Description, "write ")), change: "new"}} + } + + var rows []poppyRow + + for _, scope := range c.ScopeChanges { + description := `"` + poppyClean(scope.Description) + `"` + if scope.New { + rows = append(rows, poppyRow{mark: markAdded, label: "scope " + poppyClean(scope.Name), change: description}) + + continue + } + + rows = append(rows, poppyRow{mark: markChanged, label: "scope " + poppyClean(scope.Name), change: setup.ValueNone + " " + l.arrow() + " " + description}) + } + + for _, field := range c.Settings() { + label, ok := field.Label() + if !ok { + label = poppyClean(field.Name) + } + + if field.Name == setup.FieldCompanyDomains { + domains := strings.Split(field.Value, ", ") + rows = append(rows, poppyRow{mark: markAdded, label: label, change: poppyClean(domains[len(domains)-1]), note: field.Effect}) + + continue + } + + rows = append(rows, poppyRow{mark: markChanged, label: label, change: poppyBeforeAfter(l, field), note: poppyClean(field.Effect)}) + } + + return rows +} + +// poppyStepLabel names a change in the list of steps. The name of the API is added +// when the plan has several, and the guest grant, one for each API, always names +// its API. +func poppyStepLabel(p setup.Plan, c setup.Change) string { + switch c.Kind { + case setup.KindTenant: + return "Tenant settings" + case setup.KindAPI: + if len(p.APIs) > 1 { + return "API " + poppyClean(c.Target()) + } + + return "API" + case setup.KindApp: + return "App " + poppyClean(p.App) + case setup.KindGrant: + return "Guest grant " + poppyClean(c.Target()) + default: + return poppyClean(strings.TrimPrefix(c.Description, "write ")) + } +} + +// poppyApplyText renders the result of applying the plan: each step with a mark, +// the ones done, the one that failed with its error, and the ones not run. A +// failed apply ends with the command that continues it. +func poppyApplyText(l PoppyLayout, p setup.Plan, o setup.Outcome, opts PoppySetupOptions) string { + var b strings.Builder + + b.WriteString(ansi.Bold("Applying "+PoppyChangeCount(p.Count())) + "\n") + + labelWidth := 0 + for _, change := range p.Changes { + labelWidth = max(labelWidth, utf8.RuneCountInString(poppyStepLabel(p, change))) + } + + lines := make([]poppyLabeled, len(p.Changes)) + for i, change := range p.Changes { + lines[i] = poppyLabeled{label: poppyStepLabel(p, change)} + + switch { + case i < o.Done: + lines[i].mark = markDone + lines[i].value = PoppyChangeCount(change.Count()) + if change.Kind == setup.KindFile { + lines[i].value = "written" + } + case i == o.Done && o.Failure != nil: + lines[i].mark = markFailed + lines[i].value = "failed" + lines[i].below = []string{poppyClean(o.Failure.Error())} + default: + lines[i].mark = markSkipped + lines[i].value = "not run" + } + } + + poppyWriteLabeled(&b, l, labelWidth+3, lines) + + if o.Failure != nil { + b.WriteString("\nRunning the command again continues from here.\n") + b.WriteString(poppyRerun(l, poppyToRerunLabel, opts) + "\n") + } + + return b.String() +} + +// poppySetupNext lists what to do after a setup that went through. The first step +// is to deploy the config file, so it names the file. When scopes were left +// without hints, a step says how to set them. +func poppySetupNext(l PoppyLayout, domain, out string, unhinted int) string { + if out == "" { + out = config.DefaultFileName + } + + steps := [][2]string{{"Deploy " + poppyClean(out) + " with your web and API SDKs.", ""}} + + if unhinted > 0 { + steps = append(steps, [2]string{ + fmt.Sprintf("Set the hints of %d %s with --hint =changes:,personal_data:.", unhinted, poppyScopesNoun(unhinted)), + "Agents show them before asking for approval.", + }) + } + + steps = append(steps, + [2]string{"auth0 poppy doctor " + poppyClean(domain), "to check the live site."}, + [2]string{"auth0 poppy agents register ", "to allow an agent."}, + ) + + var b strings.Builder + + b.WriteString(ansi.Bold("Next") + "\n") + + for i, step := range steps { + line := step[0] + if step[1] != "" { + line += " " + step[1] + } + + prefix := " " + string(rune('1'+i)) + ". " + b.WriteString(l.hang(prefix, prefix, line)) + } + + return b.String() +} + +// The config. + +// PoppyConfigResult writes what the config command did to the file of the inputs: +// that it was written or left as it was, and what to do next. A run that asked +// questions has printed its title, and its answers about hints stay above the +// result; headerShown tells that. Unhinted is the number of scopes that were left +// without hints on purpose. +func (r *Renderer) PoppyConfigResult(in setup.Inputs, unchanged, headerShown bool, unhinted int) { + r.poppyWrite(func(l PoppyLayout) string { return poppyConfigText(l, in, unchanged, headerShown, unhinted) }) +} + +func poppyConfigText(l PoppyLayout, in setup.Inputs, unchanged, headerShown bool, unhinted int) string { + result := "written" + if unchanged { + result = "unchanged" + } + + next := [][2]string{{"auth0 poppy doctor " + text.Quote(text.Line(in.CompanyDomain)), "to check the live site."}} + if unhinted > 0 { + next = append(next, [2]string{ + "auth0 poppy config --hint =changes:,personal_data:", + fmt.Sprintf("to set the hints of %d %s.", unhinted, poppyScopesNoun(unhinted)), + }) + } + + var b strings.Builder + + // The title of the run is above, once questions were asked. + if headerShown { + b.WriteString("\n") + + if answers := poppyHintsText(l, in); answers != "" { + b.WriteString(answers + "\n") + } + } + + b.WriteString(l.entries([]poppyEntry{{mark: markDone, label: text.Line(in.Out), value: result}}) + "\n") + b.WriteString(poppyNext(l, next...)) + + return b.String() +} diff --git a/internal/display/poppy_agents.go b/internal/display/poppy_agents.go new file mode 100644 index 000000000..ba46be6d5 --- /dev/null +++ b/internal/display/poppy_agents.go @@ -0,0 +1,347 @@ +package display + +import ( + "cmp" + "fmt" + "slices" + "strings" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/poppy/agents" + "github.com/auth0/auth0-cli/internal/poppy/text" +) + +// poppyAgentView renders an agent as a row of the agents table. +type poppyAgentView struct { + agents.Agent +} + +func (v poppyAgentView) AsTableHeader() []string { + return []string{"AGENT ID", "NAME", "URL", "METADATA", "APIS"} +} + +func (v poppyAgentView) AsTableRow() []string { + return []string{ + ansi.Faint(cmp.Or(text.Line(v.AgentID), "-")), text.Line(v.Name), text.Line(v.URL), text.Line(v.MetadataStatus), + text.Line(describePoppyAPIs(v.APIs, ", ")), + } +} + +func (v poppyAgentView) Object() interface{} { return v.Agent } + +// poppyAgentDetailView renders one agent as key-value rows. +type poppyAgentDetailView struct { + *agents.Detail +} + +func (v poppyAgentDetailView) AsTableHeader() []string { return []string{} } + +func (v poppyAgentDetailView) AsTableRow() []string { return []string{} } + +func (v poppyAgentDetailView) Object() interface{} { return v.Detail } + +func (v poppyAgentDetailView) KeyValues() [][]string { + dpop := "unknown" + if v.DPoPRequired.Known { + dpop = map[bool]string{true: "required", false: "not required"}[v.DPoPRequired.Value] + } + + document := "unknown" + if v.MetadataCheck.Valid.Known { + document = "valid" + if !v.MetadataCheck.Valid.Value { + document = "invalid: " + text.Line(strings.Join(v.MetadataCheck.Violations, "; ")) + } + } + + keys := "unknown" + if v.KeysCurrent.Known { + keys = map[bool]string{true: "current", false: "changed since registration"}[v.KeysCurrent.Value] + } + + return [][]string{ + {"AGENT ID", ansi.Faint(cmp.Or(text.Line(v.AgentID), "-"))}, + {"NAME", text.Line(v.Name)}, + {"URL", text.Line(v.URL)}, + {"AGENT APP", text.Line(v.ClientID)}, + {"METADATA", text.Line(v.MetadataStatus)}, + {"DPOP", dpop}, + {"APIS", text.Escape(describePoppyAPIs(v.APIs, "\n"))}, + {"DOCUMENT", document}, + {"KEYS", keys}, + } +} + +func describePoppyAPIs(apis agents.Maybe[[]agents.Grant], separator string) string { + if !apis.Known { + return "unknown" + } + + if len(apis.Value) == 0 { + return "-" + } + + audiences := make([]string, len(apis.Value)) + for i, grant := range apis.Value { + audiences[i] = grant.Audience + } + + return strings.Join(audiences, separator) +} + +// PoppyAgentList writes the registered agents as a table, or says there are none. +func (r *Renderer) PoppyAgentList(list []agents.Agent) { + resource := "agents" + + r.Heading(fmt.Sprintf("%s (%d)", resource, len(list))) + + if len(list) == 0 { + r.EmptyState(resource, "Register one with: auth0 poppy agents register ") + return + } + + views := make([]View, len(list)) + for i, agent := range list { + views[i] = poppyAgentView{agent} + } + + r.Results(views) +} + +// PoppyAgentShow writes the details of an agent, then what could not be checked +// and the warning that its keys changed. +func (r *Renderer) PoppyAgentShow(detail *agents.Detail) { + r.Heading("agent") + r.Result(poppyAgentDetailView{detail}) + + // The notes and the key status are fields of the JSON, so they are not + // repeated on stderr. + if r.suppressDecorations() { + return + } + + for _, note := range detail.Notes { + r.Warnf("%s", text.Line(note)) + } + + if detail.KeysCurrent.Known && !detail.KeysCurrent.Value { + r.Warnf("The agent's keys changed since it was registered. Run `auth0 poppy agents register %s` again.", text.Line(detail.URL)) + } +} + +// PoppyAgentPreview writes the metadata of an agent that is about to be +// registered. +func (r *Renderer) PoppyAgentPreview(preview *agents.Preview) { + r.poppyWrite(func(l PoppyLayout) string { + entries := []poppyEntry{ + {mark: markPlain, label: "name", value: orDash(text.Line(preview.Name))}, + {mark: markPlain, label: "redirect URLs", value: orDash(text.Line(strings.Join(preview.RedirectURLs, ", ")))}, + {mark: markPlain, label: "keys", value: describePoppyKeys(preview.Keys)}, + } + + if preview.LogoURI != "" { + entries = append(entries, poppyEntry{mark: markPlain, label: "logo", value: text.Line(preview.LogoURI)}) + } + + for _, warning := range preview.Warnings { + entries = append(entries, poppyEntry{mark: markWarning, value: text.Line(warning)}) + } + + if preview.ClientID != "" { + entries = append(entries, poppyEntry{mark: markChanged, value: fmt.Sprintf("An agent app already exists for this URL (%s). It will be updated.", text.Line(preview.ClientID))}) + } + + return l.hang("Agent metadata ", ansi.Bold("Agent metadata")+" ", orDash(text.Line(preview.URL))) + l.entries(entries) + }) +} + +// orDash stands for a value that is empty. +func orDash(s string) string { return cmp.Or(s, "-") } + +func describePoppyKeys(keys []agents.Key) string { + algs := make([]string, len(keys)) + for i, key := range keys { + algs[i] = key.Alg + } + slices.Sort(algs) + + return fmt.Sprintf("%d (%s)", len(keys), strings.Join(slices.Compact(algs), ", ")) +} + +// PoppyRegisterPlan writes what registering an agent will do. The name is the one +// given for the agent record, which defaults to the name in its document. +func (r *Renderer) PoppyRegisterPlan(preview *agents.Preview, name string, requireDPoP bool, grants []agents.Grant) { + r.poppyWrite(func(l PoppyLayout) string { + app := poppyEntry{mark: markAdded, label: "Agent app", value: "create, for " + text.Line(preview.URL)} + if preview.ClientID != "" { + app = poppyEntry{mark: markChanged, label: "Agent app", value: "update, for " + text.Line(preview.URL)} + } + + entries := []poppyEntry{app} + + if requireDPoP { + entries = append(entries, poppyEntry{mark: markAdded, label: "DPoP", value: "required on the agent app"}) + } + + entries = append(entries, poppyEntry{mark: markAdded, label: "Agent record", value: fmt.Sprintf("%q", text.Line(cmp.Or(name, preview.Name)))}) + + for _, grant := range grants { + entries = append(entries, poppyEntry{ + mark: markAdded, + label: "Grant", + value: text.Line(grant.Audience) + ": " + text.Line(strings.Join(grant.Scopes, " ")) + " (skipped if the agent app already has a grant)", + }) + } + + return "\n" + ansi.Bold("This will") + "\n" + l.entries(entries) + }) +} + +// PoppyRegistration writes a registered agent. A grant the agent app already had +// with other scopes than the ones requested is followed by what differs, and the +// command that sets the scopes. +func (r *Renderer) PoppyRegistration(reg *agents.Registration) { + r.poppyWrite(func(l PoppyLayout) string { + app := poppyEntry{mark: markDone, label: "Agent app", value: "created " + text.Line(reg.ClientID)} + if !reg.AppCreated { + app = poppyEntry{mark: markChanged, label: "Agent app", value: "updated " + text.Line(reg.ClientID)} + } + + entries := []poppyEntry{app} + + if reg.DPoPRequired { + entries = append(entries, poppyEntry{mark: markDone, label: "DPoP", value: "required"}) + } + + record := poppyEntry{mark: markDone, label: "Agent record", value: "exists " + text.Line(reg.AgentID)} + if reg.AgentCreated { + record.value = fmt.Sprintf("created %s (in tokens as %s)", text.Line(reg.AgentID), text.Line(reg.URL)) + } + + entries = append(entries, record) + + var differences []poppyEntry + + for _, grant := range reg.Grants { + value := text.Line(grant.Audience) + ": " + text.Line(strings.Join(grant.Scopes, " ")) + if !grant.Created { + value += " (already granted)" + } + + entries = append(entries, poppyEntry{mark: markDone, label: "Grant", value: value}) + + if grant.Note != "" { + differences = append(differences, poppyEntry{mark: markWarning, value: text.Line(grant.Note)}) + } + + if grant.Fix != "" { + differences = append(differences, poppyEntry{mark: markPlain, label: "FIX", value: text.Line(grant.Fix)}) + } + } + + out := "\n" + l.entries(entries) + if len(differences) > 0 { + out += l.entries(differences) + } + + return out + "\n" + poppyNext(l, [2]string{"auth0 poppy agents show " + text.Quote(text.Line(reg.URL)), "to check the agent."}) + }) +} + +// PoppyRemovalStage says whether a removal is only described, about to be made, +// or done. +type PoppyRemovalStage int + +// The stages of a removal. +const ( + // PoppyRemovalWould is a dry run. + PoppyRemovalWould PoppyRemovalStage = iota + // PoppyRemovalWill is a removal that waits for approval. + PoppyRemovalWill + // PoppyRemovalDone is a removal that was made. + PoppyRemovalDone +) + +// PoppyRemoval writes what a removal removes or removed, what it keeps and how to +// remove the grants that remain. Once it is done, what was removed is marked as +// done. +func (r *Renderer) PoppyRemoval(stage PoppyRemovalStage, result *agents.RemoveResult) { + removed, kept := "This would remove:", "This would keep:" + + switch stage { + case PoppyRemovalWill: + removed, kept = "This will remove:", "This will keep:" + case PoppyRemovalDone: + removed, kept = "Removed:", "Kept:" + case PoppyRemovalWould: + } + + r.poppyWrite(func(l PoppyLayout) string { + var b strings.Builder + + list := func(heading string, items []string, mark poppyMark) { + if len(items) == 0 { + return + } + + entries := make([]poppyEntry, len(items)) + for i, item := range items { + entries[i] = poppyEntry{mark: mark, value: text.Line(item)} + } + + if b.Len() > 0 { + b.WriteString("\n") + } + + b.WriteString(ansi.Bold(heading) + "\n" + l.entries(entries)) + } + + removedMark, keptMark := markPlain, markPlain + if stage == PoppyRemovalDone { + removedMark, keptMark = markDone, markSkipped + } + + list(removed, result.Removed, removedMark) + list(kept, result.Kept, keptMark) + + if len(result.RemainingGrants) > 0 { + if b.Len() > 0 { + b.WriteString("\n") + } + + b.WriteString(ansi.Bold("Other grants of the agent app stay. Remove them with:") + "\n") + for _, grant := range result.RemainingGrants { + b.WriteString(l.hang(" ", " ", "auth0 client-grants delete "+text.Quote(text.Line(grant.ID))+" ("+text.Line(grant.Audience)+")")) + } + } + + return b.String() + }) +} + +// PoppyRemovalGone warns that no agent app was found for a reference, which means +// it may have been removed already. +func (r *Renderer) PoppyRemovalGone(ref string) { + r.poppyWrite(func(l PoppyLayout) string { + return l.entries([]poppyEntry{{mark: markWarning, value: "No agent app was found for " + text.Line(ref) + ". It may already be removed."}}) + "\n" + }) +} + +// PoppyRemoved writes a removal that was made, then what it does not do. The +// agent was gone when no app was found for it before. +func (r *Renderer) PoppyRemoved(result *agents.RemoveResult, gone bool) { + r.PoppyRemoval(PoppyRemovalDone, result) + + var notes []poppyEntry + if !gone { + notes = append(notes, poppyEntry{mark: markWarning, value: "This command does not revoke tokens already issued: access tokens stay valid until they expire."}) + } + + if len(result.Kept) == 0 && !gone { + notes = append(notes, poppyEntry{mark: markWarning, value: "Registering the agent again creates a new app, so its users must sign in again."}) + } + + if len(notes) > 0 { + r.poppyWrite(func(l PoppyLayout) string { return "\n" + l.entries(notes) }) + } +} diff --git a/internal/display/poppy_agents_test.go b/internal/display/poppy_agents_test.go new file mode 100644 index 000000000..30433e60d --- /dev/null +++ b/internal/display/poppy_agents_test.go @@ -0,0 +1,299 @@ +package display + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/poppy/agents" +) + +const ( + agentTestURL = "https://muse.example/agent.json" + agentTestAPI = "https://api.shop.example" + agentTestID = "agt_1" + agentTestApp = "cli_agent" + agentTestName = "Meta Muse" +) + +func newAgentRenderer(format OutputFormat) (*Renderer, *bytes.Buffer, *bytes.Buffer) { + var out, msg bytes.Buffer + + return &Renderer{ResultWriter: &out, MessageWriter: &msg, Format: format, Tenant: "tenant.example"}, &out, &msg +} + +func testAgent() agents.Agent { + return agents.Agent{ + AgentID: agentTestID, + Name: agentTestName, + URL: agentTestURL, + ClientID: agentTestApp, + MetadataStatus: "available", + DPoPRequired: agents.Maybe[bool]{Known: true, Value: true}, + APIs: agents.Maybe[[]agents.Grant]{Known: true, Value: []agents.Grant{{Audience: agentTestAPI, Scopes: []string{"orders:read"}}}}, + } +} + +func TestPoppyAgentViewsEscapeUntrustedText(t *testing.T) { + agent := agents.Agent{ + AgentID: agentTestID, + Name: "Muse\x1b[2J\n✓ forged", + URL: "https://muse.example/\x1b]0;x\x07agent.json", + ClientID: agentTestApp, + MetadataStatus: "available", + } + + row := poppyAgentView{agent}.AsTableRow() + assert.Equal(t, `Muse\x1b[2J ✓ forged`, row[1]) + assert.Equal(t, `https://muse.example/\x1b]0;x\x07agent.json`, row[2]) + + detail := poppyAgentDetailView{&agents.Detail{ + Agent: agent, + MetadataCheck: agents.MetadataCheck{Valid: agents.Maybe[bool]{Known: true}, Violations: []string{"redirect \x1b[31mbad"}}, + }} + got := map[string]string{} + for _, row := range detail.KeyValues() { + got[row[0]] = row[1] + } + assert.Equal(t, `Muse\x1b[2J ✓ forged`, got["NAME"]) + assert.Equal(t, `https://muse.example/\x1b]0;x\x07agent.json`, got["URL"]) + assert.Equal(t, `invalid: redirect \x1b[31mbad`, got["DOCUMENT"]) + + for _, row := range detail.KeyValues() { + assert.NotContains(t, row[1], "\x1b") + } +} + +func TestDescribePoppyKeys(t *testing.T) { + assert.Equal(t, "0 ()", describePoppyKeys(nil)) + assert.Equal(t, "3 (PS256, RS256)", describePoppyKeys([]agents.Key{{Alg: "RS256"}, {Alg: "PS256"}, {Alg: "RS256"}})) +} + +func TestPoppyAgentList(t *testing.T) { + setColors(t, false) + + list := []agents.Agent{testAgent()} + + t.Run("a table with one row for each agent", func(t *testing.T) { + r, out, _ := newAgentRenderer(OutputFormat("")) + r.PoppyAgentList(list) + + lines := bytes.Split(bytes.TrimSpace(out.Bytes()), []byte("\n")) + require.Len(t, lines, 2, "the header and the row") + assert.Regexp(t, `AGENT ID\s+NAME\s+URL\s+METADATA\s+APIS`, string(lines[0])) + assert.Regexp(t, agentTestID+`\s+`+agentTestName+`\s+`+agentTestURL+`\s+available\s+`+agentTestAPI, string(lines[1])) + }) + + t.Run("a heading with the count goes to the messages", func(t *testing.T) { + r, _, msg := newAgentRenderer(OutputFormat("")) + r.PoppyAgentList(list) + + assert.Contains(t, msg.String(), "agents (1)") + }) + + t.Run("json is the agents as they are", func(t *testing.T) { + r, out, msg := newAgentRenderer(OutputFormatJSON) + r.PoppyAgentList(list) + + want, err := json.MarshalIndent(list, "", " ") + require.NoError(t, err) + + assert.JSONEq(t, string(want), out.String()) + assert.Empty(t, msg.String(), "no decoration beside JSON") + }) + + t.Run("none says how to register one", func(t *testing.T) { + r, out, msg := newAgentRenderer(OutputFormat("")) + r.PoppyAgentList(nil) + + assert.Empty(t, out.String()) + assert.Contains(t, msg.String(), "No agents available. Register one with: auth0 poppy agents register ") + }) + + t.Run("none is an empty array in json", func(t *testing.T) { + r, out, _ := newAgentRenderer(OutputFormatJSON) + r.PoppyAgentList(nil) + + assert.JSONEq(t, "[]", out.String()) + }) +} + +func TestPoppyAgentShow(t *testing.T) { + setColors(t, false) + + detail := &agents.Detail{ + Agent: testAgent(), + MetadataCheck: agents.MetadataCheck{Valid: agents.Maybe[bool]{Known: true, Value: true}}, + KeysCurrent: agents.Maybe[bool]{Known: true}, + Notes: []string{"Could not read the grants.\nTry again."}, + } + + t.Run("key value rows, then the notes and the warning about keys", func(t *testing.T) { + r, out, msg := newAgentRenderer(OutputFormat("")) + r.PoppyAgentShow(detail) + + for label, value := range map[string]string{ + "AGENT ID": agentTestID, + "NAME": agentTestName, + "URL": agentTestURL, + "AGENT APP": agentTestApp, + "METADATA": "available", + "DPOP": "required", + "APIS": agentTestAPI, + "DOCUMENT": "valid", + "KEYS": "changed since registration", + } { + assert.Regexp(t, label+`\s+`+value, out.String()) + } + + assert.Contains(t, msg.String(), "Could not read the grants. Try again.") + assert.Contains(t, msg.String(), "Run `auth0 poppy agents register "+agentTestURL+"` again.") + }) + + t.Run("json is the detail as it is", func(t *testing.T) { + r, out, _ := newAgentRenderer(OutputFormatJSON) + r.PoppyAgentShow(detail) + + want, err := json.MarshalIndent(detail, "", " ") + require.NoError(t, err) + + assert.JSONEq(t, string(want), out.String()) + }) +} + +func TestPoppyAgentPreviewAndPlan(t *testing.T) { + setColors(t, false) + + preview := &agents.Preview{ + URL: agentTestURL, + Name: agentTestName, + RedirectURLs: []string{"https://muse.example/cb"}, + Keys: []agents.Key{{Alg: "PS256"}}, + Warnings: []string{"No logo."}, + ClientID: agentTestApp, + } + + r, out, _ := newAgentRenderer(OutputFormat("")) + r.PoppyAgentPreview(preview) + + assert.Contains(t, out.String(), "Agent metadata "+agentTestURL+"\n") + assert.Contains(t, out.String(), "name "+agentTestName+"\n") + assert.Contains(t, out.String(), "keys 1 (PS256)\n") + assert.Contains(t, out.String(), "! No logo.\n") + assert.Contains(t, out.String(), "An agent app already exists for this URL ("+agentTestApp+"). It will be updated.") + + out.Reset() + r.PoppyRegisterPlan(preview, "", true, []agents.Grant{{Audience: agentTestAPI, Scopes: []string{"orders:read"}}}) + + assert.Contains(t, out.String(), "\nThis will\n") + assert.Contains(t, out.String(), "~ Agent app update, for "+agentTestURL+"\n") + assert.Contains(t, out.String(), "+ DPoP required on the agent app\n") + assert.Contains(t, out.String(), `+ Agent record "`+agentTestName+`"`+"\n") + assert.Contains(t, out.String(), "+ Grant "+agentTestAPI+": orders:read (skipped if the agent app already has a grant)\n") +} + +func TestPoppyRegistration(t *testing.T) { + setColors(t, false) + + reg := &agents.Registration{ + URL: agentTestURL, + Name: agentTestName, + ClientID: agentTestApp, + AppCreated: true, + AgentID: agentTestID, + AgentCreated: true, + Grants: []agents.GrantResult{{Grant: agents.Grant{Audience: agentTestAPI, Scopes: []string{"orders:read"}}, Created: true}}, + } + + t.Run("what was made, and what to do next", func(t *testing.T) { + r, out, _ := newAgentRenderer(OutputFormat("")) + r.PoppyRegistration(reg) + + assert.Contains(t, out.String(), "ok Agent app created "+agentTestApp+"\n") + assert.Contains(t, out.String(), "ok Agent record created "+agentTestID+" (in tokens as "+agentTestURL+")\n") + assert.Contains(t, out.String(), "ok Grant "+agentTestAPI+": orders:read\n") + assert.Contains(t, out.String(), "Next\n auth0 poppy agents show "+agentTestURL+" to check the agent.\n") + assert.NotContains(t, out.String(), "FIX") + }) + + t.Run("a grant with other scopes is followed by what differs and the command that sets them", func(t *testing.T) { + differing := *reg + differing.AppCreated = false + differing.Grants = []agents.GrantResult{{ + Grant: agents.Grant{Audience: agentTestAPI, Scopes: []string{"orders:read"}}, + Created: false, + Note: "The grant has other scopes: checkout.", + Fix: "auth0 client-grants update cgr_1 --scopes orders:read", + }} + + r, out, _ := newAgentRenderer(OutputFormat("")) + r.PoppyRegistration(&differing) + + assert.Contains(t, out.String(), "~ Agent app updated "+agentTestApp+"\n") + assert.Contains(t, out.String(), "ok Grant "+agentTestAPI+": orders:read (already granted)\n") + assert.Contains(t, out.String(), "! The grant has other scopes: checkout.\n") + assert.Contains(t, out.String(), "FIX auth0 client-grants update cgr_1 --scopes orders:read\n") + assert.Less(t, bytes.Index(out.Bytes(), []byte("(already granted)")), bytes.Index(out.Bytes(), []byte("! The grant"))) + assert.Less(t, bytes.Index(out.Bytes(), []byte("FIX")), bytes.Index(out.Bytes(), []byte("Next"))) + }) +} + +func TestPoppyRemoval(t *testing.T) { + setColors(t, false) + + result := &agents.RemoveResult{ + URL: agentTestURL, + Removed: []string{"the grant of https://api.shop.example"}, + Kept: []string{"the agent app"}, + RemainingGrants: []agents.Grant{{ID: "cgr_9", Audience: "https://other.example"}}, + } + + tests := []struct { + name string + stage PoppyRemovalStage + want []string + }{ + {name: "dry run", stage: PoppyRemovalWould, want: []string{"This would remove:\n", "This would keep:\n"}}, + {name: "waiting for approval", stage: PoppyRemovalWill, want: []string{"This will remove:\n", "This will keep:\n"}}, + {name: "done", stage: PoppyRemovalDone, want: []string{"Removed:\n ok the grant of https://api.shop.example\n", "Kept:\n - the agent app\n"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r, out, _ := newAgentRenderer(OutputFormat("")) + r.PoppyRemoval(test.stage, result) + + for _, want := range test.want { + assert.Contains(t, out.String(), want) + } + + assert.Contains(t, out.String(), "Other grants of the agent app stay. Remove them with:\n auth0 client-grants delete cgr_9 (https://other.example)\n") + }) + } + + t.Run("a removal that was made says what it does not do", func(t *testing.T) { + r, out, _ := newAgentRenderer(OutputFormat("")) + r.PoppyRemoved(&agents.RemoveResult{Removed: []string{"the agent app"}}, false) + + assert.Contains(t, out.String(), "! This command does not revoke tokens already issued") + assert.Contains(t, out.String(), "! Registering the agent again creates a new app") + }) + + t.Run("an agent that was gone gets no such notes", func(t *testing.T) { + r, out, _ := newAgentRenderer(OutputFormat("")) + r.PoppyRemoved(&agents.RemoveResult{}, true) + + assert.NotContains(t, out.String(), "revoke") + assert.NotContains(t, out.String(), "Registering the agent again") + }) + + t.Run("no app found", func(t *testing.T) { + r, out, _ := newAgentRenderer(OutputFormat("")) + r.PoppyRemovalGone(agentTestURL) + + assert.Equal(t, " ! No agent app was found for "+agentTestURL+". It may already be removed.\n\n", out.String()) + }) +} diff --git a/internal/display/poppy_layout.go b/internal/display/poppy_layout.go new file mode 100644 index 000000000..aead908a7 --- /dev/null +++ b/internal/display/poppy_layout.go @@ -0,0 +1,324 @@ +package display + +import ( + "fmt" + "io" + "os" + "regexp" + "strings" + "unicode/utf8" + + "golang.org/x/term" + + "github.com/auth0/auth0-cli/internal/ansi" +) + +const ( + // PoppyDefaultWidth is the width of a terminal whose size is not known, and + // the width the right edge of a header is placed at when nothing is wrapped. + poppyDefaultWidth = 80 + + // PoppyMinWidth is the narrowest a wrapped text is made, however narrow the + // terminal is. + poppyMinWidth = 20 +) + +// PoppyLayout is how the text output of the Poppy commands is laid out for one +// writer. +type PoppyLayout struct { + // Width is the width of the terminal. Zero means the output is not a terminal, + // so nothing is wrapped and a script sees whole lines. + Width int + // Unicode allows the symbols that are not ASCII. Otherwise they have plain + // ASCII fallbacks. + Unicode bool +} + +// PoppyLayoutOf works out the layout for a writer. A terminal gets its width (80 +// columns when the size is not known) and the Unicode symbols. Any other writer +// is not wrapped, and gets the Unicode symbols only when colors are on. +func PoppyLayoutOf(w io.Writer) PoppyLayout { + l := PoppyLayout{Unicode: ansi.Bold("x") != "x"} + + f, ok := w.(interface{ Fd() uintptr }) + if !ok || !term.IsTerminal(int(f.Fd())) { + return l + } + + l.Unicode = true + l.Width = poppyDefaultWidth + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { + l.Width = width + } + + return l +} + +// PoppyCanErase tells whether text written to w can be taken back off the screen: +// w is a terminal, and its type is not the dumb one that ignores cursor movement. +// Whether colors are on does not matter. +func PoppyCanErase(w io.Writer) bool { + return PoppyLayoutOf(w).Width > 0 && os.Getenv("TERM") != "dumb" +} + +// lineWidth is the width to align things to the right edge at: the width of the +// terminal, else 80. +func (l PoppyLayout) lineWidth() int { + if l.Width > 0 { + return l.Width + } + + return poppyDefaultWidth +} + +// wrap breaks s into lines that fit the layout after an indent of the given +// width, on word boundaries. A text that fits, or any text when the layout does +// not wrap, stays one line as it is. It always returns at least one line. +func (l PoppyLayout) wrap(s string, indent int) []string { + width := max(l.Width-indent, poppyMinWidth) + + if l.Width <= 0 || utf8.RuneCountInString(s) <= width { + return []string{strings.TrimSpace(s)} + } + + return wrapWords(s, width) +} + +// wrapWords breaks s into lines of at most width characters on word boundaries. A +// word longer than a line is cut. It always returns at least one line. +func wrapWords(s string, width int) []string { + var ( + lines []string + line []rune + ) + for _, word := range strings.Fields(s) { + w := []rune(word) + + for len(w) > width { + if len(line) > 0 { + lines, line = append(lines, string(line)), nil + } + lines, w = append(lines, string(w[:width])), w[width:] + } + + switch { + case len(line) == 0: + line = w + case len(line)+1+len(w) <= width: + line = append(append(line, ' '), w...) + default: + lines, line = append(lines, string(line)), w + } + } + + return append(lines, string(line)) +} + +// hang writes s wrapped to the layout after a prefix: the first line starts with +// the prefix and the other lines are indented to the width of its plain text, so +// the text sits in a column. Colored may hold the prefix with color codes; plain +// is the same prefix without them. Each line ends with a newline. +func (l PoppyLayout) hang(plain, colored, s string) string { + pad := strings.Repeat(" ", utf8.RuneCountInString(plain)) + lines := l.wrap(s, utf8.RuneCountInString(plain)) + + var b strings.Builder + b.WriteString(colored + lines[0] + "\n") + for _, line := range lines[1:] { + b.WriteString(pad + line + "\n") + } + + return b.String() +} + +// header is a title in bold at the left edge and a note at the right edge, on one +// line that ends with a newline. +func (l PoppyLayout) header(left, right string) string { + gap := l.lineWidth() - utf8.RuneCountInString(left) - utf8.RuneCountInString(right) + + return ansi.Bold(left) + strings.Repeat(" ", max(gap, 1)) + right + "\n" +} + +// poppyMark is a symbol that starts a line of the output. +type poppyMark int + +// The symbols of the output. +const ( + // MarkDone is a step that is done, or a value that is already right. + markDone poppyMark = iota + // MarkChanged is a value that is changed. + markChanged + // MarkAdded is something that is added. + markAdded + // MarkWarning is something to look at. + markWarning + // MarkFailed is a step that failed. + markFailed + // MarkSkipped is a step that did not run. + markSkipped + // MarkPlain is a line without a symbol, kept in line with the ones that have one. + markPlain +) + +// mark returns the symbol of a mark, and what to print for it: colored when the +// output has colors. Without Unicode the symbol is plain ASCII. +func (l PoppyLayout) mark(m poppyMark) (symbol, colored string) { + switch m { + case markDone: + symbol = l.pick("✓", "ok") + + return symbol, ansi.Green(symbol) + case markChanged: + return "~", ansi.Yellow("~") + case markAdded: + return "+", ansi.Green("+") + case markWarning: + return "!", ansi.Yellow("!") + case markFailed: + symbol = l.pick("✗", "x") + + return symbol, ansi.Red(symbol) + case markSkipped: + return "-", ansi.Faint("-") + default: + return " ", " " + } +} + +// markWidth is the width of the widest symbol, so lines that start with +// different marks can be padded to line up. +func (l PoppyLayout) markWidth() int { + if l.Unicode { + return 1 + } + + return 2 +} + +// arrow separates the value before a change from the value after it. +func (l PoppyLayout) arrow() string { return l.pick("→", "->") } + +// alert is the sign that heads a note about wide effects. +func (l PoppyLayout) alert() string { return l.pick("⚠", "!") } + +func (l PoppyLayout) pick(unicode, ascii string) string { + if l.Unicode { + return unicode + } + + return ascii +} + +// poppyEntry is a line of a block: a mark, a label in a column, then a value that +// wraps in its own column. +type poppyEntry struct { + mark poppyMark + label string + value string +} + +// entries writes the lines of a block with the labels padded to one width. An +// entry without a label puts its value right after the mark, and with no mark +// either it only indents the value. Each line ends with a newline. +func (l PoppyLayout) entries(entries []poppyEntry) string { + labelWidth := 0 + for _, e := range entries { + labelWidth = max(labelWidth, utf8.RuneCountInString(e.label)) + } + + var b strings.Builder + + for _, e := range entries { + symbol, colored := l.mark(e.mark) + pad := strings.Repeat(" ", max(l.markWidth()-utf8.RuneCountInString(symbol), 0)) + + plain, shown := " "+symbol+pad+" ", " "+colored+pad+" " + if e.mark == markPlain && e.label == "" { + plain, shown = " ", " " + } + if e.label != "" { + label := e.label + strings.Repeat(" ", labelWidth-utf8.RuneCountInString(e.label)+2) + plain, shown = plain+label, shown+label + } + + b.WriteString(l.hang(plain, shown, e.value)) + } + + return b.String() +} + +// ansiSequence matches the escape sequences that color text. They take no room on +// the screen. +var ansiSequence = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// poppyBlock is a part of the screen that can be taken back. Everything written to +// it goes to the writer and is counted in screen rows, so that erase can clear +// exactly those rows with cursor movement and nothing else. It never relies on a +// saved cursor position, which a scrolling screen invalidates. +// +// Text written to a block must end each line with a newline: a last line without +// one is not counted. +type poppyBlock struct { + w io.Writer + width int + can bool + rows int + // Tail is the text after the last newline written. + tail string +} + +// newPoppyBlock starts an empty block on w. The layout gives the width at which +// long lines wrap on the screen, and can tells whether cursor movement works +// there, see PoppyCanErase. When it does not, erase leaves the text in place. +func newPoppyBlock(w io.Writer, l PoppyLayout, can bool) *poppyBlock { + return &poppyBlock{w: w, width: l.Width, can: can} +} + +// Write writes p to the writer and counts the rows it takes. +func (b *poppyBlock) Write(p []byte) (int, error) { + n, err := b.w.Write(p) + + lines := strings.Split(b.tail+string(p[:n]), "\n") + for _, line := range lines[:len(lines)-1] { + b.rows += poppyLineRows(line, b.width) + } + b.tail = lines[len(lines)-1] + + return n, err +} + +// add counts rows that something else wrote in the place of the block, such as a +// prompt that was answered and left them on the screen. +func (b *poppyBlock) add(rows int) { + b.rows += max(rows, 0) +} + +// erase takes the block off the screen. The cursor must be at the start of the +// row below the block. It moves up over the rows and clears from there to the end +// of the screen. A block that cannot be erased is only forgotten, so the next one +// counts its own rows. +func (b *poppyBlock) erase() error { + rows := b.rows + b.rows, b.tail = 0, "" + + if !b.can || rows == 0 { + return nil + } + + _, err := fmt.Fprintf(b.w, "\r\x1b[%dA\x1b[J", rows) + + return err +} + +// poppyLineRows is the number of screen rows that a line takes in a terminal of +// the given width. A line of exactly the width takes one row, and so does an empty +// line. A width of zero means the line is not wrapped. +func poppyLineRows(line string, width int) int { + if width <= 0 { + return 1 + } + + cells := utf8.RuneCountInString(ansiSequence.ReplaceAllString(line, "")) + + return max((cells+width-1)/width, 1) +} diff --git a/internal/display/poppy_layout_test.go b/internal/display/poppy_layout_test.go new file mode 100644 index 000000000..7c952e770 --- /dev/null +++ b/internal/display/poppy_layout_test.go @@ -0,0 +1,297 @@ +package display + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/ansi" +) + +// setColors turns the colors of the ansi package on or off for one test. +func setColors(t *testing.T, on bool) { + t.Helper() + + force, disable, override := ansi.ForceColors, ansi.DisableColors, ansi.EnvironmentOverrideColors + ansi.ForceColors, ansi.EnvironmentOverrideColors = on, false + ansi.Initialize(!on) + t.Cleanup(func() { + ansi.ForceColors, ansi.EnvironmentOverrideColors = force, override + ansi.Initialize(disable) + }) +} + +func TestPoppyLayoutOf(t *testing.T) { + t.Run("a writer that is not a terminal is not wrapped", func(t *testing.T) { + setColors(t, false) + assert.Equal(t, PoppyLayout{}, PoppyLayoutOf(&bytes.Buffer{})) + }) + + t.Run("colors keep the Unicode symbols on a pipe", func(t *testing.T) { + setColors(t, true) + assert.Equal(t, PoppyLayout{Unicode: true}, PoppyLayoutOf(&bytes.Buffer{})) + }) + + t.Run("a file that is not a terminal is not wrapped", func(t *testing.T) { + setColors(t, false) + + f, err := os.CreateTemp(t.TempDir(), "report") + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + assert.Equal(t, PoppyLayout{}, PoppyLayoutOf(f)) + }) +} + +func TestPoppyLineWidth(t *testing.T) { + assert.Equal(t, poppyDefaultWidth, PoppyLayout{}.lineWidth()) + assert.Equal(t, 120, PoppyLayout{Width: 120}.lineWidth()) +} + +func TestPoppyWrap(t *testing.T) { + words := strings.Repeat("word ", 30) + + tests := []struct { + name string + layout PoppyLayout + indent int + want []string + }{ + {name: "no width keeps one line", layout: PoppyLayout{}, want: []string{strings.TrimSpace(words)}}, + {name: "wraps at the width less the indent", layout: PoppyLayout{Width: 20}, indent: 0, want: nil}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := test.layout.wrap(words, test.indent) + + if test.want != nil { + assert.Equal(t, test.want, got) + + return + } + + require.Greater(t, len(got), 1) + for _, line := range got { + assert.LessOrEqual(t, utf8.RuneCountInString(line), 20) + } + }) + } + + assert.Equal(t, []string{"aaaa", "aaaa", "aa"}, wrapWords("aaaaaaaaaa", 4), "a word longer than a line is cut") + assert.Equal(t, []string{""}, wrapWords("", 10), "there is always a line") + assert.Equal(t, []string{"a b", "c"}, wrapWords("a b c", 3)) + assert.Len(t, PoppyLayout{Width: 5}.wrap(strings.Repeat("x", 100), 0), 5, "the width never goes below the minimum of 20") +} + +func TestPoppyHang(t *testing.T) { + l := PoppyLayout{Width: 40, Unicode: true} + + got := l.hang(" ✓ API ", " X API ", "alpha beta gamma delta epsilon zeta eta theta") + + assert.Equal(t, " X API alpha beta gamma delta epsilon\n zeta eta theta\n", got) + assert.Equal(t, " ✓ API alpha beta gamma delta epsilon\n", PoppyLayout{}.hang(" ✓ API ", " ✓ API ", "alpha beta gamma delta epsilon")) +} + +func TestPoppyMarks(t *testing.T) { + setColors(t, false) + + tests := []struct { + mark poppyMark + unicode string + ascii string + unicodeOnly bool + }{ + {markDone, "✓", "ok", false}, + {markChanged, "~", "~", false}, + {markAdded, "+", "+", false}, + {markWarning, "!", "!", false}, + {markFailed, "✗", "x", false}, + {markSkipped, "-", "-", false}, + } + + for _, test := range tests { + symbol, colored := PoppyLayout{Unicode: true}.mark(test.mark) + assert.Equal(t, test.unicode, symbol) + assert.Equal(t, test.unicode, colored, "no color codes when colors are off") + + symbol, _ = PoppyLayout{}.mark(test.mark) + assert.Equal(t, test.ascii, symbol) + } + + assert.Equal(t, "→", PoppyLayout{Unicode: true}.arrow()) + assert.Equal(t, "->", PoppyLayout{}.arrow()) + assert.Equal(t, "⚠", PoppyLayout{Unicode: true}.alert()) + assert.Equal(t, "!", PoppyLayout{}.alert()) + assert.Equal(t, 1, PoppyLayout{Unicode: true}.markWidth()) + assert.Equal(t, 2, PoppyLayout{}.markWidth()) + + setColors(t, true) + _, colored := PoppyLayout{Unicode: true}.mark(markFailed) + assert.Equal(t, ansi.Red("✗"), colored) +} + +func TestPoppyEntries(t *testing.T) { + setColors(t, false) + + entries := []poppyEntry{ + {mark: markDone, label: "Agent app", value: "created"}, + {mark: markChanged, label: "DPoP", value: "required"}, + {mark: markWarning, value: "A note without a label."}, + {mark: markPlain, label: "name", value: "Meta Muse"}, + {mark: markPlain, value: "A line that is only indented."}, + } + + t.Run("unicode", func(t *testing.T) { + assert.Equal(t, " ✓ Agent app created\n"+ + " ~ DPoP required\n"+ + " ! A note without a label.\n"+ + " name Meta Muse\n"+ + " A line that is only indented.\n", + PoppyLayout{Unicode: true}.entries(entries)) + }) + + t.Run("ascii", func(t *testing.T) { + assert.Equal(t, " ok Agent app created\n"+ + " ~ DPoP required\n"+ + " ! A note without a label.\n"+ + " name Meta Muse\n"+ + " A line that is only indented.\n", + PoppyLayout{}.entries(entries)) + }) + + t.Run("a long value wraps in its column", func(t *testing.T) { + out := PoppyLayout{Width: 40, Unicode: true}.entries([]poppyEntry{ + {mark: markDone, label: "Grant", value: "https://api.shop.example: orders:read checkout refunds wishlist"}, + }) + + for _, line := range strings.Split(strings.TrimSuffix(out, "\n"), "\n") { + assert.LessOrEqual(t, utf8.RuneCountInString(line), 40, line) + } + + assert.Contains(t, out, "\n ", "the next lines sit under the value") + }) +} + +func TestPoppyCanErase(t *testing.T) { + t.Run("a writer that is not a terminal cannot", func(t *testing.T) { + t.Setenv("TERM", "xterm") + assert.False(t, PoppyCanErase(&bytes.Buffer{})) + }) + + t.Run("a dumb terminal cannot", func(t *testing.T) { + t.Setenv("TERM", "dumb") + assert.False(t, PoppyCanErase(&bytes.Buffer{})) + }) +} + +func TestPoppyLineRows(t *testing.T) { + tests := []struct { + name string + line string + width int + want int + }{ + {name: "an empty line takes a row", line: "", width: 80, want: 1}, + {name: "a short line", line: "hello", width: 80, want: 1}, + {name: "a line of exactly the width", line: strings.Repeat("a", 80), width: 80, want: 1}, + {name: "one cell over the width", line: strings.Repeat("a", 81), width: 80, want: 2}, + {name: "several rows", line: strings.Repeat("a", 161), width: 80, want: 3}, + {name: "colors take no room", line: ansi.Bold(strings.Repeat("a", 80)), width: 80, want: 1}, + {name: "wide characters count as one cell each", line: strings.Repeat("é", 80), width: 80, want: 1}, + {name: "no width means no wrapping", line: strings.Repeat("a", 500), width: 0, want: 1}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, poppyLineRows(test.line, test.width)) + }) + } +} + +func TestPoppyBlock(t *testing.T) { + long := strings.Repeat("a", 100) + + tests := []struct { + name string + erase bool + write []string + rows int + // Want is what the writer got after the block was erased. + want string + }{ + {name: "one line", erase: true, write: []string{"title\n"}, rows: 1, want: "title\n\r\x1b[1A\x1b[J"}, + {name: "two lines", erase: true, write: []string{"title\n", "reason\n"}, rows: 2, want: "title\nreason\n\r\x1b[2A\x1b[J"}, + {name: "a line that wraps", erase: true, write: []string{long + "\n"}, rows: 2, want: long + "\n\r\x1b[2A\x1b[J"}, + {name: "a line written in parts", erase: true, write: []string{"ti", "tle\n"}, rows: 1, want: "title\n\r\x1b[1A\x1b[J"}, + {name: "several lines in one write", erase: true, write: []string{"a\nb\nc\n"}, rows: 3, want: "a\nb\nc\n\r\x1b[3A\x1b[J"}, + {name: "a blank line counts", erase: true, write: []string{"\n", "title\n"}, rows: 2, want: "\ntitle\n\r\x1b[2A\x1b[J"}, + {name: "nothing written, nothing erased", erase: true, write: nil, rows: 0, want: ""}, + {name: "a terminal that cannot erase keeps the lines", erase: false, write: []string{"title\n", "reason\n"}, rows: 2, want: "title\nreason\n"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var out bytes.Buffer + + b := newPoppyBlock(&out, PoppyLayout{Width: 80}, test.erase) + for _, part := range test.write { + _, err := io.WriteString(b, part) + require.NoError(t, err) + } + + assert.Equal(t, test.rows, b.rows) + require.NoError(t, b.erase()) + assert.Equal(t, test.want, out.String()) + assert.Zero(t, b.rows, "a block that was erased starts again") + }) + } + + t.Run("rows that something else left are counted with Add", func(t *testing.T) { + var out bytes.Buffer + + b := newPoppyBlock(&out, PoppyLayout{Width: 80}, true) + _, _ = io.WriteString(b, "title\n") + b.add(2) + b.add(-5) + + require.NoError(t, b.erase()) + assert.True(t, strings.HasSuffix(out.String(), "\r\x1b[3A\x1b[J"), out.String()) + }) + + t.Run("the next block counts its own rows", func(t *testing.T) { + var out bytes.Buffer + + b := newPoppyBlock(&out, PoppyLayout{Width: 80}, true) + _, _ = io.WriteString(b, "one\ntwo\n") + require.NoError(t, b.erase()) + out.Reset() + + _, _ = io.WriteString(b, "three\n") + require.NoError(t, b.erase()) + assert.Equal(t, "three\n\r\x1b[1A\x1b[J", out.String()) + }) + + t.Run("an unfinished line is not counted", func(t *testing.T) { + var out bytes.Buffer + + b := newPoppyBlock(&out, PoppyLayout{Width: 80}, true) + _, _ = io.WriteString(b, "title\nprompt") + + assert.Equal(t, 1, b.rows) + }) +} + +func TestPoppyHeader(t *testing.T) { + setColors(t, false) + + assert.Equal(t, "Poppy setup"+strings.Repeat(" ", 80-len("Poppy setup")-len("tenant x"))+"tenant x\n", + PoppyLayout{Width: 80}.header("Poppy setup", "tenant x")) + assert.Equal(t, "A very long title tenant x\n", PoppyLayout{Width: 10}.header("A very long title", "tenant x"), "at least one space") +} diff --git a/internal/display/poppy_test.go b/internal/display/poppy_test.go new file mode 100644 index 000000000..283515c5f --- /dev/null +++ b/internal/display/poppy_test.go @@ -0,0 +1,962 @@ +package display + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/poppy/config" + "github.com/auth0/auth0-cli/internal/poppy/doctor" + "github.com/auth0/auth0-cli/internal/poppy/setup" +) + +const ( + poppyShopAPI = "https://api.shop.example" + poppyShopApp = "W1x9aBcD2eFgH3iJ" + poppyTenantURL = "tenant.us.auth0.com" +) + +var update = flag.Bool("update", false, "rewrite the golden files") + +var ( + unicode80 = PoppyLayout{Width: 80, Unicode: true} + ascii80 = PoppyLayout{Width: 80} + pipe = PoppyLayout{Unicode: true} +) + +// assertGolden compares got with testdata/.golden.txt, and rewrites the +// file when the tests run with -update. +func assertGolden(t *testing.T, name, got string) { + t.Helper() + + path := filepath.Join("testdata", name+".golden.txt") + if *update { + require.NoError(t, os.MkdirAll("testdata", 0o750)) + require.NoError(t, os.WriteFile(path, []byte(got), 0o600)) + } + + want, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(want), got) +} + +// poppyConfiguredState is a tenant that setup has already been run against, with +// the config file it wrote. +func poppyConfiguredState(t *testing.T) *setup.State { + t.Helper() + + file, err := config.Read(filepath.Join("..", "poppy", "config", "testdata", "auth0-poppy.golden.json")) + require.NoError(t, err) + + return &setup.State{ + Tenant: setup.Tenant{ + CIMD: true, + IssParameter: true, + ResourceParameterProfile: "compatibility", + DefaultAudience: poppyShopAPI, + FriendlyName: "Shop", + CompanyDomains: []string{"https://shop.example"}, + }, + Domain: poppyTenantURL, + CustomDomain: "auth.shop.example", + APIs: []setup.API{{ + ID: "rs_shop", + Identifier: poppyShopAPI, + Name: "Shop API", + Scopes: []setup.Scope{ + {Name: "orders:read", Description: "See your orders and their status. Read only."}, + {Name: "checkout", Description: "Place orders and pay with your saved card. Can't be undone."}, + {Name: setup.BrowserSignInScope, Description: setup.BrowserSignInDescription}, + }, + AllowOfflineAccess: true, + AgentSubjectClaims: "auth0-v1", + Poppy: true, + GuestPolicy: "require_client_grant", + }}, + App: setup.App{ClientID: poppyShopApp, Name: "Shop Web", FirstParty: true, LoginURI: "https://shop.example/login", BrowserJoin: true}, + HasGuestGrant: map[string]bool{poppyShopAPI: true}, + File: file, + } +} + +func poppyConfiguredInputs() setup.Inputs { + return setup.Inputs{ + CompanyDomain: "shop.example", + APIs: []string{poppyShopAPI}, + App: "Shop Web", + Out: config.DefaultFileName, + GuestAPIAccess: true, + GuestAllScopes: true, + } +} + +// poppyFreshState is a tenant that has never been set up for Poppy. +func poppyFreshState(t *testing.T) (*setup.State, setup.Inputs) { + t.Helper() + + state := poppyConfiguredState(t) + state.Tenant = setup.Tenant{ResourceParameterProfile: "audience", FriendlyName: "Shop"} + state.CustomDomain = "" + state.APIs[0] = setup.API{ + ID: "rs_shop", + Identifier: poppyShopAPI, + Name: "Shop API", + Scopes: []setup.Scope{ + {Name: "orders:read", Description: "See your orders and their status. Read only."}, + {Name: "checkout", Description: "Place orders and pay with your saved card. Can't be undone."}, + }, + AgentSubjectClaims: "off", + } + state.App.BrowserJoin = false + state.HasGuestGrant = map[string]bool{} + state.File = nil + + in := poppyConfiguredInputs() + in.APIURLs = map[string]string{poppyShopAPI: poppyShopAPI + "/openapi.json"} + + return state, in +} + +func renderWith(t *testing.T, r doctor.Report, l PoppyLayout) string { + t.Helper() + + return doctorText(l, r) +} + +func render(t *testing.T, r doctor.Report) string { + t.Helper() + + return renderWith(t, r, unicode80) +} + +// greenReport is the report of a site that passes every check, as the doctor +// produced it. +func greenReport(t *testing.T) doctor.Report { + t.Helper() + + data, err := os.ReadFile(filepath.Join("testdata", "report-green.json")) + require.NoError(t, err) + + var report doctor.Report + require.NoError(t, json.Unmarshal(data, &report)) + + return report +} + +func shopReport(results ...doctor.Result) doctor.Report { + return doctor.Report{Domain: "shop.example", ProtocolVersion: "0.3", Issuer: "https://shop.example/", Results: results} +} + +var ( + issuerPass = doctor.Result{ID: "D3", Spec: "3.2", Level: doctor.Pass, Message: "issuer matches https://shop.example/"} + + protectedFail = doctor.Result{ + ID: "D3", + Spec: "3.2", + Level: doctor.Fail, + Message: "protected_resources does not list https://shop.example", + Fix: "Run auth0 poppy setup --company-domain shop.example", + } + + ps384Fail = doctor.Result{ + ID: "D4", + Spec: "4.1", + Level: doctor.Fail, + Message: "client assertions: PS384 not accepted by the issuer", + Fix: "PS384 needs the HRI SKU. Ask support if you need it", + PlatformDependency: true, + } + + hintWarn = doctor.Result{ + ID: "D5", + Spec: "4.7", + Level: doctor.Warn, + Message: "scope_hints missing for checkout", + Fix: "auth0 poppy setup --hint checkout=changes:,personal_data:", + } + + tenantSkipped = doctor.Result{ + ID: "D8", + Level: doctor.Skipped, + Message: "you are logged in to a different tenant than the one that serves shop.example. Log in to that tenant to run these checks.", + } +) + +func TestPoppyDoctorTextGolden(t *testing.T) { + green := greenReport(t) + + long := shopReport( + doctor.Result{ID: "D1", Spec: "3", Level: doctor.Pass, Message: "poppy.json is served over HTTPS at /.well-known/poppy.json"}, + doctor.Result{ID: "D7", Spec: "7", Level: doctor.Fail, Message: "https://api.shop.example/poppy/openapi.json returned HTTP 503, and the document must be served with status 200 so that agents can read it", Fix: "Serve the document at https://api.shop.example/poppy/openapi.json with status 200."}, + doctor.Result{ID: "D6", Spec: "6", Level: doctor.Warn, Message: "web.browser_session_endpoint is not published, so agents cannot join a browser session"}, + tenantSkipped, + ) + long.Domain, long.Issuer = "a-very-long-company-domain.example.com", "https://login.a-very-long-company-domain.example.com/" + + tests := []struct { + name string + report doctor.Report + layout PoppyLayout + }{ + {name: "report-green", report: green, layout: unicode80}, + {name: "report-fail", report: shopReport(issuerPass, protectedFail, ps384Fail, tenantSkipped), layout: unicode80}, + {name: "report-warn", report: shopReport(issuerPass, hintWarn), layout: unicode80}, + {name: "report-skipped-tenant", report: shopReport(issuerPass, tenantSkipped), layout: unicode80}, + { + name: "report-other", + report: shopReport(issuerPass, + doctor.Result{ID: "D99", Level: doctor.Pass, Message: "a check this CLI does not know"}, + doctor.Result{Level: doctor.Warn, Message: "a result without an ID"}), + layout: unicode80, + }, + {name: "report-no-header-details", report: doctor.Report{Domain: "shop.example", Results: []doctor.Result{issuerPass}}, layout: unicode80}, + {name: "report-ascii", report: shopReport(issuerPass, protectedFail, tenantSkipped), layout: ascii80}, + {name: "report-width-60", report: long, layout: PoppyLayout{Width: 60, Unicode: true}}, + {name: "report-width-100", report: long, layout: PoppyLayout{Width: 100, Unicode: true}}, + { + name: "report-escapes", + report: shopReport(doctor.Result{ + ID: "D5", Level: doctor.Fail, + Message: "scope \x1b[2J\x1b[31mred\nforged line", + Fix: "run \x1b]0;title\x07\nthen \r check", + }), + layout: unicode80, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assertGolden(t, test.name, renderWith(t, test.report, test.layout)) + }) + } +} + +func TestPoppyDoctorTextLines(t *testing.T) { + tests := []struct { + name string + report doctor.Report + layout PoppyLayout + want string + }{ + { + name: "an empty report", + report: doctor.Report{Domain: "shop.example"}, + layout: unicode80, + want: "Doctor for shop.example\n\n0 checks ran.\n", + }, + { + name: "the protocol version sits at the right edge", + report: doctor.Report{Domain: "shop.example", ProtocolVersion: "0.4", Results: []doctor.Result{{ID: "D1", Level: doctor.Pass, Message: "ok"}}}, + layout: PoppyLayout{Width: 40, Unicode: true}, + want: "Doctor for shop.example protocol 0.4\n\n" + + "Website files\n ✓ ok\n\n1 passed.\n", + }, + { + name: "a header wider than the terminal keeps one space", + report: doctor.Report{Domain: "shop.example", ProtocolVersion: "0.4"}, + layout: PoppyLayout{Width: 10, Unicode: true}, + want: "Doctor for shop.example protocol 0.4\n\n0 checks ran.\n", + }, + { + name: "groups come in a fixed order whatever the result order", + report: doctor.Report{Domain: "d", Results: []doctor.Result{{ID: "D8", Level: doctor.Pass, Message: "t"}, {ID: "D1", Level: doctor.Pass, Message: "w"}}}, + layout: unicode80, + want: "Doctor for d\n\nWebsite files\n ✓ w\n\nTenant\n ✓ t\n\n2 passed.\n", + }, + { + name: "a fix sits under the message of any level", + report: doctor.Report{Domain: "d", Results: []doctor.Result{ + {ID: "D8", Level: doctor.Skipped, Message: "cannot check the web apps", Fix: "auth0 login --scopes read:clients"}, + {ID: "D4", Level: doctor.Fail, Message: "bad", Fix: "one\ntwo", PlatformDependency: true}, + }}, + layout: unicode80, + want: "Doctor for d\n\n" + + "Auth0 issuer\n FAIL bad\n FIX one\n two (Auth0 must fix this)\n\n" + + "Tenant\n - skipped: cannot check the web apps\n FIX auth0 login --scopes read:clients\n\n" + + "1 failed, 0 passed, 1 skipped.\n", + }, + { + name: "ASCII fallback for the pass mark", + report: doctor.Report{Domain: "d", Results: []doctor.Result{{ID: "D1", Level: doctor.Pass, Message: "fine"}}}, + layout: ascii80, + want: "Doctor for d\n\nWebsite files\n ok fine\n\n1 passed.\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, renderWith(t, test.report, test.layout)) + }) + } +} + +func TestPoppyDoctorTextCollapsesRepeatedSkips(t *testing.T) { + const reason = "no usable tenant login; run auth0 login to check the tenant" + + report := doctor.Report{Domain: "d", Results: []doctor.Result{ + {ID: "D8", Level: doctor.Skipped, Message: reason}, + {ID: "D9", Level: doctor.Skipped, Message: reason}, + {ID: "D10", Level: doctor.Pass, Message: "fine"}, + {ID: "D11", Level: doctor.Skipped, Message: reason}, + {ID: "D12", Level: doctor.Skipped, Message: reason}, + {ID: "D13", Level: doctor.Skipped, Message: reason}, + {ID: "D14", Level: doctor.Skipped, Message: "another reason"}, + }} + + want := "Doctor for d\n\nTenant\n" + + " - skipped (2 checks): " + reason + "\n" + + " ✓ fine\n" + + " - skipped (3 checks): " + reason + "\n" + + " - skipped: another reason\n\n" + + "1 passed, 6 skipped.\n" + + assert.Equal(t, want, renderWith(t, report, PoppyLayout{Unicode: true}), "the summary still counts every check") + assert.Equal(t, 6, report.Count(doctor.Skipped)) +} + +func TestPoppyDoctorTextDoesNotWrapWithoutAWidth(t *testing.T) { + const full = "checkout is in neither auth.scopes nor the scopes_supported of the issuer, and the signed-in browser join needs it" + + report := doctor.Report{Domain: "d", Results: []doctor.Result{{ID: "D5", Level: doctor.Warn, Message: full}}} + + assert.Contains(t, renderWith(t, report, PoppyLayout{Unicode: true}), " WARN "+full+"\n", "a pipe sees the whole line") + assert.NotContains(t, renderWith(t, report, PoppyLayout{Width: 60, Unicode: true}), " WARN "+full+"\n", "a terminal wraps it") +} + +func TestPoppyDoctorProblems(t *testing.T) { + report := shopReport(issuerPass, protectedFail, hintWarn, tenantSkipped, + doctor.Result{ID: "D1", Level: doctor.Pass, Message: "poppy.json is served"}) + + tests := []struct { + name string + layout PoppyLayout + prefix string + want string + }{ + { + name: "only warnings and failures, in their groups, with fixes", + layout: unicode80, + prefix: " ", + want: " Auth0 issuer\n" + + " FAIL protected_resources does not list https://shop.example\n" + + " FIX Run auth0 poppy setup --company-domain shop.example\n" + + "\n" + + " Scopes\n" + + " WARN scope_hints missing for checkout\n" + + " FIX auth0 poppy setup --hint checkout=changes:,personal_data:\n", + }, + { + name: "the prefix narrows the wrapping", + layout: PoppyLayout{Width: 30, Unicode: true}, + prefix: " ", + want: " Auth0 issuer\n" + + " FAIL protected_resources\n" + + " does not list\n" + + " https://shop.example\n" + + " FIX Run auth0 poppy setup --company-domain shop.example\n" + + "\n" + + " Scopes\n" + + " WARN scope_hints missing\n" + + " for checkout\n" + + " FIX auth0 poppy setup --hint checkout=changes:,personal_data:\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, doctorProblems(test.layout, report, test.prefix)) + }) + } + + assert.Empty(t, doctorProblems(unicode80, shopReport(issuerPass, tenantSkipped), " "), "nothing to say when nothing warned or failed") +} + +func TestPoppyDoctorTextWrapsUnderTheMessage(t *testing.T) { + report := doctor.Report{Domain: "d", Results: []doctor.Result{ + {ID: "D1", Level: doctor.Pass, Message: strings.Repeat("word ", 30)}, + {ID: "D1", Level: doctor.Fail, Message: strings.Repeat("x", 100)}, + }} + + for _, width := range []int{40, 60, 100} { + out := renderWith(t, report, PoppyLayout{Width: width, Unicode: true}) + + for _, line := range strings.Split(out, "\n") { + assert.LessOrEqual(t, utf8.RuneCountInString(line), width, "line %q at width %d", line, width) + } + } + + out := renderWith(t, report, PoppyLayout{Width: 40, Unicode: true}) + assert.Contains(t, out, " ✓ word word word word word word word\n word word", "continuation lines sit under the message") + assert.Contains(t, out, " FAIL "+strings.Repeat("x", 33)+"\n "+strings.Repeat("x", 33)+"\n", "a word longer than a line is cut") +} + +func TestPoppyDoctorTextCapsAHugeMessage(t *testing.T) { + huge := "scope " + strings.Repeat("x", 1<<20) + + out := render(t, doctor.Report{Domain: "d", Results: []doctor.Result{{ID: "D5", Level: doctor.Fail, Message: huge}, {ID: "D5", Level: doctor.Pass, Message: "ok"}}}) + + assert.Less(t, len(out), 1000) + assert.Contains(t, out, "…\n") + assert.Equal(t, 193, strings.Count(out, "x"), "the message is cut to 200 characters") +} + +func TestPoppyDoctorTextColors(t *testing.T) { + setColors(t, true) + + out := render(t, doctor.Report{Domain: "d", Results: []doctor.Result{ + {ID: "D1", Level: doctor.Pass, Message: "fine"}, + {ID: "D1", Level: doctor.Warn, Message: "hmm"}, + {ID: "D1", Level: doctor.Fail, Message: "bad"}, + {ID: "D1", Level: doctor.Skipped, Message: "later"}, + }}) + + assert.Contains(t, out, ansi.Green("✓")) + assert.Contains(t, out, ansi.Yellow("WARN")) + assert.Contains(t, out, ansi.Red("FAIL")) + assert.Contains(t, out, ansi.Faint("-")) + assert.Contains(t, out, ansi.Bold("Website files")) + + setColors(t, false) + assert.NotContains(t, render(t, doctor.Report{Domain: "d", Results: []doctor.Result{{ID: "D1", Level: doctor.Pass, Message: "fine"}}}), "\x1b") +} + +func TestPoppyDoctorEveryCheckHasAGroup(t *testing.T) { + for i := 1; i <= 17; i++ { + id := fmt.Sprintf("D%d", i) + assert.NotEqual(t, doctorOtherGroup, doctorGroupOf(id), id) + } + + assert.Equal(t, doctorOtherGroup, doctorGroupOf("D99")) + assert.Equal(t, doctorOtherGroup, doctorGroupOf("")) + + seen := map[string]string{} + for _, g := range doctorGroups { + for _, id := range g.ids { + assert.NotContains(t, seen, id, "%s is in two groups", id) + seen[id] = g.name + } + } +} + +// poppyRichTenant is a tenant with something to change in every group: scopes to +// describe and to add, a login route, and a guest grant. +func poppyRichTenant(t *testing.T) (*setup.State, setup.Inputs) { + t.Helper() + + state, in := poppyFreshState(t) + state.Tenant.CompanyDomains = []string{"https://old.example"} + state.APIs[0].Scopes = append(state.APIs[0].Scopes, setup.Scope{Name: "refunds"}) + state.App.LoginURI = "" + + in.Scopes = map[string]string{"refunds": "Refund an order you placed.", "wishlist": "Keep a list of things you want."} + in.LoginURI = "https://shop.example/login" + in.GuestAllScopes, in.GuestScopes = false, map[string][]string{poppyShopAPI: {"orders:read", "checkout"}} + + return state, in +} + +func poppyBuildPlan(t *testing.T, state *setup.State, in setup.Inputs) setup.Plan { + t.Helper() + + plan, err := setup.Build(state, in) + require.NoError(t, err) + + return plan +} + +func TestPoppyPlanTextGolden(t *testing.T) { + state, in := poppyRichTenant(t) + rich := poppyBuildPlan(t, state, in) + richOpts := PoppySetupOptions{Inputs: in, DryRun: true} + + // The tenant is right but for the guest grant and the file. + configured := poppyConfiguredState(t) + configuredIn := poppyConfiguredInputs() + configuredIn.GuestAPIAccess = false + empty := poppyBuildPlan(t, configured, configuredIn) + + // Settings that are right are collapsed into one line. + partial := poppyConfiguredState(t) + partial.Tenant.CIMD = false + partial.App.BrowserJoin = false + partialPlan := poppyBuildPlan(t, partial, configuredIn) + + // One API, no custom domain: the issuer warning. + freshState, freshIn := poppyFreshState(t) + freshPlan := poppyBuildPlan(t, freshState, freshIn) + + // Several APIs. + many, manyIn := freshState, freshIn + many.APIs = append(many.APIs, setup.API{ID: "rs_pay", Identifier: "https://pay.shop.example", Name: "Pay", AgentSubjectClaims: "off", + Scopes: []setup.Scope{{Name: "pay", Description: "Pay for an order."}}}) + manyIn.APIs = append(manyIn.APIs, "https://pay.shop.example") + manyIn.APIURLs = map[string]string{poppyShopAPI: poppyShopAPI + "/openapi.json", "https://pay.shop.example": "https://pay.shop.example/openapi.json"} + manyPlan := poppyBuildPlan(t, many, manyIn) + + // A value that could not be read, and a setting that does not exist. + unknown := setup.Plan{ + Tenant: poppyTenantURL, + Issuer: "https://" + poppyTenantURL + "/", + CompanyDomain: "shop.example", + APIs: []string{poppyShopAPI}, + App: "Shop Web", + AppFirstParty: true, + Warnings: []string{"Something else to look at."}, + Changes: []setup.Change{ + {Kind: setup.KindApp, Impact: setup.ImpactApp, Fields: []setup.Field{ + {Name: "initiate_login_uri", Value: "https://shop.example/login", Before: "none"}, + {Name: "allow_personal_agent_browser_join", Value: "true"}, + }}, + {Kind: setup.KindFile, Description: "write auth0-poppy.json", Impact: setup.ImpactFile, Fields: []setup.Field{}}, + }, + } + + tests := []struct { + name string + plan setup.Plan + layout PoppyLayout + opts PoppySetupOptions + }{ + {name: "plan-dry-run", plan: rich, layout: unicode80, opts: richOpts}, + {name: "plan-dry-run-ascii", plan: rich, layout: ascii80, opts: richOpts}, + {name: "plan-dry-run-pipe", plan: rich, layout: pipe, opts: richOpts}, + {name: "plan-dry-run-60", plan: rich, layout: PoppyLayout{Width: 60, Unicode: true}, opts: richOpts}, + {name: "plan-dry-run-100", plan: rich, layout: PoppyLayout{Width: 100, Unicode: true}, opts: richOpts}, + {name: "plan-before-apply", plan: rich, layout: unicode80, opts: PoppySetupOptions{Inputs: in}}, + {name: "plan-nothing-to-change", plan: empty, layout: unicode80, opts: PoppySetupOptions{Inputs: configuredIn, DryRun: true}}, + {name: "plan-no-custom-domain", plan: freshPlan, layout: unicode80, opts: PoppySetupOptions{Inputs: freshIn, DryRun: true}}, + {name: "plan-unchanged-collapsed", plan: partialPlan, layout: unicode80, opts: PoppySetupOptions{Inputs: configuredIn, DryRun: true}}, + {name: "plan-several-apis", plan: manyPlan, layout: unicode80, opts: PoppySetupOptions{Inputs: manyIn, DryRun: true}}, + {name: "plan-unknown-before", plan: unknown, layout: unicode80, opts: PoppySetupOptions{DryRun: true}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assertGolden(t, test.name, poppyPlanText(test.layout, test.plan, test.opts)) + }) + } +} + +func TestPoppyPlanTextWarningsWrap(t *testing.T) { + state, in := poppyFreshState(t) + plan := poppyBuildPlan(t, state, in) + + for _, width := range []int{60, 100} { + out := poppyPlanText(PoppyLayout{Width: width, Unicode: true}, plan, PoppySetupOptions{Inputs: in, DryRun: true}) + + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, poppyToApplyLabel) || strings.HasPrefix(line, strings.Repeat(" ", len(poppyToApplyLabel)+2)) { + continue + } + + assert.LessOrEqual(t, utf8.RuneCountInString(line), width, "line %q at width %d", line, width) + } + + assert.Contains(t, strings.Join(strings.Fields(out), " "), plan.Warnings[0], "the warning is whole, only wrapped") + } + + assert.Contains(t, poppyPlanText(pipe, plan, PoppySetupOptions{Inputs: in}), plan.Warnings[0], "a pipe gets the whole warning on one line") +} + +func TestPoppyPlanTextLines(t *testing.T) { + state, in := poppyRichTenant(t) + plan := poppyBuildPlan(t, state, in) + + out := poppyPlanText(unicode80, plan, PoppySetupOptions{Inputs: in, DryRun: true}) + + for _, want := range []string{ + "Poppy setup for shop.example", + "tenant " + poppyTenantURL + "\n", + "Dry run: nothing will be changed.\n", + " ✓ API " + poppyShopAPI + "\n", + " ✓ Web app Shop Web (first party)\n", + " ✓ Scopes all described (refunds, wishlist from your --scope)\n", + " ! Issuer https://" + poppyTenantURL + "/\n", + " Login route https://shop.example/login\n", + " Guest access 2 scopes on https://api.shop.example\n", + " orders:read, checkout\n", + "Plan: 16 changes, 5 of them affect every app on the tenant\n", + "⚠ affects all apps\n", + " ~ iss in sign-in redirects", + "off → on", + "every app's redirects", + } { + assert.Contains(t, out, want) + } + + assert.NotContains(t, out, "+ scope refunds", "a scope that only gets a description is not shown as new") + + assert.Contains(t, out, `~ scope refunds`) + assert.Contains(t, out, `none → "Refund an order you placed."`) + assert.Contains(t, out, `+ scope wishlist`) + assert.Contains(t, out, " + company domain", "the company domain is added") + assert.Contains(t, out, " https://shop.example\n", "only the new company domain is shown") + assert.NotContains(t, out, "old.example") + + order := []string{"Tenant settings", "API " + poppyShopAPI, "App Shop Web", "Guest grant", "File\n"} + last := -1 + for _, heading := range order { + at := strings.Index(out, "\n"+heading) + require.Greater(t, at, last, "%q comes after the one before it", heading) + last = at + } +} + +func TestPoppyPlanTextNothingToChange(t *testing.T) { + state := poppyConfiguredState(t) + in := poppyConfiguredInputs() + in.GuestAPIAccess = false + plan := poppyBuildPlan(t, state, in) + + // The file differs from what is derived only when the tenant changed, so the + // plan is empty here. + require.True(t, plan.Empty()) + + out := poppyPlanText(unicode80, plan, PoppySetupOptions{Inputs: in, DryRun: true}) + + assert.Contains(t, out, "Nothing to change. Everything already matches.\n") + assert.NotContains(t, out, "To apply") + assert.NotContains(t, out, "Your answers") + assert.NotContains(t, out, "Dry run") + assert.True(t, strings.HasPrefix(out, "Poppy setup for shop.example")) +} + +func TestPoppyPlanTextDoesNotApplyWithoutDryRun(t *testing.T) { + state, in := poppyRichTenant(t) + plan := poppyBuildPlan(t, state, in) + + out := poppyPlanText(unicode80, plan, PoppySetupOptions{Inputs: in}) + + assert.NotContains(t, out, "To apply") + assert.NotContains(t, out, "Dry run") +} + +func TestRendererPoppyPlan(t *testing.T) { + state, in := poppyRichTenant(t) + plan := poppyBuildPlan(t, state, in) + + t.Run("a writer that is not a terminal gets the whole lines", func(t *testing.T) { + setColors(t, false) + + var buf bytes.Buffer + r := &Renderer{ResultWriter: &buf} + r.PoppyPlan(plan, PoppySetupOptions{Inputs: in, DryRun: true}) + + assert.Equal(t, poppyPlanText(PoppyLayout{}, plan, PoppySetupOptions{Inputs: in, DryRun: true}), buf.String()) + assert.NotContains(t, buf.String(), "→", "ASCII without colors") + assert.Contains(t, buf.String(), "->") + assert.NotContains(t, buf.String(), "\x1b") + }) +} + +func TestPoppyPlanTextEscapesTenantText(t *testing.T) { + state, in := poppyFreshState(t) + state.App.Name = "Shop\x1b[2J Web\nforged" + plan := poppyBuildPlan(t, state, in) + + out := poppyPlanText(unicode80, plan, PoppySetupOptions{Inputs: in}) + + assert.NotContains(t, out, "\x1b") + assert.Contains(t, out, `Shop\x1b[2J Web forged`) +} + +func TestPoppyApplyTextGolden(t *testing.T) { + state, in := poppyRichTenant(t) + plan := poppyBuildPlan(t, state, in) + opts := PoppySetupOptions{Inputs: in, Command: setup.CommandOptions{Yes: true}} + + tests := []struct { + name string + layout PoppyLayout + outcome setup.Outcome + }{ + {name: "apply-success", layout: unicode80, outcome: setup.Outcome{Done: len(plan.Changes)}}, + {name: "apply-success-ascii", layout: ascii80, outcome: setup.Outcome{Done: len(plan.Changes)}}, + {name: "apply-partial-failure", layout: unicode80, outcome: setup.Outcome{Done: 1, Failure: errors.New("failed to update the API " + poppyShopAPI + ": 500 Internal Server Error")}}, + {name: "apply-partial-failure-60", layout: PoppyLayout{Width: 60, Unicode: true}, outcome: setup.Outcome{Done: 2, Failure: errors.New("failed to update the app Shop Web: 403 Forbidden\nInsufficient scope")}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assertGolden(t, test.name, poppyApplyText(test.layout, plan, test.outcome, opts)) + }) + } +} + +func TestPoppyApplyTextCounts(t *testing.T) { + state, in := poppyRichTenant(t) + plan := poppyBuildPlan(t, state, in) + + out := poppyApplyText(unicode80, plan, setup.Outcome{Done: len(plan.Changes)}, PoppySetupOptions{Inputs: in}) + + assert.Contains(t, out, "Applying 16 changes\n") + assert.Contains(t, out, " ✓ Tenant settings") + assert.Contains(t, out, "5 changes") + assert.Contains(t, out, " ✓ auth0-poppy.json") + assert.Contains(t, out, "written") + assert.NotContains(t, out, "continues", "a success has no rerun command") +} + +func TestPoppySetupNext(t *testing.T) { + assert.Equal(t, "Next\n"+ + " 1. Deploy auth0-poppy.json with your web and API SDKs.\n"+ + " 2. auth0 poppy doctor shop.example to check the live site.\n"+ + " 3. auth0 poppy agents register to allow an agent.\n", + poppySetupNext(unicode80, "shop.example", "", 0)) + + assert.Contains(t, poppySetupNext(unicode80, "shop.example", "./out/poppy.json", 0), "Deploy ./out/poppy.json with") + + for _, tc := range []struct { + unhinted int + want string + }{ + {1, "Set the hints of 1 scope with --hint"}, + {4, "Set the hints of 4 scopes with --hint"}, + } { + out := poppySetupNext(unicode80, "shop.example", "", tc.unhinted) + assert.Contains(t, out, tc.want) + assert.Contains(t, out, "=changes:,personal_data:") + assert.Contains(t, out, " 3. auth0 poppy doctor shop.example", "the hint step goes before the check") + } + + assert.NotContains(t, poppySetupNext(unicode80, "shop.example", "", 0), "--hint") +} + +func TestPoppyHintsText(t *testing.T) { + setColors(t, false) + + hints := func(names ...string) map[string]config.Hints { + all := map[string]config.Hints{ + "orders:read": {Changes: "none"}, + "profile": {Changes: "none", PersonalData: true}, + "cart": {Changes: "reversible"}, + "wishlist": {Changes: "reversible", PersonalData: true}, + "checkout": {Changes: "irreversible", PersonalData: true}, + "refund": {Changes: "irreversible"}, + "delete": {Changes: "irreversible", PersonalData: true}, + "export": {Changes: "none", PersonalData: true}, + } + + picked := map[string]config.Hints{} + for _, name := range names { + picked[name] = all[name] + } + + return picked + } + + tests := []struct { + name string + hints map[string]config.Hints + empty bool + }{ + {name: "no hints", empty: true}, + {name: "three hints", hints: hints("checkout", "orders:read", "cart")}, + {name: "five hints are all shown", hints: hints("checkout", "orders:read", "cart", "profile", "refund")}, + {name: "eight hints show three and count the rest", hints: hints("checkout", "orders:read", "cart", "profile", "refund", "wishlist", "delete", "export")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := poppyHintsText(unicode80, setup.Inputs{Hints: test.hints}) + + if test.empty { + assert.Empty(t, got, "no hints, no block") + + return + } + + assertGolden(t, "hints-"+strings.ReplaceAll(test.name, " ", "-"), got) + }) + } + + t.Run("a narrow terminal wraps on the label column", func(t *testing.T) { + got := poppyHintsText(PoppyLayout{Width: 24, Unicode: true}, setup.Inputs{Hints: hints("checkout", "orders:read")}) + + assertGolden(t, "hints-narrow", got) + }) +} + +func newTestPoppyScreen(erase bool) (*PoppyScreen, *bytes.Buffer) { + var out bytes.Buffer + + r := &Renderer{ResultWriter: &out} + + return r.NewPoppyScreen("Poppy setup", "mock.auth0.test", PoppyLayout{Width: 80, Unicode: true}, erase), &out +} + +func TestPoppyScreenTitle(t *testing.T) { + ask := func(s *PoppyScreen) { + s.Begin("Question", "Why.") + require.NoError(t, s.End()) + } + + t.Run("the title of the run is printed once, above the first question", func(t *testing.T) { + screen, out := newTestPoppyScreen(false) + screen.ForDomain("shop.example") + + ask(screen) + ask(screen) + + assert.Equal(t, 1, strings.Count(out.String(), "Poppy setup for shop.example")) + assert.True(t, strings.HasPrefix(out.String(), "Poppy setup for shop.example")) + assert.True(t, screen.HeaderShown()) + }) + + t.Run("no title until the company domain is known", func(t *testing.T) { + screen, out := newTestPoppyScreen(false) + + ask(screen) + + assert.NotContains(t, out.String(), "Poppy setup") + assert.False(t, screen.HeaderShown()) + }) + + t.Run("a run that prints nothing after its questions takes the title back", func(t *testing.T) { + screen, out := newTestPoppyScreen(true) + screen.ForDomain("shop.example") + + ask(screen) + screen.DropHeader() + + assert.True(t, strings.HasSuffix(out.String(), "\r\x1b[1A\x1b[J"), "one row, the title") + assert.False(t, screen.HeaderShown()) + }) + + t.Run("the title stays once text was printed below it", func(t *testing.T) { + screen, out := newTestPoppyScreen(true) + screen.ForDomain("shop.example") + + ask(screen) + screen.Printed() + screen.DropHeader() + + assert.NotContains(t, out.String(), "\x1b[1A") + assert.True(t, screen.HeaderShown()) + }) + + t.Run("a note stays too", func(t *testing.T) { + screen, out := newTestPoppyScreen(true) + screen.ForDomain("shop.example") + + ask(screen) + screen.Note("Nothing to %s.", "choose") + screen.DropHeader() + + assert.Contains(t, out.String(), "Nothing to choose.") + assert.True(t, screen.HeaderShown()) + }) + + t.Run("an answer and a refusal are lines of the block", func(t *testing.T) { + screen, out := newTestPoppyScreen(true) + + screen.Answered("Does it change anything?", "none") + screen.Invalid("Answer y or n.") + + assert.Equal(t, " ✓ Does it change anything? none\n ✗ Answer y or n.\n", out.String()) + assert.Equal(t, 2, screen.BlockRows()) + }) + + t.Run("Ctrl-C says nothing was changed", func(t *testing.T) { + screen, out := newTestPoppyScreen(true) + + screen.Cancelled() + + assert.Equal(t, PoppyCancelled+"\n", out.String()) + }) +} + +func TestPoppyScreenRows(t *testing.T) { + tests := []struct { + name string + line string + want int + }{ + {name: "one row", line: " Question", want: 1}, + {name: "a long line wraps", line: " " + strings.Repeat("x", 100), want: 2}, + {name: "an empty line takes a row", want: 1}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + screen, out := newTestPoppyScreen(true) + + assert.Equal(t, test.want, screen.Rows(test.line)) + + screen.AddRows(screen.Rows(test.line)) + + assert.Equal(t, test.want, screen.BlockRows()) + assert.Empty(t, out.String(), "counting writes nothing") + }) + } +} + +func TestPoppyHintNote(t *testing.T) { + tests := []struct { + name string + hints *config.Hints + want string + }{ + {name: "no hints", want: ""}, + {name: "changes nothing", hints: &config.Hints{Changes: "none"}, want: "[changes: none]"}, + {name: "personal data", hints: &config.Hints{Changes: "reversible", PersonalData: true}, want: "[changes: reversible, personal data]"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, poppyHintNote(test.hints)) + }) + } +} + +func TestPoppyScopeRows(t *testing.T) { + scopes := []PoppyScopeChoice{ + {Name: "orders:read", Description: "See your orders and their status", Hints: &config.Hints{Changes: "none"}}, + {Name: "checkout", Description: "Place orders and pay with your saved card"}, + {Name: "poppy:browser-sign-in", Description: "Use the website signed in, with the other access you approve"}, + } + + rowsAt := func(width int) []string { + var out bytes.Buffer + + r := &Renderer{ResultWriter: &out} + + return r.NewPoppyScreen("", "", PoppyLayout{Width: width, Unicode: true}, false).ScopeRows(scopes) + } + + t.Run("names are aligned and the descriptions follow", func(t *testing.T) { + assert.Equal(t, []string{ + "orders:read See your orders and their status [changes: none]", + "checkout Place orders and pay with your saved card", + "poppy:browser-sign-in Use the website signed in, with the other access you approve", + }, rowsAt(120)) + }) + + t.Run("a description that does not fit is cut, never wrapped", func(t *testing.T) { + for _, row := range rowsAt(60) { + assert.LessOrEqual(t, len([]rune(row))+PoppyListMarks, 60, row) + } + + assert.Contains(t, rowsAt(60)[2], "…") + }) + + t.Run("the hints stay when the description is cut", func(t *testing.T) { + assert.True(t, strings.HasSuffix(rowsAt(60)[0], "[changes: none]"), rowsAt(60)[0]) + }) +} + +func TestPoppyChangeCount(t *testing.T) { + assert.Equal(t, "1 change", PoppyChangeCount(1)) + assert.Equal(t, "3 changes", PoppyChangeCount(3)) +} diff --git a/internal/display/testdata/apply-partial-failure-60.golden.txt b/internal/display/testdata/apply-partial-failure-60.golden.txt new file mode 100644 index 000000000..2048f6249 --- /dev/null +++ b/internal/display/testdata/apply-partial-failure-60.golden.txt @@ -0,0 +1,19 @@ +Applying 16 changes + ✓ Tenant settings 5 changes + ✓ API 7 changes + ✗ App Shop Web failed + failed to update the app Shop Web: 403 Forbidden + Insufficient scope + - Guest grant https://api.shop.example not run + - auth0-poppy.json not run + +Running the command again continues from here. +To rerun: auth0 poppy setup --company-domain shop.example \ + --api https://api.shop.example \ + --api-url https://api.shop.example=https://api.shop.example/openapi.json \ + --app "Shop Web" \ + --scope "refunds=Refund an order you placed." \ + --scope "wishlist=Keep a list of things you want." \ + --login-uri https://shop.example/login \ + --guest-api-access \ + --guest-scopes checkout,orders:read --yes diff --git a/internal/display/testdata/apply-partial-failure.golden.txt b/internal/display/testdata/apply-partial-failure.golden.txt new file mode 100644 index 000000000..09135f9bb --- /dev/null +++ b/internal/display/testdata/apply-partial-failure.golden.txt @@ -0,0 +1,17 @@ +Applying 16 changes + ✓ Tenant settings 5 changes + ✗ API failed + failed to update the API https://api.shop.example: 500 Internal Server + Error + - App Shop Web not run + - Guest grant https://api.shop.example not run + - auth0-poppy.json not run + +Running the command again continues from here. +To rerun: auth0 poppy setup --company-domain shop.example \ + --api https://api.shop.example \ + --api-url https://api.shop.example=https://api.shop.example/openapi.json \ + --app "Shop Web" --scope "refunds=Refund an order you placed." \ + --scope "wishlist=Keep a list of things you want." \ + --login-uri https://shop.example/login --guest-api-access \ + --guest-scopes checkout,orders:read --yes diff --git a/internal/display/testdata/apply-success-ascii.golden.txt b/internal/display/testdata/apply-success-ascii.golden.txt new file mode 100644 index 000000000..f0a01cd10 --- /dev/null +++ b/internal/display/testdata/apply-success-ascii.golden.txt @@ -0,0 +1,6 @@ +Applying 16 changes + ok Tenant settings 5 changes + ok API 7 changes + ok App Shop Web 2 changes + ok Guest grant https://api.shop.example 1 change + ok auth0-poppy.json written diff --git a/internal/display/testdata/apply-success.golden.txt b/internal/display/testdata/apply-success.golden.txt new file mode 100644 index 000000000..af604c18e --- /dev/null +++ b/internal/display/testdata/apply-success.golden.txt @@ -0,0 +1,6 @@ +Applying 16 changes + ✓ Tenant settings 5 changes + ✓ API 7 changes + ✓ App Shop Web 2 changes + ✓ Guest grant https://api.shop.example 1 change + ✓ auth0-poppy.json written diff --git a/internal/display/testdata/hints-eight-hints-show-three-and-count-the-rest.golden.txt b/internal/display/testdata/hints-eight-hints-show-three-and-count-the-rest.golden.txt new file mode 100644 index 000000000..3ae7af372 --- /dev/null +++ b/internal/display/testdata/hints-eight-hints-show-three-and-count-the-rest.golden.txt @@ -0,0 +1,5 @@ +Your answers + Hints export: none, personal data + orders:read: none + profile: none, personal data + (5 more scopes not shown here) diff --git a/internal/display/testdata/hints-five-hints-are-all-shown.golden.txt b/internal/display/testdata/hints-five-hints-are-all-shown.golden.txt new file mode 100644 index 000000000..85c4fa2be --- /dev/null +++ b/internal/display/testdata/hints-five-hints-are-all-shown.golden.txt @@ -0,0 +1,6 @@ +Your answers + Hints orders:read: none + profile: none, personal data + cart: reversible + checkout: irreversible, personal data + refund: irreversible diff --git a/internal/display/testdata/hints-narrow.golden.txt b/internal/display/testdata/hints-narrow.golden.txt new file mode 100644 index 000000000..49d4500b3 --- /dev/null +++ b/internal/display/testdata/hints-narrow.golden.txt @@ -0,0 +1,5 @@ +Your answers + Hints orders:read: none + checkout: + irreversible, + personal data diff --git a/internal/display/testdata/hints-three-hints.golden.txt b/internal/display/testdata/hints-three-hints.golden.txt new file mode 100644 index 000000000..e717d292e --- /dev/null +++ b/internal/display/testdata/hints-three-hints.golden.txt @@ -0,0 +1,4 @@ +Your answers + Hints orders:read: none + cart: reversible + checkout: irreversible, personal data diff --git a/internal/display/testdata/plan-before-apply.golden.txt b/internal/display/testdata/plan-before-apply.golden.txt new file mode 100644 index 000000000..c9f4dbd3c --- /dev/null +++ b/internal/display/testdata/plan-before-apply.golden.txt @@ -0,0 +1,46 @@ +Poppy setup for shop.example tenant tenant.us.auth0.com + +Checked + ✓ API https://api.shop.example + ✓ Web app Shop Web (first party) + ✓ Scopes all described (refunds, wishlist from your --scope) + ! Issuer https://tenant.us.auth0.com/ + The tenant has no verified custom domain, so agents will see the + Auth0 domain tenant.us.auth0.com as the issuer. Changing the + issuer later signs every user out. + +Your answers + OpenAPI URL https://api.shop.example/openapi.json + Login route https://shop.example/login + Guest access 2 scopes on https://api.shop.example + orders:read, checkout + +Plan: 16 changes, 5 of them affect every app on the tenant + +Tenant settings ⚠ affects all apps + ~ CIMD support off → on + ~ iss in sign-in redirects off → on every app's redirects + ~ resource parameter profile audience → compatibility + ~ default audience none → https://api.shop.example + apps that send no audience + + company domain https://shop.example + +API https://api.shop.example + ~ scope refunds none → "Refund an order you placed." + + scope poppy:browser-sign-in "Use the website signed in, with the other + access you approve" + + scope wishlist "Keep a list of things you want." + ~ offline access off → on + ~ agent subject claims off → auth0-v1 + ~ Poppy API off → on + ~ guest policy none → require_client_grant + +App Shop Web + ~ login route none → https://shop.example/login + ~ browser join off → on + +Guest grant + + https://api.shop.example scopes: orders:read, checkout + +File + + auth0-poppy.json new diff --git a/internal/display/testdata/plan-dry-run-100.golden.txt b/internal/display/testdata/plan-dry-run-100.golden.txt new file mode 100644 index 000000000..b3d7421eb --- /dev/null +++ b/internal/display/testdata/plan-dry-run-100.golden.txt @@ -0,0 +1,52 @@ +Poppy setup for shop.example tenant tenant.us.auth0.com +Dry run: nothing will be changed. + +Checked + ✓ API https://api.shop.example + ✓ Web app Shop Web (first party) + ✓ Scopes all described (refunds, wishlist from your --scope) + ! Issuer https://tenant.us.auth0.com/ + The tenant has no verified custom domain, so agents will see the Auth0 domain + tenant.us.auth0.com as the issuer. Changing the issuer later signs every user out. + +Your answers + OpenAPI URL https://api.shop.example/openapi.json + Login route https://shop.example/login + Guest access 2 scopes on https://api.shop.example + orders:read, checkout + +Plan: 16 changes, 5 of them affect every app on the tenant + +Tenant settings ⚠ affects all apps + ~ CIMD support off → on + ~ iss in sign-in redirects off → on every app's redirects + ~ resource parameter profile audience → compatibility + ~ default audience none → https://api.shop.example + apps that send no audience + + company domain https://shop.example + +API https://api.shop.example + ~ scope refunds none → "Refund an order you placed." + + scope poppy:browser-sign-in "Use the website signed in, with the other access you approve" + + scope wishlist "Keep a list of things you want." + ~ offline access off → on + ~ agent subject claims off → auth0-v1 + ~ Poppy API off → on + ~ guest policy none → require_client_grant + +App Shop Web + ~ login route none → https://shop.example/login + ~ browser join off → on + +Guest grant + + https://api.shop.example scopes: orders:read, checkout + +File + + auth0-poppy.json new + +To apply: auth0 poppy setup --company-domain shop.example --api https://api.shop.example \ + --api-url https://api.shop.example=https://api.shop.example/openapi.json \ + --app "Shop Web" --scope "refunds=Refund an order you placed." \ + --scope "wishlist=Keep a list of things you want." \ + --login-uri https://shop.example/login --guest-api-access \ + --guest-scopes checkout,orders:read diff --git a/internal/display/testdata/plan-dry-run-60.golden.txt b/internal/display/testdata/plan-dry-run-60.golden.txt new file mode 100644 index 000000000..c561a5e4f --- /dev/null +++ b/internal/display/testdata/plan-dry-run-60.golden.txt @@ -0,0 +1,64 @@ +Poppy setup for shop.example tenant tenant.us.auth0.com +Dry run: nothing will be changed. + +Checked + ✓ API https://api.shop.example + ✓ Web app Shop Web (first party) + ✓ Scopes all described (refunds, wishlist from your + --scope) + ! Issuer https://tenant.us.auth0.com/ + The tenant has no verified custom domain, so + agents will see the Auth0 domain + tenant.us.auth0.com as the issuer. Changing + the issuer later signs every user out. + +Your answers + OpenAPI URL https://api.shop.example/openapi.json + Login route https://shop.example/login + Guest access 2 scopes on https://api.shop.example + orders:read, checkout + +Plan: 16 changes, 5 of them affect every app on the tenant + +Tenant settings ⚠ affects all apps + ~ CIMD support off → on + ~ iss in sign-in redirects off → on + every app's redirects + ~ resource parameter profile audience → compatibility + ~ default audience none → + https://api.shop.example + apps that send no audience + + company domain https://shop.example + +API https://api.shop.example + ~ scope refunds none → "Refund an order you + placed." + + scope poppy:browser-sign-in "Use the website signed in, + with the other access you + approve" + + scope wishlist "Keep a list of things you + want." + ~ offline access off → on + ~ agent subject claims off → auth0-v1 + ~ Poppy API off → on + ~ guest policy none → require_client_grant + +App Shop Web + ~ login route none → https://shop.example/login + ~ browser join off → on + +Guest grant + + https://api.shop.example scopes: orders:read, checkout + +File + + auth0-poppy.json new + +To apply: auth0 poppy setup --company-domain shop.example \ + --api https://api.shop.example \ + --api-url https://api.shop.example=https://api.shop.example/openapi.json \ + --app "Shop Web" \ + --scope "refunds=Refund an order you placed." \ + --scope "wishlist=Keep a list of things you want." \ + --login-uri https://shop.example/login \ + --guest-api-access \ + --guest-scopes checkout,orders:read diff --git a/internal/display/testdata/plan-dry-run-ascii.golden.txt b/internal/display/testdata/plan-dry-run-ascii.golden.txt new file mode 100644 index 000000000..eac3bc168 --- /dev/null +++ b/internal/display/testdata/plan-dry-run-ascii.golden.txt @@ -0,0 +1,55 @@ +Poppy setup for shop.example tenant tenant.us.auth0.com +Dry run: nothing will be changed. + +Checked + ok API https://api.shop.example + ok Web app Shop Web (first party) + ok Scopes all described (refunds, wishlist from your --scope) + ! Issuer https://tenant.us.auth0.com/ + The tenant has no verified custom domain, so agents will see the + Auth0 domain tenant.us.auth0.com as the issuer. Changing the + issuer later signs every user out. + +Your answers + OpenAPI URL https://api.shop.example/openapi.json + Login route https://shop.example/login + Guest access 2 scopes on https://api.shop.example + orders:read, checkout + +Plan: 16 changes, 5 of them affect every app on the tenant + +Tenant settings ! affects all apps + ~ CIMD support off -> on + ~ iss in sign-in redirects off -> on every app's redirects + ~ resource parameter profile audience -> compatibility + ~ default audience none -> https://api.shop.example + apps that send no audience + + company domain https://shop.example + +API https://api.shop.example + ~ scope refunds none -> "Refund an order you placed." + + scope poppy:browser-sign-in "Use the website signed in, with the other + access you approve" + + scope wishlist "Keep a list of things you want." + ~ offline access off -> on + ~ agent subject claims off -> auth0-v1 + ~ Poppy API off -> on + ~ guest policy none -> require_client_grant + +App Shop Web + ~ login route none -> https://shop.example/login + ~ browser join off -> on + +Guest grant + + https://api.shop.example scopes: orders:read, checkout + +File + + auth0-poppy.json new + +To apply: auth0 poppy setup --company-domain shop.example \ + --api https://api.shop.example \ + --api-url https://api.shop.example=https://api.shop.example/openapi.json \ + --app "Shop Web" --scope "refunds=Refund an order you placed." \ + --scope "wishlist=Keep a list of things you want." \ + --login-uri https://shop.example/login --guest-api-access \ + --guest-scopes checkout,orders:read diff --git a/internal/display/testdata/plan-dry-run-pipe.golden.txt b/internal/display/testdata/plan-dry-run-pipe.golden.txt new file mode 100644 index 000000000..f5d717e1f --- /dev/null +++ b/internal/display/testdata/plan-dry-run-pipe.golden.txt @@ -0,0 +1,46 @@ +Poppy setup for shop.example tenant tenant.us.auth0.com +Dry run: nothing will be changed. + +Checked + ✓ API https://api.shop.example + ✓ Web app Shop Web (first party) + ✓ Scopes all described (refunds, wishlist from your --scope) + ! Issuer https://tenant.us.auth0.com/ + The tenant has no verified custom domain, so agents will see the Auth0 domain tenant.us.auth0.com as the issuer. Changing the issuer later signs every user out. + +Your answers + OpenAPI URL https://api.shop.example/openapi.json + Login route https://shop.example/login + Guest access 2 scopes on https://api.shop.example + orders:read, checkout + +Plan: 16 changes, 5 of them affect every app on the tenant + +Tenant settings ⚠ affects all apps + ~ CIMD support off → on + ~ iss in sign-in redirects off → on every app's redirects + ~ resource parameter profile audience → compatibility + ~ default audience none → https://api.shop.example + apps that send no audience + + company domain https://shop.example + +API https://api.shop.example + ~ scope refunds none → "Refund an order you placed." + + scope poppy:browser-sign-in "Use the website signed in, with the other access you approve" + + scope wishlist "Keep a list of things you want." + ~ offline access off → on + ~ agent subject claims off → auth0-v1 + ~ Poppy API off → on + ~ guest policy none → require_client_grant + +App Shop Web + ~ login route none → https://shop.example/login + ~ browser join off → on + +Guest grant + + https://api.shop.example scopes: orders:read, checkout + +File + + auth0-poppy.json new + +To apply: auth0 poppy setup --company-domain shop.example --api https://api.shop.example --api-url https://api.shop.example=https://api.shop.example/openapi.json --app "Shop Web" --scope "refunds=Refund an order you placed." --scope "wishlist=Keep a list of things you want." --login-uri https://shop.example/login --guest-api-access --guest-scopes checkout,orders:read diff --git a/internal/display/testdata/plan-dry-run.golden.txt b/internal/display/testdata/plan-dry-run.golden.txt new file mode 100644 index 000000000..e32c5eb71 --- /dev/null +++ b/internal/display/testdata/plan-dry-run.golden.txt @@ -0,0 +1,55 @@ +Poppy setup for shop.example tenant tenant.us.auth0.com +Dry run: nothing will be changed. + +Checked + ✓ API https://api.shop.example + ✓ Web app Shop Web (first party) + ✓ Scopes all described (refunds, wishlist from your --scope) + ! Issuer https://tenant.us.auth0.com/ + The tenant has no verified custom domain, so agents will see the + Auth0 domain tenant.us.auth0.com as the issuer. Changing the + issuer later signs every user out. + +Your answers + OpenAPI URL https://api.shop.example/openapi.json + Login route https://shop.example/login + Guest access 2 scopes on https://api.shop.example + orders:read, checkout + +Plan: 16 changes, 5 of them affect every app on the tenant + +Tenant settings ⚠ affects all apps + ~ CIMD support off → on + ~ iss in sign-in redirects off → on every app's redirects + ~ resource parameter profile audience → compatibility + ~ default audience none → https://api.shop.example + apps that send no audience + + company domain https://shop.example + +API https://api.shop.example + ~ scope refunds none → "Refund an order you placed." + + scope poppy:browser-sign-in "Use the website signed in, with the other + access you approve" + + scope wishlist "Keep a list of things you want." + ~ offline access off → on + ~ agent subject claims off → auth0-v1 + ~ Poppy API off → on + ~ guest policy none → require_client_grant + +App Shop Web + ~ login route none → https://shop.example/login + ~ browser join off → on + +Guest grant + + https://api.shop.example scopes: orders:read, checkout + +File + + auth0-poppy.json new + +To apply: auth0 poppy setup --company-domain shop.example \ + --api https://api.shop.example \ + --api-url https://api.shop.example=https://api.shop.example/openapi.json \ + --app "Shop Web" --scope "refunds=Refund an order you placed." \ + --scope "wishlist=Keep a list of things you want." \ + --login-uri https://shop.example/login --guest-api-access \ + --guest-scopes checkout,orders:read diff --git a/internal/display/testdata/plan-no-custom-domain.golden.txt b/internal/display/testdata/plan-no-custom-domain.golden.txt new file mode 100644 index 000000000..0ed3d1c66 --- /dev/null +++ b/internal/display/testdata/plan-no-custom-domain.golden.txt @@ -0,0 +1,49 @@ +Poppy setup for shop.example tenant tenant.us.auth0.com +Dry run: nothing will be changed. + +Checked + ✓ API https://api.shop.example + ✓ Web app Shop Web (first party) + ✓ Scopes all described + ! Issuer https://tenant.us.auth0.com/ + The tenant has no verified custom domain, so agents will see the + Auth0 domain tenant.us.auth0.com as the issuer. Changing the + issuer later signs every user out. + +Your answers + OpenAPI URL https://api.shop.example/openapi.json + Guest access 3 scopes on https://api.shop.example + orders:read, checkout, poppy:browser-sign-in + +Plan: 13 changes, 5 of them affect every app on the tenant + +Tenant settings ⚠ affects all apps + ~ CIMD support off → on + ~ iss in sign-in redirects off → on every app's redirects + ~ resource parameter profile audience → compatibility + ~ default audience none → https://api.shop.example + apps that send no audience + + company domain https://shop.example + +API https://api.shop.example + + scope poppy:browser-sign-in "Use the website signed in, with the other + access you approve" + ~ offline access off → on + ~ agent subject claims off → auth0-v1 + ~ Poppy API off → on + ~ guest policy none → require_client_grant + +App Shop Web + ~ browser join off → on + +Guest grant + + https://api.shop.example scopes: orders:read, checkout, + poppy:browser-sign-in + +File + + auth0-poppy.json new + +To apply: auth0 poppy setup --company-domain shop.example \ + --api https://api.shop.example \ + --api-url https://api.shop.example=https://api.shop.example/openapi.json \ + --app "Shop Web" --guest-api-access --guest-all-scopes diff --git a/internal/display/testdata/plan-nothing-to-change.golden.txt b/internal/display/testdata/plan-nothing-to-change.golden.txt new file mode 100644 index 000000000..63826e0bf --- /dev/null +++ b/internal/display/testdata/plan-nothing-to-change.golden.txt @@ -0,0 +1,8 @@ +Poppy setup for shop.example tenant tenant.us.auth0.com + +Checked + ✓ API https://api.shop.example + ✓ Web app Shop Web (first party) + ✓ Scopes all described + +Nothing to change. Everything already matches. diff --git a/internal/display/testdata/plan-several-apis.golden.txt b/internal/display/testdata/plan-several-apis.golden.txt new file mode 100644 index 000000000..31de3fee7 --- /dev/null +++ b/internal/display/testdata/plan-several-apis.golden.txt @@ -0,0 +1,61 @@ +Poppy setup for shop.example tenant tenant.us.auth0.com +Dry run: nothing will be changed. + +Checked + ✓ API https://api.shop.example + ✓ API https://pay.shop.example + ✓ Web app Shop Web (first party) + ✓ Scopes all described + ! Issuer https://tenant.us.auth0.com/ + The tenant has no verified custom domain, so agents will see the + Auth0 domain tenant.us.auth0.com as the issuer. Changing the + issuer later signs every user out. + +Your answers + OpenAPI URL https://api.shop.example/openapi.json (https://api.shop.example) + https://pay.shop.example/openapi.json (https://pay.shop.example) + Guest access 3 scopes on https://api.shop.example + orders:read, checkout, poppy:browser-sign-in + 2 scopes on https://pay.shop.example + pay, poppy:browser-sign-in + +Plan: 18 changes, 4 of them affect every app on the tenant + +Tenant settings ⚠ affects all apps + ~ CIMD support off → on + ~ iss in sign-in redirects off → on every app's redirects + ~ resource parameter profile audience → compatibility + + company domain https://shop.example + +API https://api.shop.example + + scope poppy:browser-sign-in "Use the website signed in, with the other + access you approve" + ~ offline access off → on + ~ agent subject claims off → auth0-v1 + ~ Poppy API off → on + ~ guest policy none → require_client_grant + +API https://pay.shop.example + + scope poppy:browser-sign-in "Use the website signed in, with the other + access you approve" + ~ offline access off → on + ~ agent subject claims off → auth0-v1 + ~ Poppy API off → on + ~ guest policy none → require_client_grant + +App Shop Web + ~ browser join off → on + +Guest grant + + https://api.shop.example scopes: orders:read, checkout, + poppy:browser-sign-in + + https://pay.shop.example scopes: pay, poppy:browser-sign-in + +File + + auth0-poppy.json new + +To apply: auth0 poppy setup --company-domain shop.example \ + --api https://api.shop.example --api https://pay.shop.example \ + --api-url https://api.shop.example=https://api.shop.example/openapi.json \ + --api-url https://pay.shop.example=https://pay.shop.example/openapi.json \ + --app "Shop Web" --guest-api-access --guest-all-scopes diff --git a/internal/display/testdata/plan-unchanged-collapsed.golden.txt b/internal/display/testdata/plan-unchanged-collapsed.golden.txt new file mode 100644 index 000000000..6a15612a8 --- /dev/null +++ b/internal/display/testdata/plan-unchanged-collapsed.golden.txt @@ -0,0 +1,25 @@ +Poppy setup for shop.example tenant tenant.us.auth0.com +Dry run: nothing will be changed. + +Checked + ✓ API https://api.shop.example + ✓ Web app Shop Web (first party) + ✓ Scopes all described + +Your answers + Guest access none + +Plan: 2 changes, 1 of them affects every app on the tenant + +Tenant settings ⚠ affects all apps + ~ CIMD support off → on + +App Shop Web + ~ browser join off → on + +Already correct: 8 settings (iss in sign-in redirects, resource parameter + profile, default audience, company domain, offline access, agent subject + claims, Poppy API, guest policy) + +To apply: auth0 poppy setup --company-domain shop.example \ + --api https://api.shop.example --app "Shop Web" diff --git a/internal/display/testdata/plan-unknown-before.golden.txt b/internal/display/testdata/plan-unknown-before.golden.txt new file mode 100644 index 000000000..f68fbe6e1 --- /dev/null +++ b/internal/display/testdata/plan-unknown-before.golden.txt @@ -0,0 +1,22 @@ +Poppy setup for shop.example tenant tenant.us.auth0.com +Dry run: nothing will be changed. + +Checked + ✓ API https://api.shop.example + ✓ Web app Shop Web (first party) + ✓ Scopes all described + ! Note Something else to look at. + +Your answers + Guest access none + +Plan: 3 changes + +App Shop Web + ~ login route none → https://shop.example/login + ~ browser join ? → on + +File + ~ auth0-poppy.json updated + +To apply: auth0 poppy setup diff --git a/internal/display/testdata/report-ascii.golden.txt b/internal/display/testdata/report-ascii.golden.txt new file mode 100644 index 000000000..68c727e4c --- /dev/null +++ b/internal/display/testdata/report-ascii.golden.txt @@ -0,0 +1,13 @@ +Doctor for shop.example protocol 0.3 +Issuer https://shop.example/ + +Auth0 issuer + ok issuer matches https://shop.example/ + FAIL protected_resources does not list https://shop.example + FIX Run auth0 poppy setup --company-domain shop.example + +Tenant + - skipped: you are logged in to a different tenant than the one that serves + shop.example. Log in to that tenant to run these checks. + +1 failed, 1 passed, 1 skipped. diff --git a/internal/display/testdata/report-escapes.golden.txt b/internal/display/testdata/report-escapes.golden.txt new file mode 100644 index 000000000..1ec60923e --- /dev/null +++ b/internal/display/testdata/report-escapes.golden.txt @@ -0,0 +1,9 @@ +Doctor for shop.example protocol 0.3 +Issuer https://shop.example/ + +Scopes + FAIL scope \x1b[2J\x1b[31mred forged line + FIX run \x1b]0;title\x07 + then \x0d check + +1 failed, 0 passed. diff --git a/internal/display/testdata/report-fail.golden.txt b/internal/display/testdata/report-fail.golden.txt new file mode 100644 index 000000000..1e72579ed --- /dev/null +++ b/internal/display/testdata/report-fail.golden.txt @@ -0,0 +1,15 @@ +Doctor for shop.example protocol 0.3 +Issuer https://shop.example/ + +Auth0 issuer + ✓ issuer matches https://shop.example/ + FAIL protected_resources does not list https://shop.example + FIX Run auth0 poppy setup --company-domain shop.example + FAIL client assertions: PS384 not accepted by the issuer + FIX PS384 needs the HRI SKU. Ask support if you need it (Auth0 must fix this) + +Tenant + - skipped: you are logged in to a different tenant than the one that serves + shop.example. Log in to that tenant to run these checks. + +2 failed, 1 passed, 1 skipped. diff --git a/internal/display/testdata/report-green.golden.txt b/internal/display/testdata/report-green.golden.txt new file mode 100644 index 000000000..c2b06ff11 --- /dev/null +++ b/internal/display/testdata/report-green.golden.txt @@ -0,0 +1,38 @@ +Doctor for shop.example protocol 0.4 +Issuer https://auth.shop.example/ + +Website files + ✓ poppy.json is served over HTTPS at /.well-known/poppy.json + ✓ protocol version 0.4 is one this CLI understands + ✓ organization.domain is the serving host + ✓ every URL in poppy.json is HTTPS + ✓ the protected resource is https://shop.example + ✓ the protected resource metadata names the issuer + +Auth0 issuer + ✓ issuer matches https://auth.shop.example/ + ✓ protected_resources lists https://shop.example + ✓ token, revocation, authorization and device endpoints are HTTPS + ✓ supports the grants agents use (authorization code, refresh token, device + code) + ✓ returns iss in sign-in responses + ✓ lists every scope of poppy.json + ✓ accepts ES256 client assertions + ✓ accepts PS384 client assertions + +Scopes + ✓ every scope has a description + ✓ every scope has valid hints + +Browser sign-in + ✓ the join page has a hidden code input + ✓ the join cookie has the required attributes + ✓ the join response is not cached + ✓ the web settings are valid + ✓ poppy:browser-sign-in is listed in auth.scopes + +API + ✓ https://api.shop.example/openapi.json is OpenAPI 3.1.0 + ✓ mcp endpoint listed: https://mcp.shop.example/mcp + +23 passed. diff --git a/internal/display/testdata/report-green.json b/internal/display/testdata/report-green.json new file mode 100644 index 000000000..527303e22 --- /dev/null +++ b/internal/display/testdata/report-green.json @@ -0,0 +1,145 @@ +{ + "domain": "shop.example", + "protocol_version": "0.4", + "issuer": "https://auth.shop.example/", + "results": [ + { + "id": "D1", + "spec": "3", + "level": "pass", + "message": "poppy.json is served over HTTPS at /.well-known/poppy.json" + }, + { + "id": "D1", + "spec": "3", + "level": "pass", + "message": "protocol version 0.4 is one this CLI understands" + }, + { + "id": "D1", + "spec": "3.1", + "level": "pass", + "message": "organization.domain is the serving host" + }, + { + "id": "D1", + "spec": "3.1", + "level": "pass", + "message": "every URL in poppy.json is HTTPS" + }, + { + "id": "D2", + "spec": "3.2", + "level": "pass", + "message": "the protected resource is https://shop.example" + }, + { + "id": "D2", + "spec": "3.2", + "level": "pass", + "message": "the protected resource metadata names the issuer" + }, + { + "id": "D3", + "spec": "3.2", + "level": "pass", + "message": "issuer matches https://auth.shop.example/" + }, + { + "id": "D3", + "spec": "3.2", + "level": "pass", + "message": "protected_resources lists https://shop.example" + }, + { + "id": "D3", + "spec": "4.2", + "level": "pass", + "message": "token, revocation, authorization and device endpoints are HTTPS" + }, + { + "id": "D3", + "spec": "4.2", + "level": "pass", + "message": "supports the grants agents use (authorization code, refresh token, device code)" + }, + { + "id": "D3", + "spec": "4.2", + "level": "pass", + "message": "returns iss in sign-in responses" + }, + { + "id": "D3", + "spec": "4.2", + "level": "pass", + "message": "lists every scope of poppy.json" + }, + { + "id": "D4", + "spec": "4.1", + "level": "pass", + "message": "accepts ES256 client assertions" + }, + { + "id": "D4", + "spec": "4.1", + "level": "pass", + "message": "accepts PS384 client assertions" + }, + { + "id": "D5", + "spec": "4.7", + "level": "pass", + "message": "every scope has a description" + }, + { + "id": "D5", + "spec": "4.7", + "level": "pass", + "message": "every scope has valid hints" + }, + { + "id": "D6", + "spec": "6", + "level": "pass", + "message": "the join page has a hidden code input" + }, + { + "id": "D6", + "spec": "6", + "level": "pass", + "message": "the join cookie has the required attributes" + }, + { + "id": "D6", + "spec": "6", + "level": "pass", + "message": "the join response is not cached" + }, + { + "id": "D7", + "spec": "7", + "level": "pass", + "message": "https://api.shop.example/openapi.json is OpenAPI 3.1.0" + }, + { + "id": "D7", + "spec": "7", + "level": "pass", + "message": "mcp endpoint listed: https://mcp.shop.example/mcp" + }, + { + "id": "D15", + "spec": "3.1", + "level": "pass", + "message": "the web settings are valid" + }, + { + "id": "D16", + "spec": "4.7", + "level": "pass", + "message": "poppy:browser-sign-in is listed in auth.scopes" + } + ] +} diff --git a/internal/display/testdata/report-no-header-details.golden.txt b/internal/display/testdata/report-no-header-details.golden.txt new file mode 100644 index 000000000..ebfa872db --- /dev/null +++ b/internal/display/testdata/report-no-header-details.golden.txt @@ -0,0 +1,6 @@ +Doctor for shop.example + +Auth0 issuer + ✓ issuer matches https://shop.example/ + +1 passed. diff --git a/internal/display/testdata/report-other.golden.txt b/internal/display/testdata/report-other.golden.txt new file mode 100644 index 000000000..c4fe03480 --- /dev/null +++ b/internal/display/testdata/report-other.golden.txt @@ -0,0 +1,11 @@ +Doctor for shop.example protocol 0.3 +Issuer https://shop.example/ + +Auth0 issuer + ✓ issuer matches https://shop.example/ + +Other + ✓ a check this CLI does not know + WARN a result without an ID + +1 warning, 2 passed. diff --git a/internal/display/testdata/report-skipped-tenant.golden.txt b/internal/display/testdata/report-skipped-tenant.golden.txt new file mode 100644 index 000000000..d5b67dcd3 --- /dev/null +++ b/internal/display/testdata/report-skipped-tenant.golden.txt @@ -0,0 +1,11 @@ +Doctor for shop.example protocol 0.3 +Issuer https://shop.example/ + +Auth0 issuer + ✓ issuer matches https://shop.example/ + +Tenant + - skipped: you are logged in to a different tenant than the one that serves + shop.example. Log in to that tenant to run these checks. + +1 passed, 1 skipped. diff --git a/internal/display/testdata/report-warn.golden.txt b/internal/display/testdata/report-warn.golden.txt new file mode 100644 index 000000000..07c5c0df8 --- /dev/null +++ b/internal/display/testdata/report-warn.golden.txt @@ -0,0 +1,11 @@ +Doctor for shop.example protocol 0.3 +Issuer https://shop.example/ + +Auth0 issuer + ✓ issuer matches https://shop.example/ + +Scopes + WARN scope_hints missing for checkout + FIX auth0 poppy setup --hint checkout=changes:,personal_data: + +1 warning, 1 passed. diff --git a/internal/display/testdata/report-width-100.golden.txt b/internal/display/testdata/report-width-100.golden.txt new file mode 100644 index 000000000..eec01be13 --- /dev/null +++ b/internal/display/testdata/report-width-100.golden.txt @@ -0,0 +1,19 @@ +Doctor for a-very-long-company-domain.example.com protocol 0.3 +Issuer https://login.a-very-long-company-domain.example.com/ + +Website files + ✓ poppy.json is served over HTTPS at /.well-known/poppy.json + +Browser sign-in + WARN web.browser_session_endpoint is not published, so agents cannot join a browser session + +API + FAIL https://api.shop.example/poppy/openapi.json returned HTTP 503, and the document must be + served with status 200 so that agents can read it + FIX Serve the document at https://api.shop.example/poppy/openapi.json with status 200. + +Tenant + - skipped: you are logged in to a different tenant than the one that serves shop.example. Log in + to that tenant to run these checks. + +1 failed, 1 warning, 1 passed, 1 skipped. diff --git a/internal/display/testdata/report-width-60.golden.txt b/internal/display/testdata/report-width-60.golden.txt new file mode 100644 index 000000000..5d228bef7 --- /dev/null +++ b/internal/display/testdata/report-width-60.golden.txt @@ -0,0 +1,23 @@ +Doctor for a-very-long-company-domain.example.com protocol 0.3 +Issuer https://login.a-very-long-company-domain.example.com/ + +Website files + ✓ poppy.json is served over HTTPS at + /.well-known/poppy.json + +Browser sign-in + WARN web.browser_session_endpoint is not published, so + agents cannot join a browser session + +API + FAIL https://api.shop.example/poppy/openapi.json returned + HTTP 503, and the document must be served with status + 200 so that agents can read it + FIX Serve the document at https://api.shop.example/poppy/openapi.json with status 200. + +Tenant + - skipped: you are logged in to a different tenant than + the one that serves shop.example. Log in to that tenant + to run these checks. + +1 failed, 1 warning, 1 passed, 1 skipped. From db695877ee27fcf00a48d10878b1190b93400059 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Fri, 6 Nov 2026 07:10:37 +0530 Subject: [PATCH 2/4] fix(poppy): say what agents remove does to refresh tokens Deleting the agent app revokes its refresh tokens, and keeping the app does not. The help text, the confirmation prompts and the result of `auth0 poppy agents remove` now say which of the two happened, instead of saying the command never revokes tokens. Access tokens already issued still stay valid until they expire. --- docs/auth0_poppy_agents_remove.md | 2 +- internal/cli/poppy_agents.go | 10 +++++----- internal/cli/poppy_agents_test.go | 2 +- internal/display/poppy_agents.go | 9 +++++++-- internal/display/poppy_agents_test.go | 12 +++++++++++- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/auth0_poppy_agents_remove.md b/docs/auth0_poppy_agents_remove.md index a25e20e54..87a835ee4 100644 --- a/docs/auth0_poppy_agents_remove.md +++ b/docs/auth0_poppy_agents_remove.md @@ -5,7 +5,7 @@ has_toc: false --- # auth0 poppy agents remove -(Experimental) Removes the agent's Poppy access: its grants to your Poppy APIs. It changes the tenant only, and does not touch the config file. When the agent has no other grants it also removes the agent record and the app, so registering the agent again creates a new app and its users must sign in again. When the app has other grants, only the Poppy access is removed and the other grants are listed, unless you pass `--remove-all-grants`; in a terminal you are asked which to do. This command does not revoke tokens already issued: access tokens stay valid until they expire. Use `--dry-run` to print what would be removed, and `--force` to skip the confirmation. +(Experimental) Removes the agent's Poppy access: its grants to your Poppy APIs. It changes the tenant only, and does not touch the config file. When the agent has no other grants it also removes the agent record and the app, so registering the agent again creates a new app and its users must sign in again. When the app has other grants, only the Poppy access is removed and the other grants are listed, unless you pass `--remove-all-grants`; in a terminal you are asked which to do. Deleting the app revokes its refresh tokens; when the app is kept, its refresh tokens are not revoked. Access tokens already issued stay valid until they expire. Use `--dry-run` to print what would be removed, and `--force` to skip the confirmation. **Warning:** This command is experimental and is subject to change in future versions. diff --git a/internal/cli/poppy_agents.go b/internal/cli/poppy_agents.go index b6167dd98..1b78ddf61 100644 --- a/internal/cli/poppy_agents.go +++ b/internal/cli/poppy_agents.go @@ -181,9 +181,9 @@ func poppyAgentsRemoveCmd(cli *cli) *cobra.Command { "and does not touch the config file. When the agent has no other grants it also removes the agent record and the app, so registering " + "the agent again creates a new app and its users must sign in again. When the app has other grants, " + "only the Poppy access is removed and the other grants are listed, unless you pass `--remove-all-grants`; " + - "in a terminal you are asked which to do. This command does not revoke tokens already issued: access " + - "tokens stay valid until they expire. Use `--dry-run` to print what would be removed, and `--force` " + - "to skip the confirmation."), + "in a terminal you are asked which to do. Deleting the app revokes its refresh tokens; when the app is kept, " + + "its refresh tokens are not revoked. Access tokens already issued stay valid until they expire. " + + "Use `--dry-run` to print what would be removed, and `--force` to skip the confirmation."), Example: ` auth0 poppy agents remove https://muse.example/agent.json auth0 poppy agents remove https://muse.example/agent.json --dry-run auth0 poppy agents remove --remove-all-grants --force`, @@ -642,7 +642,7 @@ func runPoppyAgentsRemove(cmd *cobra.Command, cli *cli, inputs poppyRemoveInputs func askPoppyRemoval(screen *poppyScreen, choose bool) (proceed, everything bool, err error) { if !choose { proceed, err = screen.confirmAsk("Remove the agent", - "This removes the grants of the agent, and its record and app. Tokens already issued stay valid until they expire.", "Remove this agent?") + "This removes the grants of the agent, and its record and app. Deleting the app revokes its refresh tokens. Access tokens already issued stay valid until they expire.", "Remove this agent?") return proceed, true, err } @@ -658,7 +658,7 @@ func askPoppyRemoval(screen *poppyScreen, choose bool) (proceed, everything bool var picked int err = screen.ask("Other grants of the agent app", - "The agent app has grants to APIs other than Poppy ones. Removing only the Poppy access leaves the app and those grants.", func() (err error) { + "The agent app has grants to APIs other than Poppy ones. Removing only the Poppy access leaves the app and those grants, and does not revoke the refresh tokens of the app.", func() (err error) { picked, err = screen.asker.choose(screen, "What should be removed?", options, 0) return err diff --git a/internal/cli/poppy_agents_test.go b/internal/cli/poppy_agents_test.go index 0266656b4..58b63c683 100644 --- a/internal/cli/poppy_agents_test.go +++ b/internal/cli/poppy_agents_test.go @@ -476,7 +476,7 @@ func TestPoppyAgentsRemove(t *testing.T) { " ok agent record "+poppyTestAgentID+"\n"+ " ok agent app "+poppyTestClientID+"\n"+ "\n"+ - " ! This command does not revoke tokens already issued: access tokens stay valid until they expire.\n"+ + " ! The refresh tokens of the app were revoked with it. Access tokens already issued stay valid until they expire.\n"+ " ! Registering the agent again creates a new app, so its users must sign in again.\n", h.out.String()) assert.Empty(t, h.messages.String()) diff --git a/internal/display/poppy_agents.go b/internal/display/poppy_agents.go index ba46be6d5..d7e82db36 100644 --- a/internal/display/poppy_agents.go +++ b/internal/display/poppy_agents.go @@ -333,8 +333,13 @@ func (r *Renderer) PoppyRemoved(result *agents.RemoveResult, gone bool) { r.PoppyRemoval(PoppyRemovalDone, result) var notes []poppyEntry - if !gone { - notes = append(notes, poppyEntry{mark: markWarning, value: "This command does not revoke tokens already issued: access tokens stay valid until they expire."}) + + switch { + case gone: + case len(result.Kept) == 0: + notes = append(notes, poppyEntry{mark: markWarning, value: "The refresh tokens of the app were revoked with it. Access tokens already issued stay valid until they expire."}) + default: + notes = append(notes, poppyEntry{mark: markWarning, value: "The agent app was kept, so its refresh tokens are not revoked. To cut the agent off completely, remove everything with --remove-all-grants. Access tokens already issued stay valid until they expire."}) } if len(result.Kept) == 0 && !gone { diff --git a/internal/display/poppy_agents_test.go b/internal/display/poppy_agents_test.go index 30433e60d..556631bac 100644 --- a/internal/display/poppy_agents_test.go +++ b/internal/display/poppy_agents_test.go @@ -278,10 +278,20 @@ func TestPoppyRemoval(t *testing.T) { r, out, _ := newAgentRenderer(OutputFormat("")) r.PoppyRemoved(&agents.RemoveResult{Removed: []string{"the agent app"}}, false) - assert.Contains(t, out.String(), "! This command does not revoke tokens already issued") + assert.Contains(t, out.String(), "! The refresh tokens of the app were revoked with it. Access tokens already issued stay valid until they expire.") assert.Contains(t, out.String(), "! Registering the agent again creates a new app") }) + t.Run("a kept app says its refresh tokens are not revoked", func(t *testing.T) { + r, out, _ := newAgentRenderer(OutputFormat("")) + r.PoppyRemoved(&agents.RemoveResult{Removed: []string{"client grant cgr_1"}, Kept: []string{"agent app app_1"}}, false) + + assert.Contains(t, out.String(), "! The agent app was kept, so its refresh tokens are not revoked.") + assert.Contains(t, out.String(), "--remove-all-grants") + assert.NotContains(t, out.String(), "were revoked with it") + assert.NotContains(t, out.String(), "Registering the agent again") + }) + t.Run("an agent that was gone gets no such notes", func(t *testing.T) { r, out, _ := newAgentRenderer(OutputFormat("")) r.PoppyRemoved(&agents.RemoveResult{}, true) From 888dcf730274713f2d4790e4a58ffcf1a8bda02b Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Fri, 6 Nov 2026 07:36:05 +0530 Subject: [PATCH 3/4] fix(poppy): tidy prompts, row counting and issuer check - Remove the run header when a text or confirm prompt is interrupted. - Erase question lines only when stderr is a terminal too. - Count wide and fullwidth characters as two cells when wrapping. - Check the config file issuer before asking the hint questions. - Split WriteString calls that concatenate and use strings.FieldsSeq. --- internal/cli/poppy.go | 38 +++++-- internal/cli/poppy_test.go | 90 +++++++++++++++++ internal/display/poppy.go | 137 +++++++++++++++++--------- internal/display/poppy_agents.go | 7 +- internal/display/poppy_layout.go | 103 +++++++++++++------ internal/display/poppy_layout_test.go | 65 ++++++++++++ internal/iostream/iostream.go | 6 ++ internal/poppy/agents/register.go | 6 +- internal/poppy/config/merge.go | 15 ++- internal/poppy/setup/command.go | 3 +- internal/poppy/setup/derive.go | 8 ++ internal/poppy/setup/derive_test.go | 41 ++++++++ internal/poppy/setup/inputs.go | 3 +- 13 files changed, 430 insertions(+), 92 deletions(-) diff --git a/internal/cli/poppy.go b/internal/cli/poppy.go index eed6bfb1b..97f5310e7 100644 --- a/internal/cli/poppy.go +++ b/internal/cli/poppy.go @@ -20,6 +20,7 @@ import ( "github.com/auth0/auth0-cli/internal/ansi" "github.com/auth0/auth0-cli/internal/auth0" "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/iostream" "github.com/auth0/auth0-cli/internal/poppy/config" "github.com/auth0/auth0-cli/internal/poppy/discovery" "github.com/auth0/auth0-cli/internal/poppy/doctor" @@ -52,7 +53,7 @@ type poppyAsker struct { var poppyAsk = poppyAsker{ canPrompt: canPrompt, - erasable: display.PoppyCanErase, + erasable: poppyEraseHook(display.PoppyCanErase, iostream.IsMessagesTerminal), interrupt: handleInputError, text: terminalPoppyText, confirm: terminalPoppyConfirm, @@ -61,6 +62,15 @@ var poppyAsk = poppyAsker{ guest: terminalPoppyGuestPrompts, } +// poppyEraseHook makes the hook that tells whether the lines of a question can be +// taken back. The blocks and the erase sequences go to the writer, but survey +// draws the questions on stderr. When stderr is redirected the rows of the +// questions are in a file, and counting them would erase lines that are not ours, +// so the writer must be erasable and stderr a terminal as well. +func poppyEraseHook(writer func(w io.Writer) bool, stderr func() bool) func(w io.Writer) bool { + return func(w io.Writer) bool { return writer(w) && stderr() } +} + // errPoppyDoctorFailed is returned after the report is printed when a doctor check failed. var errPoppyDoctorFailed = errors.New("some doctor checks failed") @@ -460,6 +470,11 @@ func runPoppySetup(cmd *cobra.Command, cli *cli, flags setup.Flags, opts poppySe return err } + // A file of another issuer is refused before any question is asked. + if err := setup.CheckIssuer(state, in); err != nil { + return poppySetupError(err) + } + if err := setup.Check(state, in); err != nil { if !poppyAsk.canPrompt(cmd) || !isPoppyScopeDescriptionRequired(err) { return poppySetupError(err) @@ -1021,6 +1036,11 @@ func runPoppyConfig(cmd *cobra.Command, cli *cli, flags setup.Flags) error { return poppySetupError(err) } + // A file of another issuer is refused before any hint is asked. + if err := setup.CheckIssuer(state, in); err != nil { + return poppySetupError(err) + } + unhinted := 0 if poppyAsk.canPrompt(cmd) { if unhinted, err = askPoppyHints(screen, setup.HintScopes(state, in, false), &in); err != nil { @@ -1293,8 +1313,19 @@ func (s *poppyScreen) leftOnScreen(rows ...string) { // terminalPoppyText asks for a line of text, with a default that Enter accepts. func terminalPoppyText(s *poppyScreen, label, def string) (string, error) { + question := " " + label + " " + if def != "" { + question += "(" + def + ") " + } + var answer string if err := s.askPrompt(&survey.Input{Message: label, Default: def}, &answer); err != nil { + // Ctrl-C ends the line, so the cursor is on the row below the question. + // What was typed is not known, only the question is counted. + if errors.Is(err, terminal.InterruptErr) { + s.AddRows(s.Rows(question)) + } + return "", err } @@ -1305,11 +1336,6 @@ func terminalPoppyText(s *poppyScreen, label, def string) (string, error) { typed = "" } - question := " " + label + " " - if def != "" { - question += "(" + def + ") " - } - s.AddRows(s.Rows(question+typed) - s.Rows(question)) return answer, nil diff --git a/internal/cli/poppy_test.go b/internal/cli/poppy_test.go index 191fe384a..116c6e7dc 100644 --- a/internal/cli/poppy_test.go +++ b/internal/cli/poppy_test.go @@ -1309,6 +1309,96 @@ func TestPoppyConfigErrors(t *testing.T) { }) } +func TestPoppyEraseHook(t *testing.T) { + tests := []struct { + name string + stdout bool + stderr bool + wantErase bool + }{ + {name: "both are terminals", stdout: true, stderr: true, wantErase: true}, + {name: "stderr is redirected", stdout: true, stderr: false}, + {name: "stdout is not a terminal", stdout: false, stderr: true}, + {name: "neither is a terminal", stdout: false, stderr: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + hook := poppyEraseHook( + func(io.Writer) bool { return test.stdout }, + func() bool { return test.stderr }, + ) + + assert.Equal(t, test.wantErase, hook(io.Discard)) + }) + } +} + +// TestPoppyIssuerMismatchAsksNothing checks that a file of another issuer is +// refused before the first question, not after the last one. +func TestPoppyIssuerMismatchAsksNothing(t *testing.T) { + // The golden file has the issuer of a custom domain, and these tenants have none. + configured := func() poppyTenantState { state := configuredPoppyTenant(); state.domains = nil; return state } + fresh := func() poppyTenantState { state := freshPoppyTenant(); state.domains = nil; return state } + + setupArgs := func(t *testing.T, extra ...string) []string { + return freshSetupArgs(t, append([]string{"--out", goldenPoppyConfig(t), "--skip-doctor"}, extra...)...) + } + // Hints that the file lacks are what config would ask about. + configArgs := func(t *testing.T, extra ...string) []string { + path := goldenPoppyConfig(t) + + cfg, err := config.Read(path) + require.NoError(t, err) + + for name, scope := range cfg.Scopes { + scope.Hints = nil + cfg.Scopes[name] = scope + } + require.NoError(t, config.Write(path, cfg)) + + return append([]string{ + "config", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, "--out", path, + }, extra...) + } + + tests := []struct { + name string + tenant func() poppyTenantState + args func(t *testing.T, extra ...string) []string + }{ + {name: "setup", tenant: fresh, args: setupArgs}, + {name: "config", tenant: configured, args: configArgs}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Run("no question is asked", func(t *testing.T) { + h := newPoppySetupHarness(t, test.tenant()) + h.allowWrites() + + run := h.runPromptMode(t, modeTerminal, test.args(t)) + + assert.Empty(t, run.asked, "questions asked") + + reason, _ := decodeCLIError(t, run.err) + assert.Equal(t, "issuer_mismatch", reason) + assert.ErrorIs(t, run.err, config.ErrIssuerMismatch) + assert.Contains(t, run.err.Error(), "Pass --force-config to overwrite it") + }) + + t.Run("--force-config goes on", func(t *testing.T) { + h := newPoppySetupHarness(t, test.tenant()) + h.allowWrites() + + run := h.runPromptMode(t, modeTerminal, test.args(t, "--force-config")) + + require.NoError(t, run.err) + }) + }) + } +} + func TestPoppySetupError(t *testing.T) { plain := errors.New("plain") diff --git a/internal/display/poppy.go b/internal/display/poppy.go index 98a65ef0c..13d2f89ca 100644 --- a/internal/display/poppy.go +++ b/internal/display/poppy.go @@ -6,7 +6,6 @@ import ( "maps" "slices" "strings" - "unicode/utf8" "github.com/auth0/auth0-cli/internal/ansi" "github.com/auth0/auth0-cli/internal/poppy/config" @@ -122,7 +121,8 @@ func poppyScopesNoun(count int) string { func poppyNext(l PoppyLayout, steps ...[2]string) string { var b strings.Builder - b.WriteString(ansi.Bold("Next") + "\n") + b.WriteString(ansi.Bold("Next")) + b.WriteString("\n") for _, step := range steps { b.WriteString(l.hang(" ", " ", step[0]+" "+step[1])) @@ -308,7 +308,7 @@ func poppyHintNote(hints *config.Hints) string { func (s *PoppyScreen) ScopeRows(scopes []PoppyScopeChoice) []string { nameWidth := 0 for _, scope := range scopes { - nameWidth = max(nameWidth, utf8.RuneCountInString(text.Line(scope.Name))) + nameWidth = max(nameWidth, poppyCells(text.Line(scope.Name))) } // The room taken by the focus mark, the checkbox and the gaps. @@ -322,12 +322,12 @@ func (s *PoppyScreen) ScopeRows(scopes []PoppyScopeChoice) []string { description := text.Line(scope.Description) if note != "" { - description = text.Cap(description, max(room-utf8.RuneCountInString(note)-1, poppyMinWidth/2)) + " " + note + description = text.Cap(description, max(room-poppyCells(note)-1, poppyMinWidth/2)) + " " + note } else { description = text.Cap(description, max(room, poppyMinWidth/2)) } - rows[i] = name + strings.Repeat(" ", nameWidth-utf8.RuneCountInString(name)+2) + description + rows[i] = name + strings.Repeat(" ", nameWidth-poppyCells(name)+2) + description } return rows @@ -489,14 +489,16 @@ func doctorHeader(b *strings.Builder, l PoppyLayout, r doctor.Report) { if r.ProtocolVersion != "" { right := "protocol " + text.Cap(text.Line(r.ProtocolVersion), poppyMaxMessageRunes) - gap := l.lineWidth() - utf8.RuneCountInString(left) - utf8.RuneCountInString(right) + gap := l.lineWidth() - poppyCells(left) - poppyCells(right) b.WriteString(strings.Repeat(" ", max(gap, 1))) b.WriteString(right) } b.WriteByte('\n') if r.Issuer != "" { - b.WriteString("Issuer " + text.Cap(text.Line(r.Issuer), poppyMaxMessageRunes) + "\n") + b.WriteString("Issuer ") + b.WriteString(text.Cap(text.Line(r.Issuer), poppyMaxMessageRunes)) + b.WriteString("\n") } } @@ -513,12 +515,18 @@ func doctorWriteResult(b *strings.Builder, l PoppyLayout, e doctorEntry) { message = label + ": " + message } - pad := strings.Repeat(" ", len(doctorIndent)+utf8.RuneCountInString(symbol)+1) + pad := strings.Repeat(" ", len(doctorIndent)+poppyCells(symbol)+1) lines := l.wrap(message, len(pad)) - b.WriteString(doctorIndent + colored + " " + lines[0] + "\n") + b.WriteString(doctorIndent) + b.WriteString(colored) + b.WriteString(" ") + b.WriteString(lines[0]) + b.WriteString("\n") for _, line := range lines[1:] { - b.WriteString(pad + line + "\n") + b.WriteString(pad) + b.WriteString(line) + b.WriteString("\n") } if e.Fix == "" { @@ -528,7 +536,8 @@ func doctorWriteResult(b *strings.Builder, l PoppyLayout, e doctorEntry) { if e.PlatformDependency { fix += doctorPlatformNote } - b.WriteString(pad + doctorFixLabel) + b.WriteString(pad) + b.WriteString(doctorFixLabel) b.WriteString(strings.ReplaceAll(fix, "\n", "\n"+pad+strings.Repeat(" ", len(doctorFixLabel)))) b.WriteByte('\n') } @@ -574,14 +583,17 @@ func (r *Renderer) PoppySetupResult(plan setup.Plan, report *doctor.Report, opts var b strings.Builder if !plan.Empty() { - b.WriteString("\n" + poppyApplyText(l, plan, setup.Outcome{Done: len(plan.Changes)}, opts)) + b.WriteString("\n") + b.WriteString(poppyApplyText(l, plan, setup.Outcome{Done: len(plan.Changes)}, opts)) } if report != nil { - b.WriteString("\n" + poppyDoctorAfterSetup(l, *report)) + b.WriteString("\n") + b.WriteString(poppyDoctorAfterSetup(l, *report)) } - b.WriteString("\n" + poppySetupNext(l, opts.Inputs.CompanyDomain, opts.Inputs.Out, opts.Unhinted)) + b.WriteString("\n") + b.WriteString(poppySetupNext(l, opts.Inputs.CompanyDomain, opts.Inputs.Out, opts.Unhinted)) return b.String() }) @@ -593,11 +605,15 @@ func (r *Renderer) PoppySetupResult(plan setup.Plan, report *doctor.Report, opts func poppyDoctorAfterSetup(l PoppyLayout, report doctor.Report) string { var b strings.Builder - b.WriteString(ansi.Bold("Checking your setup (auth0 poppy doctor)") + "\n") - b.WriteString(" " + report.Summary() + "\n") + b.WriteString(ansi.Bold("Checking your setup (auth0 poppy doctor)")) + b.WriteString("\n") + b.WriteString(" ") + b.WriteString(report.Summary()) + b.WriteString("\n") if problems := doctorProblems(l, report, " "); problems != "" { - b.WriteString("\n" + problems) + b.WriteString("\n") + b.WriteString(problems) } b.WriteString("\n") @@ -642,7 +658,9 @@ func poppyPlanText(l PoppyLayout, p setup.Plan, opts PoppySetupOptions) string { b.WriteString("\n") b.WriteString(poppyAnswers(l, p, opts.Inputs)) - b.WriteString("\n" + poppySummary(p) + "\n") + b.WriteString("\n") + b.WriteString(poppySummary(p)) + b.WriteString("\n") for _, group := range poppyGroups(l, p) { b.WriteString("\n") @@ -661,7 +679,9 @@ func poppyPlanText(l PoppyLayout, p setup.Plan, opts PoppySetupOptions) string { } if opts.DryRun { - b.WriteString("\n" + poppyRerun(l, poppyToApplyLabel, opts) + "\n") + b.WriteString("\n") + b.WriteString(poppyRerun(l, poppyToApplyLabel, opts)) + b.WriteString("\n") } return b.String() @@ -678,9 +698,12 @@ func poppyHangAfter(l PoppyLayout, s, indent string) string { lines := l.wrap(s, len(indent)) var b strings.Builder - b.WriteString(lines[0] + "\n") + b.WriteString(lines[0]) + b.WriteString("\n") for _, line := range lines[1:] { - b.WriteString(indent + line + "\n") + b.WriteString(indent) + b.WriteString(line) + b.WriteString("\n") } return b.String() @@ -724,22 +747,26 @@ type poppyLabeled struct { func poppyWriteLabeled(b *strings.Builder, l PoppyLayout, labelWidth int, lines []poppyLabeled) { for _, line := range lines { symbol, colored := l.mark(line.mark) - pad := strings.Repeat(" ", max(l.markWidth()-utf8.RuneCountInString(symbol), 0)) - label := line.label + strings.Repeat(" ", max(labelWidth-utf8.RuneCountInString(line.label), 0)) + pad := strings.Repeat(" ", max(l.markWidth()-poppyCells(symbol), 0)) + label := line.label + strings.Repeat(" ", max(labelWidth-poppyCells(line.label), 0)) plain := " " + symbol + pad + " " + label b.WriteString(l.hang(plain, " "+colored+pad+" "+label, line.value)) - indent := strings.Repeat(" ", utf8.RuneCountInString(plain)) + indent := strings.Repeat(" ", poppyCells(plain)) for _, rest := range line.rest { for _, wrapped := range l.wrap(rest, len(indent)) { - b.WriteString(indent + wrapped + "\n") + b.WriteString(indent) + b.WriteString(wrapped) + b.WriteString("\n") } } for _, below := range line.below { for _, wrapped := range l.wrap(below, len(poppyBelowIndent)) { - b.WriteString(poppyBelowIndent + wrapped + "\n") + b.WriteString(poppyBelowIndent) + b.WriteString(wrapped) + b.WriteString("\n") } } } @@ -780,7 +807,8 @@ func poppyChecked(l PoppyLayout, p setup.Plan, in setup.Inputs) string { } var b strings.Builder - b.WriteString(ansi.Bold("Checked") + "\n") + b.WriteString(ansi.Bold("Checked")) + b.WriteString("\n") poppyWriteLabeled(&b, l, poppyCheckedLabelWidth, lines) return b.String() @@ -833,13 +861,14 @@ func poppyHintsText(l PoppyLayout, in setup.Inputs) string { func poppyAnswersText(l PoppyLayout, rows []poppyAnswerRow) string { var b strings.Builder - b.WriteString(ansi.Bold("Your answers") + "\n") + b.WriteString(ansi.Bold("Your answers")) + b.WriteString("\n") for _, row := range rows { - label := " " + row.label + strings.Repeat(" ", max(poppyAnswerLabelWidth-utf8.RuneCountInString(row.label), 0)) + label := " " + row.label + strings.Repeat(" ", max(poppyAnswerLabelWidth-poppyCells(row.label), 0)) b.WriteString(l.hang(label, label, row.value)) - indent := strings.Repeat(" ", utf8.RuneCountInString(label)) + indent := strings.Repeat(" ", poppyCells(label)) for _, more := range row.more { b.WriteString(l.hang(indent, indent, more)) } @@ -976,8 +1005,9 @@ func poppyGroup(l PoppyLayout, heading, note string, rows []poppyRow) string { b.WriteString(ansi.Bold(heading)) if note != "" { - gap := l.lineWidth() - utf8.RuneCountInString(heading) - utf8.RuneCountInString(note) - b.WriteString(strings.Repeat(" ", max(gap, 2)) + ansi.Yellow(note)) + gap := l.lineWidth() - poppyCells(heading) - poppyCells(note) + b.WriteString(strings.Repeat(" ", max(gap, 2))) + b.WriteString(ansi.Yellow(note)) } b.WriteString("\n") @@ -991,23 +1021,23 @@ func poppyGroup(l PoppyLayout, heading, note string, rows []poppyRow) string { func poppyWriteRows(b *strings.Builder, l PoppyLayout, rows []poppyRow) { labelWidth, changeWidth := 0, 0 for _, r := range rows { - labelWidth = max(labelWidth, utf8.RuneCountInString(r.label)) - changeWidth = max(changeWidth, min(utf8.RuneCountInString(r.change), poppyMaxChangeColumn)) + labelWidth = max(labelWidth, poppyCells(r.label)) + changeWidth = max(changeWidth, min(poppyCells(r.change), poppyMaxChangeColumn)) } changeWidth += 2 for _, r := range rows { symbol, colored := l.mark(r.mark) - pad := strings.Repeat(" ", max(l.markWidth()-utf8.RuneCountInString(symbol), 0)) - label := r.label + strings.Repeat(" ", labelWidth-utf8.RuneCountInString(r.label)+2) + pad := strings.Repeat(" ", max(l.markWidth()-poppyCells(symbol), 0)) + label := r.label + strings.Repeat(" ", labelWidth-poppyCells(r.label)+2) plain := " " + symbol + pad + " " + label - indent := strings.Repeat(" ", utf8.RuneCountInString(plain)) + indent := strings.Repeat(" ", poppyCells(plain)) lines := l.wrap(r.change, len(indent)) last := lines[len(lines)-1] - sameLine := r.note != "" && len(lines) == 1 && utf8.RuneCountInString(last) <= changeWidth-2 && - (l.Width == 0 || len(indent)+changeWidth+utf8.RuneCountInString(r.note) <= l.Width) + sameLine := r.note != "" && len(lines) == 1 && poppyCells(last) <= changeWidth-2 && + (l.Width == 0 || len(indent)+changeWidth+poppyCells(r.note) <= l.Width) for i, line := range lines { prefix := indent @@ -1016,19 +1046,23 @@ func poppyWriteRows(b *strings.Builder, l PoppyLayout, rows []poppyRow) { } if i == len(lines)-1 && sameLine { - line += strings.Repeat(" ", changeWidth-utf8.RuneCountInString(line)) + ansi.Faint(r.note) + line += strings.Repeat(" ", changeWidth-poppyCells(line)) + ansi.Faint(r.note) } - b.WriteString(prefix + line + "\n") + b.WriteString(prefix) + b.WriteString(line) + b.WriteString("\n") } if r.note != "" && !sameLine { column := indent - if l.Width == 0 || len(indent)+changeWidth+utf8.RuneCountInString(r.note) <= l.Width { + if l.Width == 0 || len(indent)+changeWidth+poppyCells(r.note) <= l.Width { column += strings.Repeat(" ", changeWidth) } - b.WriteString(column + ansi.Faint(r.note) + "\n") + b.WriteString(column) + b.WriteString(ansi.Faint(r.note)) + b.WriteString("\n") } } } @@ -1122,11 +1156,12 @@ func poppyStepLabel(p setup.Plan, c setup.Change) string { func poppyApplyText(l PoppyLayout, p setup.Plan, o setup.Outcome, opts PoppySetupOptions) string { var b strings.Builder - b.WriteString(ansi.Bold("Applying "+PoppyChangeCount(p.Count())) + "\n") + b.WriteString(ansi.Bold("Applying " + PoppyChangeCount(p.Count()))) + b.WriteString("\n") labelWidth := 0 for _, change := range p.Changes { - labelWidth = max(labelWidth, utf8.RuneCountInString(poppyStepLabel(p, change))) + labelWidth = max(labelWidth, poppyCells(poppyStepLabel(p, change))) } lines := make([]poppyLabeled, len(p.Changes)) @@ -1154,7 +1189,8 @@ func poppyApplyText(l PoppyLayout, p setup.Plan, o setup.Outcome, opts PoppySetu if o.Failure != nil { b.WriteString("\nRunning the command again continues from here.\n") - b.WriteString(poppyRerun(l, poppyToRerunLabel, opts) + "\n") + b.WriteString(poppyRerun(l, poppyToRerunLabel, opts)) + b.WriteString("\n") } return b.String() @@ -1184,7 +1220,8 @@ func poppySetupNext(l PoppyLayout, domain, out string, unhinted int) string { var b strings.Builder - b.WriteString(ansi.Bold("Next") + "\n") + b.WriteString(ansi.Bold("Next")) + b.WriteString("\n") for i, step := range steps { line := step[0] @@ -1231,11 +1268,13 @@ func poppyConfigText(l PoppyLayout, in setup.Inputs, unchanged, headerShown bool b.WriteString("\n") if answers := poppyHintsText(l, in); answers != "" { - b.WriteString(answers + "\n") + b.WriteString(answers) + b.WriteString("\n") } } - b.WriteString(l.entries([]poppyEntry{{mark: markDone, label: text.Line(in.Out), value: result}}) + "\n") + b.WriteString(l.entries([]poppyEntry{{mark: markDone, label: text.Line(in.Out), value: result}})) + b.WriteString("\n") b.WriteString(poppyNext(l, next...)) return b.String() diff --git a/internal/display/poppy_agents.go b/internal/display/poppy_agents.go index d7e82db36..3575b3232 100644 --- a/internal/display/poppy_agents.go +++ b/internal/display/poppy_agents.go @@ -293,7 +293,9 @@ func (r *Renderer) PoppyRemoval(stage PoppyRemovalStage, result *agents.RemoveRe b.WriteString("\n") } - b.WriteString(ansi.Bold(heading) + "\n" + l.entries(entries)) + b.WriteString(ansi.Bold(heading)) + b.WriteString("\n") + b.WriteString(l.entries(entries)) } removedMark, keptMark := markPlain, markPlain @@ -309,7 +311,8 @@ func (r *Renderer) PoppyRemoval(stage PoppyRemovalStage, result *agents.RemoveRe b.WriteString("\n") } - b.WriteString(ansi.Bold("Other grants of the agent app stay. Remove them with:") + "\n") + b.WriteString(ansi.Bold("Other grants of the agent app stay. Remove them with:")) + b.WriteString("\n") for _, grant := range result.RemainingGrants { b.WriteString(l.hang(" ", " ", "auth0 client-grants delete "+text.Quote(text.Line(grant.ID))+" ("+text.Line(grant.Audience)+")")) } diff --git a/internal/display/poppy_layout.go b/internal/display/poppy_layout.go index aead908a7..837652105 100644 --- a/internal/display/poppy_layout.go +++ b/internal/display/poppy_layout.go @@ -6,9 +6,9 @@ import ( "os" "regexp" "strings" - "unicode/utf8" "golang.org/x/term" + "golang.org/x/text/width" "github.com/auth0/auth0-cli/internal/ansi" ) @@ -77,41 +77,60 @@ func (l PoppyLayout) lineWidth() int { func (l PoppyLayout) wrap(s string, indent int) []string { width := max(l.Width-indent, poppyMinWidth) - if l.Width <= 0 || utf8.RuneCountInString(s) <= width { + if l.Width <= 0 || poppyCells(s) <= width { return []string{strings.TrimSpace(s)} } return wrapWords(s, width) } -// wrapWords breaks s into lines of at most width characters on word boundaries. A -// word longer than a line is cut. It always returns at least one line. +// wrapWords breaks s into lines of at most width cells on word boundaries. A word +// longer than a line is cut. It always returns at least one line. func wrapWords(s string, width int) []string { var ( lines []string - line []rune + line string + used int ) - for _, word := range strings.Fields(s) { - w := []rune(word) - - for len(w) > width { - if len(line) > 0 { - lines, line = append(lines, string(line)), nil + for word := range strings.FieldsSeq(s) { + for poppyCells(word) > width { + if used > 0 { + lines, line, used = append(lines, line), "", 0 } - lines, w = append(lines, string(w[:width])), w[width:] + + var head string + head, word = splitCells(word, width) + lines = append(lines, head) } + cells := poppyCells(word) + switch { - case len(line) == 0: - line = w - case len(line)+1+len(w) <= width: - line = append(append(line, ' '), w...) + case used == 0: + line, used = word, cells + case used+1+cells <= width: + line, used = line+" "+word, used+1+cells default: - lines, line = append(lines, string(line)), w + lines, line, used = append(lines, line), word, cells + } + } + + return append(lines, line) +} + +// splitCells cuts the longest start of s that fits in the given cells, and always +// takes at least one rune. +func splitCells(s string, cells int) (head, rest string) { + used := 0 + + for i, r := range s { + used += poppyRuneCells(r) + if used > cells && i > 0 { + return s[:i], s[i:] } } - return append(lines, string(line)) + return s, "" } // hang writes s wrapped to the layout after a prefix: the first line starts with @@ -119,13 +138,17 @@ func wrapWords(s string, width int) []string { // the text sits in a column. Colored may hold the prefix with color codes; plain // is the same prefix without them. Each line ends with a newline. func (l PoppyLayout) hang(plain, colored, s string) string { - pad := strings.Repeat(" ", utf8.RuneCountInString(plain)) - lines := l.wrap(s, utf8.RuneCountInString(plain)) + pad := strings.Repeat(" ", poppyCells(plain)) + lines := l.wrap(s, poppyCells(plain)) var b strings.Builder - b.WriteString(colored + lines[0] + "\n") + b.WriteString(colored) + b.WriteString(lines[0]) + b.WriteString("\n") for _, line := range lines[1:] { - b.WriteString(pad + line + "\n") + b.WriteString(pad) + b.WriteString(line) + b.WriteString("\n") } return b.String() @@ -134,7 +157,7 @@ func (l PoppyLayout) hang(plain, colored, s string) string { // header is a title in bold at the left edge and a note at the right edge, on one // line that ends with a newline. func (l PoppyLayout) header(left, right string) string { - gap := l.lineWidth() - utf8.RuneCountInString(left) - utf8.RuneCountInString(right) + gap := l.lineWidth() - poppyCells(left) - poppyCells(right) return ansi.Bold(left) + strings.Repeat(" ", max(gap, 1)) + right + "\n" } @@ -223,21 +246,21 @@ type poppyEntry struct { func (l PoppyLayout) entries(entries []poppyEntry) string { labelWidth := 0 for _, e := range entries { - labelWidth = max(labelWidth, utf8.RuneCountInString(e.label)) + labelWidth = max(labelWidth, poppyCells(e.label)) } var b strings.Builder for _, e := range entries { symbol, colored := l.mark(e.mark) - pad := strings.Repeat(" ", max(l.markWidth()-utf8.RuneCountInString(symbol), 0)) + pad := strings.Repeat(" ", max(l.markWidth()-poppyCells(symbol), 0)) plain, shown := " "+symbol+pad+" ", " "+colored+pad+" " if e.mark == markPlain && e.label == "" { plain, shown = " ", " " } if e.label != "" { - label := e.label + strings.Repeat(" ", labelWidth-utf8.RuneCountInString(e.label)+2) + label := e.label + strings.Repeat(" ", labelWidth-poppyCells(e.label)+2) plain, shown = plain+label, shown+label } @@ -310,6 +333,30 @@ func (b *poppyBlock) erase() error { return err } +// poppyCells is the number of cells that s takes on the screen: the color +// sequences take none, and a character that is wide or fullwidth in East Asian +// text takes two. Combining marks and emoji are not special cased, so they count +// as one cell each, as they do for the other width math here. +func poppyCells(s string) int { + cells := 0 + + for _, r := range ansiSequence.ReplaceAllString(s, "") { + cells += poppyRuneCells(r) + } + + return cells +} + +// poppyRuneCells is the number of cells that one character takes, see poppyCells. +func poppyRuneCells(r rune) int { + switch width.LookupRune(r).Kind() { + case width.EastAsianWide, width.EastAsianFullwidth: + return 2 + default: + return 1 + } +} + // poppyLineRows is the number of screen rows that a line takes in a terminal of // the given width. A line of exactly the width takes one row, and so does an empty // line. A width of zero means the line is not wrapped. @@ -318,7 +365,5 @@ func poppyLineRows(line string, width int) int { return 1 } - cells := utf8.RuneCountInString(ansiSequence.ReplaceAllString(line, "")) - - return max((cells+width-1)/width, 1) + return max((poppyCells(line)+width-1)/width, 1) } diff --git a/internal/display/poppy_layout_test.go b/internal/display/poppy_layout_test.go index 7c952e770..1f337b7df 100644 --- a/internal/display/poppy_layout_test.go +++ b/internal/display/poppy_layout_test.go @@ -206,6 +206,9 @@ func TestPoppyLineRows(t *testing.T) { {name: "colors take no room", line: ansi.Bold(strings.Repeat("a", 80)), width: 80, want: 1}, {name: "wide characters count as one cell each", line: strings.Repeat("é", 80), width: 80, want: 1}, {name: "no width means no wrapping", line: strings.Repeat("a", 500), width: 0, want: 1}, + {name: "twenty wide characters take two rows at width 20", line: strings.Repeat("日", 20), width: 20, want: 2}, + {name: "ten wide characters fill a row of 20", line: strings.Repeat("日", 10), width: 20, want: 1}, + {name: "colored wide characters", line: ansi.Bold(strings.Repeat("日", 11)), width: 20, want: 2}, } for _, test := range tests { @@ -215,6 +218,68 @@ func TestPoppyLineRows(t *testing.T) { } } +func TestPoppyCells(t *testing.T) { + tests := []struct { + name string + in string + want int + }{ + {name: "empty", in: "", want: 0}, + {name: "ascii", in: "hello", want: 5}, + {name: "a check mark", in: "✓", want: 1}, + {name: "an arrow", in: "→", want: 1}, + {name: "an accented letter", in: "é", want: 1}, + {name: "wide characters take two cells", in: "日本語", want: 6}, + {name: "fullwidth forms take two cells", in: "AB", want: 4}, + {name: "mixed", in: "ab 日本 → ok", want: 12}, + {name: "colors take none", in: ansi.Bold("日本") + " ok", want: 7}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setColors(t, true) + assert.Equal(t, test.want, poppyCells(test.in)) + }) + } +} + +func TestPoppyWrapCells(t *testing.T) { + l := PoppyLayout{Width: 20} + + tests := []struct { + name string + in string + want []string + }{ + { + name: "a sentence of wide characters is cut at the width", + in: "日本語の説明文です日本語の説明文です", + want: []string{"日本語の説明文です日", "本語の説明文です"}, + }, + { + name: "words of wide characters wrap on their gaps", + in: "日本語の説明 文です 日本語の説明 文です", + want: []string{"日本語の説明 文です", "日本語の説明 文です"}, + }, + { + name: "ascii still wraps by characters", + in: "aaaa bbbb cccc dddd eeee", + want: []string{"aaaa bbbb cccc dddd", "eeee"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + lines := l.wrap(test.in, 0) + assert.Equal(t, test.want, lines) + + for _, line := range lines { + assert.LessOrEqual(t, poppyCells(line), 20, line) + } + }) + } +} + func TestPoppyBlock(t *testing.T) { long := strings.Repeat("a", 100) diff --git a/internal/iostream/iostream.go b/internal/iostream/iostream.go index 8569228eb..8820d55c9 100644 --- a/internal/iostream/iostream.go +++ b/internal/iostream/iostream.go @@ -24,6 +24,12 @@ func IsOutputTerminal() bool { return isatty.IsTerminal(Output.Fd()) } +// IsMessagesTerminal tells whether the messages stream, where the prompts are +// drawn, is a terminal. +func IsMessagesTerminal() bool { + return isatty.IsTerminal(Messages.Fd()) +} + // PipedInput reads all data available on stdin when stdin is not a terminal (a // pipe or redirect). It returns an empty slice on an interactive terminal. A read // failure is returned as an error rather than a panic, so a broken pipe on the diff --git a/internal/poppy/agents/register.go b/internal/poppy/agents/register.go index fede43f11..fba4b6aee 100644 --- a/internal/poppy/agents/register.go +++ b/internal/poppy/agents/register.go @@ -48,11 +48,13 @@ func (e *InvalidDocumentError) Error() string { fmt.Fprintf(&b, "the agent metadata document at %s is not valid:", e.URL) for _, violation := range e.Violations { - b.WriteString("\n " + violation) + b.WriteString("\n ") + b.WriteString(violation) } if slices.ContainsFunc(e.Violations, aboutKeys) { - b.WriteString("\n" + ecKeysNote) + b.WriteString("\n") + b.WriteString(ecKeysNote) } return b.String() diff --git a/internal/poppy/config/merge.go b/internal/poppy/config/merge.go index 538c1ed57..60f1e2531 100644 --- a/internal/poppy/config/merge.go +++ b/internal/poppy/config/merge.go @@ -69,6 +69,17 @@ type MergeOptions struct { ForceConfig bool } +// CheckIssuer returns an error that wraps ErrIssuerMismatch when the existing file +// (which may be nil) has an issuer other than the given one, unless the options +// allow overwriting it. Merge makes the same check. +func CheckIssuer(existing *Config, issuer string, opts MergeOptions) error { + if existing != nil && existing.Issuer != "" && existing.Issuer != issuer && !opts.ForceConfig { + return fmt.Errorf("%w: the file has %q and the tenant has %q", ErrIssuerMismatch, existing.Issuer, issuer) + } + + return nil +} + // Merge builds the config to write from the values derived from the tenant, // the existing file (which may be nil) and the inputs. It does not modify its // arguments. @@ -96,8 +107,8 @@ func Merge(existing, derived *Config, in Inputs, opts MergeOptions) (*Config, er return nil, errors.New("the values derived from the tenant are required") } - if existing != nil && existing.Issuer != "" && existing.Issuer != derived.Issuer && !opts.ForceConfig { - return nil, fmt.Errorf("%w: the file has %q and the tenant has %q", ErrIssuerMismatch, existing.Issuer, derived.Issuer) + if err := CheckIssuer(existing, derived.Issuer, opts); err != nil { + return nil, err } if existing == nil { existing = &Config{} diff --git a/internal/poppy/setup/command.go b/internal/poppy/setup/command.go index c0af4cebb..34cfb38ef 100644 --- a/internal/poppy/setup/command.go +++ b/internal/poppy/setup/command.go @@ -135,7 +135,8 @@ func FormatCommand(label string, parts []string, width int) string { for _, part := range parts[1:] { if utf8.RuneCountInString(line)+1+utf8.RuneCountInString(part)+2 > width { - b.WriteString(line + " \\\n") + b.WriteString(line) + b.WriteString(" \\\n") line = indent + part continue diff --git a/internal/poppy/setup/derive.go b/internal/poppy/setup/derive.go index 20e115d5f..73e2acc79 100644 --- a/internal/poppy/setup/derive.go +++ b/internal/poppy/setup/derive.go @@ -77,6 +77,14 @@ func Ready(state *State, in Inputs) error { return &NotReadyError{Problems: problems, Fix: "Run: auth0 poppy setup --company-domain " + cmp.Or(in.CompanyDomain, "")} } +// CheckIssuer returns the error that Derive and Build return when the existing +// config file belongs to another issuer than the tenant, unless the inputs allow +// overwriting it. It reads only the state, so a command can refuse before it asks +// any question. +func CheckIssuer(state *State, in Inputs) error { + return config.CheckIssuer(state.File, state.Issuer(), config.MergeOptions{ForceConfig: in.ForceConfig}) +} + // Derive builds the content of the config file from the state and the inputs, as // the file will read once setup has made its changes. Values Auth0 does not // store come from the inputs, else from the existing file; when neither has one, diff --git a/internal/poppy/setup/derive_test.go b/internal/poppy/setup/derive_test.go index 1364765c0..bd1683726 100644 --- a/internal/poppy/setup/derive_test.go +++ b/internal/poppy/setup/derive_test.go @@ -89,6 +89,47 @@ func TestDeriveErrors(t *testing.T) { }) } +func TestCheckIssuer(t *testing.T) { + tests := []struct { + name string + change func(state *State, in *Inputs) + wantErr bool + }{ + {name: "the file has the issuer of the tenant", change: func(*State, *Inputs) {}}, + {name: "there is no file", change: func(state *State, _ *Inputs) { state.File = nil }}, + {name: "the file has no issuer", change: func(state *State, _ *Inputs) { state.File.Issuer = "" }}, + {name: "the file has another issuer", change: func(state *State, _ *Inputs) { state.CustomDomain = "login.other.example" }, wantErr: true}, + { + name: "another issuer is allowed with force", + change: func(state *State, in *Inputs) { + state.CustomDomain = "login.other.example" + in.ForceConfig = true + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state, in := configuredState(t), configuredInputs() + test.change(state, &in) + + err := CheckIssuer(state, in) + + if !test.wantErr { + assert.NoError(t, err) + + return + } + + require.ErrorIs(t, err, config.ErrIssuerMismatch) + + _, deriveErr := Derive(state, in) + require.Error(t, deriveErr) + assert.Equal(t, deriveErr.Error(), err.Error(), "the same error as Derive") + }) + } +} + func TestReady(t *testing.T) { tests := []struct { name string diff --git a/internal/poppy/setup/inputs.go b/internal/poppy/setup/inputs.go index 49a2b4780..2f895dde4 100644 --- a/internal/poppy/setup/inputs.go +++ b/internal/poppy/setup/inputs.go @@ -770,7 +770,8 @@ func scopeDescriptionRequired(undescribed []undescribedScope) *InputError { break } - b.WriteString("\n " + flag(name)) + b.WriteString("\n ") + b.WriteString(flag(name)) } fix = b.String() From 038d539dcba3afd37860b17ebdc944b07abb7fe3 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Fri, 6 Nov 2026 20:59:56 +0530 Subject: [PATCH 4/4] feat(poppy): ask for web.unscoped in setup and config web.unscoped is a decision of the website owner, so setup and config now collect it like the other values only the owner knows. Pass --web-unscoped[=false], or answer a yes/no question in a terminal when the config file has no value yet. An explicit false is kept in the file, so the question is not asked again. --- docs/auth0_poppy_config.md | 3 +- docs/auth0_poppy_setup.md | 3 +- internal/cli/poppy.go | 95 ++++- internal/cli/poppy_test.go | 25 +- internal/cli/poppy_web_test.go | 458 ++++++++++++++++++++++ internal/display/poppy.go | 15 +- internal/display/poppy_test.go | 38 ++ internal/poppy/config/config.go | 9 +- internal/poppy/config/diff.go | 4 +- internal/poppy/config/file.go | 13 +- internal/poppy/config/merge.go | 13 +- internal/poppy/config/unscoped_test.go | 194 +++++++++ internal/poppy/doctor/checks_version.go | 2 +- internal/poppy/setup/command.go | 4 + internal/poppy/setup/derive.go | 12 + internal/poppy/setup/inputs.go | 11 + internal/poppy/setup/web_unscoped_test.go | 159 ++++++++ 17 files changed, 1032 insertions(+), 26 deletions(-) create mode 100644 internal/cli/poppy_web_test.go create mode 100644 internal/poppy/config/unscoped_test.go create mode 100644 internal/poppy/setup/web_unscoped_test.go diff --git a/docs/auth0_poppy_config.md b/docs/auth0_poppy_config.md index 2921a1e1f..f2d695075 100644 --- a/docs/auth0_poppy_config.md +++ b/docs/auth0_poppy_config.md @@ -5,7 +5,7 @@ has_toc: false --- # auth0 poppy config -(Experimental) Rebuilds the config file from the tenant without changing the tenant, so CI and deploys can refresh it safely. It reads the tenant, adds the values Auth0 does not store, and writes the file. If the tenant is not set up for Poppy, it exits with an error naming the missing setting. The config file is for one website, so with several company domains pass `--company-domain` to pick the one this app serves. The file is a protocol 0.4 `poppy.json` document and nothing else, so your web SDK can serve it as `/.well-known/poppy.json`. A file written for 0.3 is upgraded, and members of the file that the CLI does not use are kept. The file holds no API identifier and no web app, so without `--api` the APIs are the Poppy APIs of the tenant that the `apis` entries of the existing file are at, and without `--app` the web app is the only app of the tenant that has the browser join on. When that does not settle on one, the command stops and asks for `--api` or `--app`. +(Experimental) Rebuilds the config file from the tenant without changing the tenant, so CI and deploys can refresh it safely. It reads the tenant, adds the values Auth0 does not store, and writes the file. If the tenant is not set up for Poppy, it exits with an error naming the missing setting. The config file is for one website, so with several company domains pass `--company-domain` to pick the one this app serves. The file is a protocol 0.4 `poppy.json` document and nothing else, so your web SDK can serve it as `/.well-known/poppy.json`. A file written for 0.3 is upgraded, and members of the file that the CLI does not use are kept. In a terminal it asks whether signing in to the browser gives an agent full access to the user's account on your website (`web.unscoped`), unless you pass `--web-unscoped` or the file already has a value. The file holds no API identifier and no web app, so without `--api` the APIs are the Poppy APIs of the tenant that the `apis` entries of the existing file are at, and without `--app` the web app is the only app of the tenant that has the browser join on. When that does not settle on one, the command stops and asks for `--api` or `--app`. **Warning:** This command is experimental and is subject to change in future versions. @@ -36,6 +36,7 @@ auth0 poppy config [flags] --json-compact Output in compact json format. --name string The Company's name for the config file. When registering an agent, the agent's name. --out string Path of the config file. (default "auth0-poppy.json") + --web-unscoped Set web.unscoped in the config file: true when your website treats poppy:browser-sign-in as the account's full access on the website. auth0 poppy doctor then reminds you to check the description of poppy:browser-sign-in. Asked in a terminal when the config file has no value. ``` diff --git a/docs/auth0_poppy_setup.md b/docs/auth0_poppy_setup.md index ef12de36e..dc7a6b6f9 100644 --- a/docs/auth0_poppy_setup.md +++ b/docs/auth0_poppy_setup.md @@ -5,7 +5,7 @@ has_toc: false --- # auth0 poppy setup -(Experimental) Turns on the Poppy settings for the tenant, an existing API and an existing web app, and writes the config file. The API and the web app must already exist, and their scopes, MFA policy and Actions are left alone. The command shows a plan first and marks the tenant-wide changes, then applies it once you confirm; use `--dry-run` to see the plan only, and `--yes` to skip the question, which is required without a terminal. In a terminal it asks for any required value you did not pass, such as the OpenAPI URL of an API or the login route of the web app. Running it again changes nothing when the tenant already matches, or continues where a failed run stopped. It finishes by running `auth0 poppy doctor`, including the checks that read the tenant, unless `--skip-doctor` is set; a failed check does not fail setup, because your website may not serve the config file until you deploy it. With `--guest-api-access` the guest grant carries only the scopes you choose, with `--guest-scopes` or `--guest-all-scopes`; in a terminal setup asks, and it also asks whether to let signed-out guests use the API when no guest grant exists yet. Setup adds the scope `poppy:browser-sign-in` to each API when it is missing, with a default description unless you give one with `--scope`; every other scope offered to agents needs a description, which a terminal asks for and otherwise comes from `--scope =`. +(Experimental) Turns on the Poppy settings for the tenant, an existing API and an existing web app, and writes the config file. The API and the web app must already exist, and their scopes, MFA policy and Actions are left alone. The command shows a plan first and marks the tenant-wide changes, then applies it once you confirm; use `--dry-run` to see the plan only, and `--yes` to skip the question, which is required without a terminal. In a terminal it asks for any required value you did not pass, such as the OpenAPI URL of an API or the login route of the web app. Running it again changes nothing when the tenant already matches, or continues where a failed run stopped. It finishes by running `auth0 poppy doctor`, including the checks that read the tenant, unless `--skip-doctor` is set; a failed check does not fail setup, because your website may not serve the config file until you deploy it. With `--guest-api-access` the guest grant carries only the scopes you choose, with `--guest-scopes` or `--guest-all-scopes`; in a terminal setup asks, and it also asks whether to let signed-out guests use the API when no guest grant exists yet. It also asks whether your website treats `poppy:browser-sign-in` as the account's full access on the website, which sets `web.unscoped` in the config file, unless you pass `--web-unscoped` or the file already has a value. Setup adds the scope `poppy:browser-sign-in` to each API when it is missing, with a default description unless you give one with `--scope`; every other scope offered to agents needs a description, which a terminal asks for and otherwise comes from `--scope =`. **Warning:** This command is experimental and is subject to change in future versions. @@ -44,6 +44,7 @@ auth0 poppy setup [flags] --out string Path of the config file. (default "auth0-poppy.json") --scope stringArray The description of a scope that has none, as name="description". With one API, also adds a scope the API lacks. Repeat for several. Existing descriptions are never edited. --skip-doctor Do not run the doctor checks after setup. + --web-unscoped Set web.unscoped in the config file: true when your website treats poppy:browser-sign-in as the account's full access on the website. auth0 poppy doctor then reminds you to check the description of poppy:browser-sign-in. Asked in a terminal when the config file has no value. --yes Skip confirmation. ``` diff --git a/internal/cli/poppy.go b/internal/cli/poppy.go index 97f5310e7..587ed44f3 100644 --- a/internal/cli/poppy.go +++ b/internal/cli/poppy.go @@ -49,6 +49,7 @@ type poppyAsker struct { choose func(s *poppyScreen, message string, options []string, def int) (int, error) chooseMany func(s *poppyScreen, message string, options []string, defaults []int) ([]int, error) guest func(s *poppyScreen) poppyGuestPrompts + web func(s *poppyScreen) poppyWebPrompts } var poppyAsk = poppyAsker{ @@ -60,6 +61,7 @@ var poppyAsk = poppyAsker{ choose: terminalPoppyChoose, chooseMany: terminalPoppyChooseMany, guest: terminalPoppyGuestPrompts, + web: terminalPoppyWebPrompts, } // poppyEraseHook makes the hook that tells whether the lines of a question can be @@ -154,6 +156,11 @@ var ( LongForm: "guest-all-scopes", Help: "The guest grant carries every scope of the API. Only affects the guest grant; use --scope (singular) to describe scopes. Needs --guest-api-access. Mutually exclusive with --guest-scopes.", } + poppyWebUnscoped = Flag{ + Name: "Web Unscoped", + LongForm: "web-unscoped", + Help: "Set web.unscoped in the config file: true when your website treats poppy:browser-sign-in as the account's full access on the website. auth0 poppy doctor then reminds you to check the description of poppy:browser-sign-in. Asked in a terminal when the config file has no value.", + } poppyForceConfig = Flag{ Name: "Force Config", LongForm: "force-config", @@ -388,8 +395,9 @@ type poppySetupOptions struct { func poppySetupCmd(cli *cli) *cobra.Command { var ( - flags setup.Flags - opts poppySetupOptions + flags setup.Flags + opts poppySetupOptions + webUnscoped bool ) cmd := &cobra.Command{ @@ -407,6 +415,8 @@ func poppySetupCmd(cli *cli) *cobra.Command { "setup, because your website may not serve the config file until you deploy it. With `--guest-api-access` the " + "guest grant carries only the scopes you choose, with `--guest-scopes` or `--guest-all-scopes`; in a terminal " + "setup asks, and it also asks whether to let signed-out guests use the API when no guest grant exists yet. " + + "It also asks whether your website treats `poppy:browser-sign-in` as the account's full access on the website, " + + "which sets `web.unscoped` in the config file, unless you pass `--web-unscoped` or the file already has a value. " + "Setup adds the scope `poppy:browser-sign-in` to each API when it is missing, with a default description " + "unless you give one with `--scope`; every other scope offered to agents needs a description, which a terminal asks for " + "and otherwise comes from `--scope =`."), @@ -415,6 +425,8 @@ func poppySetupCmd(cli *cli) *cobra.Command { auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app "Shop Web" --guest-api-access --guest-scopes orders:read --yes auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app "Shop Web" --api-url https://api.shop.example=https://api.shop.example/openapi.json --login-uri https://shop.example/login --hint checkout=changes:irreversible,personal_data:true --yes`, RunE: func(cmd *cobra.Command, _ []string) error { + flags.WebUnscoped = poppyBoolIfChanged(cmd, poppyWebUnscoped.LongForm, webUnscoped) + return runPoppySetup(cmd, cli, flags, opts) }, } @@ -434,6 +446,7 @@ func poppySetupCmd(cli *cli) *cobra.Command { poppyGuestAPIAccess.RegisterBool(cmd, &flags.GuestAPIAccess, false) poppyGuestScopes.RegisterStringSlice(cmd, &flags.GuestScopes, nil) poppyGuestAllScopes.RegisterBool(cmd, &flags.GuestAllScopes, false) + poppyWebUnscoped.RegisterBool(cmd, &webUnscoped, false) poppyForceConfig.RegisterBool(cmd, &flags.ForceConfig, false) registerPoppyOutputFlags(cmd, cli) cmd.MarkFlagsMutuallyExclusive(poppyGuestScopes.LongForm, poppyGuestAllScopes.LongForm) @@ -506,6 +519,10 @@ func runPoppySetup(cmd *cobra.Command, cli *cli, flags setup.Flags, opts poppySe return err } + if err := askPoppyWebUnscoped(poppyAsk.canPrompt(cmd), poppyAsk.web(screen), state, &in); err != nil { + return err + } + plan, err := setup.Build(state, in) if err != nil { return poppySetupError(err) @@ -892,6 +909,54 @@ func askPoppyGuestScopes(prompts poppyGuestPrompts, state *setup.State, in *setu return nil } +// The wording of the question about web.unscoped. The text is the question +// itself; the note follows a Yes. +const ( + poppyWebUnscopedTitle = "Website access" + poppyWebUnscopedText = "Does your website treat poppy:browser-sign-in as the account's full access on the website? If yes, web.unscoped is set to true." + poppyWebUnscopedNote = "Make sure the description of poppy:browser-sign-in says so, for example \"Browse the website, and see and do anything your account can do there\". auth0 poppy doctor reminds you." +) + +// poppyWebPrompts are the questions about the website. Tests replace them. +type poppyWebPrompts struct { + // Unscoped asks whether the browser join gives an agent full account access. + unscoped func() (bool, error) + // Note tells the user something that is not an error. + note func(format string, args ...any) +} + +func terminalPoppyWebPrompts(s *poppyScreen) poppyWebPrompts { + return poppyWebPrompts{ + note: s.Note, + unscoped: func() (bool, error) { + return s.confirmAsk(poppyWebUnscopedTitle, "", poppyWebUnscopedText) + }, + } +} + +// askPoppyWebUnscoped asks, in a terminal, whether the browser join gives an agent +// full access to the account of the user on the website. The answer goes into the +// inputs as if --web-unscoped had given it. Nothing is asked when the flag was +// passed or the config file already has a value. +func askPoppyWebUnscoped(interactive bool, prompts poppyWebPrompts, state *setup.State, in *setup.Inputs) error { + if !interactive || !state.AsksWebUnscoped(*in) { + return nil + } + + unscoped, err := prompts.unscoped() + if err != nil { + return err + } + + in.WebUnscoped = &unscoped + + if unscoped { + prompts.note("%s", poppyWebUnscopedNote) + } + + return nil +} + // poppyGuestScopesRequiredError lists the scopes on offer, with their hints, for // a caller that cannot be asked. func poppyGuestScopesRequiredError(state *setup.State, in setup.Inputs) error { @@ -958,7 +1023,10 @@ func poppySetupError(err error) error { } func poppyConfigCmd(cli *cli) *cobra.Command { - var flags setup.Flags + var ( + flags setup.Flags + webUnscoped bool + ) cmd := &cobra.Command{ Use: "config", @@ -970,7 +1038,9 @@ func poppyConfigCmd(cli *cli) *cobra.Command { "file is for one website, so with several company domains pass `--company-domain` to pick the one this " + "app serves. The file is a protocol 0.4 `poppy.json` document and nothing else, so your web SDK can " + "serve it as `/.well-known/poppy.json`. A file written for 0.3 is upgraded, and members of the file that " + - "the CLI does not use are kept. The file holds no API identifier and no web app, so without `--api` the " + + "the CLI does not use are kept. In a terminal it asks whether signing in to the browser gives an agent full " + + "access to the user's account on your website (`web.unscoped`), unless you pass `--web-unscoped` or the file " + + "already has a value. The file holds no API identifier and no web app, so without `--api` the " + "APIs are the Poppy APIs of the tenant that the `apis` entries of the existing file are at, and without " + "`--app` the web app is the only app of the tenant that has the browser join on. When that does not " + "settle on one, the command stops and asks for `--api` or `--app`."), @@ -978,6 +1048,8 @@ func poppyConfigCmd(cli *cli) *cobra.Command { auth0 poppy config --company-domain shop.example --out ./config/auth0-poppy.json auth0 poppy config --company-domain shop.example --api https://api.shop.example --app "Shop Web" --json`, RunE: func(cmd *cobra.Command, _ []string) error { + flags.WebUnscoped = poppyBoolIfChanged(cmd, poppyWebUnscoped.LongForm, webUnscoped) + return runPoppyConfig(cmd, cli, flags) }, } @@ -989,12 +1061,23 @@ func poppyConfigCmd(cli *cli) *cobra.Command { poppyHint.RegisterStringArray(cmd, &flags.Hints, nil) poppyName.RegisterString(cmd, &flags.Name, "") poppyOut.RegisterString(cmd, &flags.Out, config.DefaultFileName) + poppyWebUnscoped.RegisterBool(cmd, &webUnscoped, false) poppyForceConfig.RegisterBool(cmd, &flags.ForceConfig, false) registerPoppyOutputFlags(cmd, cli) return cmd } +// poppyBoolIfChanged returns the value of a bool flag, or nil when the flag was +// not passed, so that a flag set to false differs from one left out. +func poppyBoolIfChanged(cmd *cobra.Command, name string, value bool) *bool { + if !cmd.Flags().Changed(name) { + return nil + } + + return &value +} + func runPoppyConfig(cmd *cobra.Command, cli *cli, flags setup.Flags) error { in, err := flags.Parse() if err != nil { @@ -1048,6 +1131,10 @@ func runPoppyConfig(cmd *cobra.Command, cli *cli, flags setup.Flags) error { } } + if err := askPoppyWebUnscoped(poppyAsk.canPrompt(cmd), poppyAsk.web(screen), state, &in); err != nil { + return err + } + cfg, err := setup.Derive(state, in) if err != nil { return poppySetupError(err) diff --git a/internal/cli/poppy_test.go b/internal/cli/poppy_test.go index 116c6e7dc..b86549dbd 100644 --- a/internal/cli/poppy_test.go +++ b/internal/cli/poppy_test.go @@ -70,7 +70,7 @@ func TestPoppyCommands(t *testing.T) { path: []string{"poppy", "setup"}, flags: []string{ "company-domain", "api", "api-url", "app", "scope", "hint", "name", "login-uri", "out", - "dry-run", "yes", "skip-doctor", "guest-api-access", "guest-scopes", "guest-all-scopes", "force-config", "json", "json-compact", + "dry-run", "yes", "skip-doctor", "guest-api-access", "guest-scopes", "guest-all-scopes", "web-unscoped", "force-config", "json", "json-compact", }, }, { @@ -78,7 +78,7 @@ func TestPoppyCommands(t *testing.T) { path: []string{"poppy", "config"}, flags: []string{ "company-domain", "api", "api-url", "app", "hint", "name", "out", - "force-config", "json", "json-compact", + "web-unscoped", "force-config", "json", "json-compact", }, }, { @@ -594,6 +594,16 @@ func installPoppyAsker(t *testing.T, mode poppyPromptMode) *[]string { note: func(string, ...any) {}, } } + poppyAsk.web = func(*poppyScreen) poppyWebPrompts { + return poppyWebPrompts{ + unscoped: func() (bool, error) { + asked = append(asked, "web access") + + return true, nil + }, + note: func(string, ...any) {}, + } + } return &asked } @@ -2358,7 +2368,7 @@ func checkPoppySetupPromptRows(t *testing.T, tenant poppyTenantState, extra []st func TestPoppySetupPromptRule(t *testing.T) { const plan = "Poppy setup for shop.example" - asks := []string{"hints", "guest access", "guest scopes", "confirm"} + asks := []string{"hints", "guest access", "guest scopes", "web access", "confirm"} checkPoppySetupPromptRows(t, freshPoppyTenant(), []string{"--skip-doctor"}, []poppyPromptRow{ {name: "terminal", mode: modeTerminal, asked: asks, text: "Applying"}, @@ -2377,7 +2387,7 @@ func TestPoppySetupPromptRuleScopeDescription(t *testing.T) { missing = "FIX Pass --scope 'checkout='." ) - asks := []string{"text Text", "hints", "confirm"} + asks := []string{"text Text", "hints", "web access", "confirm"} tenant := freshPoppyTenant() tenant.server.Scopes[1].Description = nil @@ -2399,7 +2409,7 @@ func TestPoppySetupPromptRuleGuestScopes(t *testing.T) { missing = "pass --guest-scopes or --guest-all-scopes" ) - asks := []string{"hints", "guest scopes"} + asks := []string{"hints", "guest scopes", "web access"} checkPoppySetupPromptRows(t, freshPoppyTenant(), []string{"--skip-doctor", "--yes", "--guest-api-access"}, []poppyPromptRow{ {name: "terminal", mode: modeTerminal, asked: asks, text: "Applying"}, @@ -2414,10 +2424,11 @@ func TestPoppySetupPromptRuleGuestScopes(t *testing.T) { func TestPoppyConfigPromptRule(t *testing.T) { // A terminal is asked about the hints of the scopes, and the test terminal - // cannot take the question back, so it stays on the screen. + // cannot take the question back, so it stays on the screen. Then it is asked + // about web.unscoped, as the file has no value for it. const question = "Hints for your scopes" - asks := []string{"hints"} + asks := []string{"hints", "web access"} tests := []poppyPromptRow{ {name: "terminal", mode: modeTerminal, asked: asks, text: "written"}, diff --git a/internal/cli/poppy_web_test.go b/internal/cli/poppy_web_test.go new file mode 100644 index 000000000..a9d820c0d --- /dev/null +++ b/internal/cli/poppy_web_test.go @@ -0,0 +1,458 @@ +package cli + +import ( + "os" + "strings" + "testing" + + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/poppy/config" + "github.com/auth0/auth0-cli/internal/poppy/setup" +) + +func TestPoppyBoolIfChanged(t *testing.T) { + tests := []struct { + name string + args []string + want *bool + }{ + {name: "not passed", args: nil, want: nil}, + {name: "bare flag", args: []string{"--web-unscoped"}, want: testPtr(true)}, + {name: "true", args: []string{"--web-unscoped=true"}, want: testPtr(true)}, + {name: "false", args: []string{"--web-unscoped=false"}, want: testPtr(false)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "x", RunE: func(*cobra.Command, []string) error { return nil }} + + var raw bool + poppyWebUnscoped.RegisterBool(cmd, &raw, false) + cmd.SetArgs(test.args) + require.NoError(t, cmd.Execute()) + + assert.Equal(t, test.want, poppyBoolIfChanged(cmd, poppyWebUnscoped.LongForm, raw)) + }) + } +} + +func TestPoppyWebUnscopedFlagIsOnSetupAndConfigOnly(t *testing.T) { + root := newPoppyTestRoot() + + for _, path := range [][]string{{"poppy", "setup"}, {"poppy", "config"}} { + cmd, _, err := root.Find(path) + require.NoError(t, err) + + flag := cmd.Flags().Lookup("web-unscoped") + require.NotNil(t, flag, strings.Join(path, " ")) + assert.Contains(t, flag.Usage, "poppy:browser-sign-in") + assert.Contains(t, flag.Usage, "doctor") + } + + for _, path := range [][]string{{"poppy", "doctor"}, {"poppy", "agents", "register"}} { + cmd, _, err := root.Find(path) + require.NoError(t, err) + assert.Nil(t, cmd.Flags().Lookup("web-unscoped"), strings.Join(path, " ")) + } +} + +func TestAskPoppyWebUnscoped(t *testing.T) { + fileWith := func(unscoped, set bool) *setup.State { + return &setup.State{File: &config.Config{Web: config.Web{Unscoped: unscoped, UnscopedSet: set}}} + } + + tests := []struct { + name string + interactive bool + state *setup.State + in setup.Inputs + answer bool + wantAsked bool + wantNote bool + want *bool + }{ + {name: "no terminal asks nothing", interactive: false, state: &setup.State{}, want: nil}, + {name: "yes", interactive: true, state: &setup.State{}, answer: true, wantAsked: true, wantNote: true, want: testPtr(true)}, + {name: "no, which is then written as false", interactive: true, state: &setup.State{}, answer: false, wantAsked: true, want: testPtr(false)}, + {name: "the file has no value", interactive: true, state: fileWith(false, false), answer: true, wantAsked: true, wantNote: true, want: testPtr(true)}, + {name: "the file says false", interactive: true, state: fileWith(false, true), want: nil}, + {name: "the file says true", interactive: true, state: fileWith(true, true), want: nil}, + {name: "the flag was passed as true", interactive: true, state: &setup.State{}, in: setup.Inputs{WebUnscoped: testPtr(true)}, want: testPtr(true)}, + {name: "the flag was passed as false", interactive: true, state: &setup.State{}, in: setup.Inputs{WebUnscoped: testPtr(false)}, want: testPtr(false)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var asked, noted bool + + prompts := poppyWebPrompts{ + unscoped: func() (bool, error) { + asked = true + + return test.answer, nil + }, + note: func(format string, args ...any) { + noted = true + + assert.Equal(t, "%s", format) + require.Len(t, args, 1) + assert.Equal(t, poppyWebUnscopedNote, args[0]) + }, + } + + in := test.in + + require.NoError(t, askPoppyWebUnscoped(test.interactive, prompts, test.state, &in)) + assert.Equal(t, test.wantAsked, asked, "asked") + assert.Equal(t, test.wantNote, noted, "note after a yes only") + assert.Equal(t, test.want, in.WebUnscoped) + }) + } + + t.Run("a failure of the question ends it and sets nothing", func(t *testing.T) { + prompts := poppyWebPrompts{ + unscoped: func() (bool, error) { return false, terminal.InterruptErr }, + note: func(string, ...any) { t.Fatal("no note after Ctrl-C") }, + } + + var in setup.Inputs + + require.ErrorIs(t, askPoppyWebUnscoped(true, prompts, &setup.State{}, &in), terminal.InterruptErr) + assert.Nil(t, in.WebUnscoped) + }) +} + +func TestPoppyWebUnscopedWording(t *testing.T) { + assert.Equal(t, "Website access", poppyWebUnscopedTitle) + assert.Contains(t, poppyWebUnscopedText, "poppy:browser-sign-in") + assert.Contains(t, poppyWebUnscopedText, "web.unscoped is set to true") + assert.Contains(t, poppyWebUnscopedNote, "auth0 poppy doctor reminds you") +} + +// webAnswer is what a person does at the question about web.unscoped: a bool +// answers it, an error is Ctrl-C. +type webAnswer any + +// runWebMode runs a command in a mode with the question about web.unscoped +// scripted through the confirm seam, so that the real prompts and the screen +// handle it. It returns the run and how many times the question was asked. +func (h *poppySetupHarness) runWebMode(t *testing.T, mode poppyPromptMode, args []string, answer webAnswer) (poppyPromptRun, int) { + t.Helper() + + installPoppyAsker(t, mode) + + asked := 0 + previous := poppyAsk.confirm + + poppyAsk.web = terminalPoppyWebPrompts + poppyAsk.interrupt = func(err error) error { return err } + poppyAsk.confirm = func(s *poppyScreen, message string, def bool) (bool, error) { + if message != poppyWebUnscopedText { + return previous(s, message, def) + } + + asked++ + + if err, ok := answer.(error); ok { + return false, err + } + + return answer.(bool), nil + } + + h.agentMode = mode.agentMode + + err := h.run(append(args, mode.flags...)...) + + return poppyPromptRun{out: h.out.String(), errOut: h.messages.String(), err: err}, asked +} + +// poppyConfigWithWeb returns a copy of the golden config file for the configured +// tenant with the web member written as given, or untouched when member is empty. +func poppyConfigWithWeb(t *testing.T, member string) string { + t.Helper() + + path := goldenPoppyConfig(t) + if member == "" { + return path + } + + data, err := os.ReadFile(path) + require.NoError(t, err) + + raw := strings.Replace(string(data), `"browser_session_endpoint"`, member+`, "browser_session_endpoint"`, 1) + require.NotEqual(t, string(data), raw) + require.NoError(t, os.WriteFile(path, []byte(raw), 0o600)) + + return path +} + +// unscopedIn reads the web.unscoped member of a config file: its value and +// whether the file has it. +func unscopedIn(t *testing.T, path string) (value, present bool) { + t.Helper() + + cfg, err := config.Read(path) + require.NoError(t, err) + + return cfg.Web.Unscoped, cfg.Web.UnscopedSet +} + +func TestPoppySetupWebUnscoped(t *testing.T) { + // Fresh tenant, no file: the question is asked whenever a terminal is there and + // no flag answers it. + tests := []struct { + name string + mode poppyPromptMode + flags []string + answer webAnswer + wantAsked int + // The member in the written file: its value, and whether it is there. + wantValue bool + wantPresent bool + }{ + {name: "terminal, yes", mode: modeTerminal, answer: true, wantAsked: 1, wantValue: true, wantPresent: true}, + {name: "terminal, no is written as false", mode: modeTerminal, answer: false, wantAsked: 1, wantPresent: true}, + {name: "terminal with --json still asks", mode: modeTerminalJSON, answer: true, wantAsked: 1, wantValue: true, wantPresent: true}, + {name: "the flag answers it, true", mode: modeTerminal, flags: []string{"--web-unscoped"}, answer: false, wantValue: true, wantPresent: true}, + {name: "the flag answers it, explicit true", mode: modeTerminal, flags: []string{"--web-unscoped=true"}, answer: false, wantValue: true, wantPresent: true}, + {name: "the flag answers it, false", mode: modeTerminal, flags: []string{"--web-unscoped=false"}, answer: true, wantPresent: true}, + {name: "terminal with --no-input asks nothing and leaves it absent", mode: modeTerminalNoInput, answer: true}, + {name: "piped asks nothing and leaves it absent", mode: modePiped, answer: true}, + {name: "piped with --json asks nothing", mode: modePipedJSON, answer: true}, + {name: "agent mode asks nothing", mode: modeAgent, answer: true}, + {name: "piped uses the flag", mode: modePiped, flags: []string{"--web-unscoped"}, answer: true, wantValue: true, wantPresent: true}, + {name: "piped uses the flag set to false", mode: modePiped, flags: []string{"--web-unscoped=false"}, answer: true, wantPresent: true}, + {name: "agent mode uses the flag", mode: modeAgent, flags: []string{"--web-unscoped"}, answer: true, wantValue: true, wantPresent: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + args := freshSetupArgs(t, append([]string{"--yes", "--skip-doctor", "--guest-api-access", "--guest-all-scopes"}, test.flags...)...) + + run, asked := h.runWebMode(t, test.mode, args, test.answer) + + require.NoError(t, run.err) + assert.Equal(t, test.wantAsked, asked, "times asked") + + value, present := unscopedIn(t, outOf(args)) + assert.Equal(t, test.wantValue, value, "web.unscoped") + assert.Equal(t, test.wantPresent, present, "web.unscoped is in the file") + + if test.wantAsked > 0 && test.answer == true { + assert.Contains(t, run.out, poppyWebUnscopedNote, "the note after a yes") + } else { + assert.NotContains(t, run.out, poppyWebUnscopedNote) + } + }) + } +} + +func TestPoppySetupWebUnscopedWithAFile(t *testing.T) { + tests := []struct { + name string + member string + flags []string + answer webAnswer + wantAsked int + wantValue bool + wantPresent bool + }{ + {name: "the file has no value: asked, yes", member: "", answer: true, wantAsked: 1, wantValue: true, wantPresent: true}, + {name: "the file has no value: asked, no", member: "", answer: false, wantAsked: 1, wantPresent: true}, + {name: "the file says false: not asked, kept", member: `"unscoped": false`, answer: true, wantPresent: true}, + {name: "the file says true: not asked, kept", member: `"unscoped": true`, answer: false, wantValue: true, wantPresent: true}, + {name: "the file says false, the flag says true", member: `"unscoped": false`, flags: []string{"--web-unscoped"}, answer: false, wantValue: true, wantPresent: true}, + {name: "the file says true, the flag says false", member: `"unscoped": true`, flags: []string{"--web-unscoped=false"}, answer: true, wantPresent: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + h.allowWrites() + + path := poppyConfigWithWeb(t, test.member) + args := []string{"setup", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--guest-api-access", "--guest-all-scopes", "--out", path, "--skip-doctor", "--yes"} + + run, asked := h.runWebMode(t, modeTerminal, append(args, test.flags...), test.answer) + + require.NoError(t, run.err) + assert.Equal(t, test.wantAsked, asked, "times asked") + + value, present := unscopedIn(t, path) + assert.Equal(t, test.wantValue, value, "web.unscoped") + assert.Equal(t, test.wantPresent, present, "web.unscoped is in the file") + }) + } +} + +func TestPoppySetupWebUnscopedAsksAfterTheGuestQuestion(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + installedAsked := installPoppyAsker(t, modeTerminal) + poppyAsk.web = func(s *poppyScreen) poppyWebPrompts { + return poppyWebPrompts{ + unscoped: func() (bool, error) { + *installedAsked = append(*installedAsked, "web access") + + return false, nil + }, + note: func(string, ...any) {}, + } + } + + require.NoError(t, h.run(freshSetupArgs(t, "--yes", "--skip-doctor")...)) + + assert.Equal(t, []string{"hints", "guest access", "guest scopes", "web access"}, *installedAsked) +} + +func TestPoppySetupWebUnscopedDryRun(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + args := freshSetupArgs(t, "--dry-run", "--skip-doctor", "--guest-api-access", "--guest-all-scopes") + + run, asked := h.runWebMode(t, modeTerminal, args, true) + + require.NoError(t, run.err) + assert.Equal(t, 1, asked, "a dry run in a terminal asks as a real run does") + assert.Contains(t, run.out, "Your answers") + assert.Contains(t, run.out, "Web access web.unscoped true") + assert.Contains(t, run.out, "--web-unscoped=true", "the command to apply carries the answer") + assert.NoFileExists(t, outOf(args), "a dry run writes nothing") +} + +func TestPoppySetupWebUnscopedDryRunFromTheFlag(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + path := poppyConfigWithWeb(t, "") + before, err := os.ReadFile(path) + require.NoError(t, err) + + args := []string{"setup", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, + "--guest-api-access", "--guest-all-scopes", "--out", path, "--skip-doctor", "--dry-run", "--web-unscoped"} + + run, asked := h.runWebMode(t, modePiped, args, true) + + require.NoError(t, run.err) + assert.Zero(t, asked) + assert.Contains(t, run.out, "Web access web.unscoped true") + assert.Contains(t, run.out, "updated", "the plan shows the file as changed, because only web.unscoped differs") + + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(before), string(after)) +} + +func TestPoppySetupWebUnscopedCtrlC(t *testing.T) { + h := newPoppySetupHarness(t, freshPoppyTenant()) + h.allowWrites() + + args := freshSetupArgs(t, "--yes", "--skip-doctor", "--guest-api-access", "--guest-all-scopes") + + run, asked := h.runWebMode(t, modeTerminal, args, terminal.InterruptErr) + + require.ErrorIs(t, run.err, terminal.InterruptErr) + assert.Equal(t, 1, asked) + assert.Contains(t, run.out, display.PoppyCancelled) + assert.Empty(t, h.calls, "the tenant was not changed") + assert.NoFileExists(t, outOf(args)) +} + +func TestPoppyConfigWebUnscoped(t *testing.T) { + tests := []struct { + name string + mode poppyPromptMode + member string + flags []string + answer webAnswer + wantAsked int + wantValue bool + wantPresent bool + }{ + {name: "terminal, no value in the file, yes", mode: modeTerminal, answer: true, wantAsked: 1, wantValue: true, wantPresent: true}, + {name: "terminal, no value in the file, no", mode: modeTerminal, answer: false, wantAsked: 1, wantPresent: true}, + {name: "terminal with --json still asks", mode: modeTerminalJSON, answer: true, wantAsked: 1, wantValue: true, wantPresent: true}, + {name: "the file says false: not asked", mode: modeTerminal, member: `"unscoped": false`, answer: true, wantPresent: true}, + {name: "the file says true: not asked", mode: modeTerminal, member: `"unscoped": true`, answer: false, wantValue: true, wantPresent: true}, + {name: "the flag over a true file", mode: modeTerminal, member: `"unscoped": true`, flags: []string{"--web-unscoped=false"}, answer: true, wantPresent: true}, + {name: "the flag over an empty file", mode: modeTerminal, flags: []string{"--web-unscoped"}, answer: false, wantValue: true, wantPresent: true}, + {name: "terminal with --no-input leaves it absent", mode: modeTerminalNoInput, answer: true}, + {name: "piped leaves it absent", mode: modePiped, answer: true}, + {name: "piped keeps the file's value", mode: modePiped, member: `"unscoped": true`, answer: false, wantValue: true, wantPresent: true}, + {name: "piped uses the flag", mode: modePiped, flags: []string{"--web-unscoped"}, answer: false, wantValue: true, wantPresent: true}, + {name: "agent mode leaves it absent", mode: modeAgent, answer: true}, + {name: "agent mode uses the flag set to false", mode: modeAgent, flags: []string{"--web-unscoped=false"}, answer: true, wantPresent: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + path := poppyConfigWithWeb(t, test.member) + + args := append([]string{"config", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, "--out", path}, test.flags...) + + run, asked := h.runWebMode(t, test.mode, args, test.answer) + + require.NoError(t, run.err) + assert.Equal(t, test.wantAsked, asked, "times asked") + + value, present := unscopedIn(t, path) + assert.Equal(t, test.wantValue, value, "web.unscoped") + assert.Equal(t, test.wantPresent, present, "web.unscoped is in the file") + }) + } +} + +func TestPoppyConfigWebUnscopedShowsInTheAnswers(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + path := poppyConfigWithWeb(t, "") + + run, asked := h.runWebMode(t, modeTerminal, []string{"config", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, "--out", path}, true) + + require.NoError(t, run.err) + assert.Equal(t, 1, asked) + assert.Contains(t, run.out, "Your answers") + assert.Contains(t, run.out, "Web access web.unscoped true") + assert.Contains(t, run.out, poppyWebUnscopedNote) +} + +func TestPoppyConfigWebUnscopedCtrlC(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + path := poppyConfigWithWeb(t, "") + + before, err := os.ReadFile(path) + require.NoError(t, err) + + run, asked := h.runWebMode(t, modeTerminal, []string{"config", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, "--out", path}, terminal.InterruptErr) + + require.ErrorIs(t, run.err, terminal.InterruptErr) + assert.Equal(t, 1, asked) + assert.Contains(t, run.out, display.PoppyCancelled) + + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), "the file was not changed") +} + +func TestPoppyConfigKeepsWebDisabledAndNeverAsksForIt(t *testing.T) { + h := newPoppySetupHarness(t, configuredPoppyTenant()) + path := poppyConfigWithWeb(t, `"disabled": true`) + + run, asked := h.runWebMode(t, modeTerminal, []string{"config", "--company-domain", "shop.example", "--api", poppyTestAPI, "--app", poppySetupAppID, "--out", path}, true) + + require.NoError(t, run.err) + assert.Equal(t, 1, asked, "only web.unscoped is asked") + + cfg, err := config.Read(path) + require.NoError(t, err) + assert.True(t, cfg.Web.Disabled) + assert.True(t, cfg.Web.Unscoped) +} diff --git a/internal/display/poppy.go b/internal/display/poppy.go index 13d2f89ca..606baf7ac 100644 --- a/internal/display/poppy.go +++ b/internal/display/poppy.go @@ -844,14 +844,15 @@ func poppyAnswers(l PoppyLayout, p setup.Plan, in setup.Inputs) string { rows = append(rows, poppyGuestAnswers(p, in)...) rows = append(rows, poppyHintAnswers(in)...) + rows = append(rows, poppyWebAnswers(in)...) return poppyAnswersText(l, rows) } // poppyHintsText is the answers block of a run that has no plan, with only the -// hints. It is empty when there are no hints. +// hints and the answer about the website. It is empty when there are neither. func poppyHintsText(l PoppyLayout, in setup.Inputs) string { - rows := poppyHintAnswers(in) + rows := append(poppyHintAnswers(in), poppyWebAnswers(in)...) if len(rows) == 0 { return "" } @@ -911,6 +912,16 @@ func poppyHintAnswers(in setup.Inputs) []poppyAnswerRow { return []poppyAnswerRow{{label: "Hints", value: lines[0], more: lines[1:]}} } +// poppyWebAnswers says what the owner decided about web.unscoped, when this run was +// given a value for it, by a flag or by an answer. +func poppyWebAnswers(in setup.Inputs) []poppyAnswerRow { + if in.WebUnscoped == nil { + return nil + } + + return []poppyAnswerRow{{label: "Web access", value: fmt.Sprintf("web.unscoped %t", *in.WebUnscoped)}} +} + // poppyGuestAnswers says what the run decided about the guest grant: the scopes of // the grant to make on each API, or that there is none. func poppyGuestAnswers(p setup.Plan, in setup.Inputs) []poppyAnswerRow { diff --git a/internal/display/poppy_test.go b/internal/display/poppy_test.go index 283515c5f..64d4e531a 100644 --- a/internal/display/poppy_test.go +++ b/internal/display/poppy_test.go @@ -960,3 +960,41 @@ func TestPoppyChangeCount(t *testing.T) { assert.Equal(t, "1 change", PoppyChangeCount(1)) assert.Equal(t, "3 changes", PoppyChangeCount(3)) } + +func TestPoppyWebAnswers(t *testing.T) { + yes, no := true, false + + tests := []struct { + name string + in setup.Inputs + want string + }{ + {name: "not given, no row", in: setup.Inputs{}}, + {name: "true", in: setup.Inputs{WebUnscoped: &yes}, want: " Web access web.unscoped true\n"}, + {name: "false", in: setup.Inputs{WebUnscoped: &no}, want: " Web access web.unscoped false\n"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := poppyHintsText(unicode80, test.in) + + if test.want == "" { + assert.Empty(t, got) + + return + } + + assert.Equal(t, "Your answers\n"+test.want, got) + }) + } + + t.Run("the plan lists it among the answers", func(t *testing.T) { + state, in := poppyRichTenant(t) + in.WebUnscoped = &yes + + out := poppyPlanText(unicode80, poppyBuildPlan(t, state, in), PoppySetupOptions{Inputs: in, DryRun: true}) + + assert.Contains(t, out, " Web access web.unscoped true\n") + assert.Contains(t, out, "--web-unscoped=true", "the command to apply repeats the answer") + }) +} diff --git a/internal/poppy/config/config.go b/internal/poppy/config/config.go index a856c10fe..cd2b2557b 100644 --- a/internal/poppy/config/config.go +++ b/internal/poppy/config/config.go @@ -116,10 +116,15 @@ type API struct { type Web struct { BrowserSessionEndpoint string // Unscoped and Disabled are the web.unscoped and web.disabled settings of - // poppy.json (protocol 0.4). Only the owner of the website knows them, so - // the CLI never sets them: it keeps what an existing file has. + // poppy.json (protocol 0.4). Only the owner of the website knows them. The + // CLI sets Unscoped only from the owner's answer (a flag or a question) and + // otherwise keeps what an existing file has. It never sets Disabled. Unscoped bool Disabled bool + // UnscopedSet tells that the file has the web.unscoped member, or that the CLI + // set it, so that an explicit false differs from an absent member. A true + // Unscoped is always written, whatever UnscopedSet says. + UnscopedSet bool } // BrowserSessionEndpoint returns the browser join address for a web app. The diff --git a/internal/poppy/config/diff.go b/internal/poppy/config/diff.go index f4080ee0b..7fb8211bc 100644 --- a/internal/poppy/config/diff.go +++ b/internal/poppy/config/diff.go @@ -91,8 +91,8 @@ func flatten(c *Config) map[string]string { set("web.browser_session_endpoint", c.Web.BrowserSessionEndpoint) - if c.Web.Unscoped { - set("web.unscoped", "true") + if c.Web.UnscopedSet || c.Web.Unscoped { + set("web.unscoped", strconv.FormatBool(c.Web.Unscoped)) } if c.Web.Disabled { diff --git a/internal/poppy/config/file.go b/internal/poppy/config/file.go index acb47cd51..34c176277 100644 --- a/internal/poppy/config/file.go +++ b/internal/poppy/config/file.go @@ -50,7 +50,7 @@ type fileDoc struct { Web *struct { BrowserSessionEndpoint string `json:"browser_session_endpoint"` Disabled bool `json:"disabled"` - Unscoped bool `json:"unscoped"` + Unscoped *bool `json:"unscoped"` } `json:"web"` } @@ -108,7 +108,12 @@ func decode(data []byte) (*Config, error) { } if doc.Web != nil { - cfg.Web = Web{BrowserSessionEndpoint: doc.Web.BrowserSessionEndpoint, Disabled: doc.Web.Disabled, Unscoped: doc.Web.Unscoped} + cfg.Web = Web{ + BrowserSessionEndpoint: doc.Web.BrowserSessionEndpoint, + Disabled: doc.Web.Disabled, + Unscoped: doc.Web.Unscoped != nil && *doc.Web.Unscoped, + UnscopedSet: doc.Web.Unscoped != nil, + } cfg.extra.web = without(rawObjectOf(top["web"]), webOrder...) cfg.extra.emptyWeb = cfg.Web == Web{} && cfg.extra.web == nil } @@ -325,8 +330,8 @@ func (c *Config) webOut(put putFunc) rawMembers { put(m, "disabled", true) } - if c.Web.Unscoped { - put(m, "unscoped", true) + if c.Web.Unscoped || c.Web.UnscopedSet { + put(m, "unscoped", c.Web.Unscoped) } return rawMembers{m, webOrder} diff --git a/internal/poppy/config/merge.go b/internal/poppy/config/merge.go index 60f1e2531..eaa0784d5 100644 --- a/internal/poppy/config/merge.go +++ b/internal/poppy/config/merge.go @@ -61,6 +61,9 @@ type Inputs struct { APIDescriptions map[string]string // Hints holds scope hints by scope name. Hints map[string]Hints + // WebUnscoped is the owner's answer for web.unscoped. Nil means it was not + // given, and the existing file's value is kept. + WebUnscoped *bool } // MergeOptions changes how Merge behaves. @@ -86,8 +89,9 @@ func CheckIssuer(existing *Config, issuer string, opts MergeOptions) error { // // The result is always at the current protocol version, so a file written at an // older supported version is upgraded. The web.unscoped and web.disabled settings -// of the existing file are kept, because only the website owner knows them, and -// so is every member of the file that the CLI does not use, such as agent, +// of the existing file are kept, because only the website owner knows them, except +// that an input for web.unscoped (the owner's answer) overrides the file's value. +// So is every member of the file that the CLI does not use, such as agent, // feedback or extensions. // // Tenant-derived values always win over the existing file. For values Auth0 @@ -128,6 +132,7 @@ func Merge(existing, derived *Config, in Inputs, opts MergeOptions) (*Config, er Web: Web{ BrowserSessionEndpoint: existing.Web.BrowserSessionEndpoint, Unscoped: existing.Web.Unscoped, + UnscopedSet: existing.Web.UnscopedSet, Disabled: existing.Web.Disabled, }, extra: existing.extra.clone(), @@ -150,6 +155,10 @@ func Merge(existing, derived *Config, in Inputs, opts MergeOptions) (*Config, er out.Web.BrowserSessionEndpoint = BrowserSessionEndpoint(out.Issuer, in.ClientID) } + if in.WebUnscoped != nil { + out.Web.Unscoped, out.Web.UnscopedSet = *in.WebUnscoped, true + } + for name, scope := range existing.Scopes { if scope.Hints != nil { h := *scope.Hints diff --git a/internal/poppy/config/unscoped_test.go b/internal/poppy/config/unscoped_test.go new file mode 100644 index 000000000..a4dafad6b --- /dev/null +++ b/internal/poppy/config/unscoped_test.go @@ -0,0 +1,194 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func boolPtr(b bool) *bool { return &b } + +// withUnscoped returns the golden file with a web.unscoped member added, or the +// golden file as it is when member is empty. +func withUnscoped(t *testing.T, member string) string { + t.Helper() + + golden, err := os.ReadFile(goldenFile) + require.NoError(t, err) + + if member == "" { + return string(golden) + } + + raw := strings.Replace(string(golden), `"browser_session_endpoint"`, member+`, "browser_session_endpoint"`, 1) + require.NotEqual(t, string(golden), raw) + + return raw +} + +func TestWebUnscopedPresence(t *testing.T) { + tests := []struct { + name string + member string + wantUnscope bool + wantSet bool + }{ + {name: "absent", member: ""}, + {name: "true", member: `"unscoped": true`, wantUnscope: true, wantSet: true}, + {name: "false", member: `"unscoped": false`, wantSet: true}, + {name: "null counts as absent", member: `"unscoped": null`}, + } + + for _, test := range tests { + t.Run("read: "+test.name, func(t *testing.T) { + cfg, err := Read(writeRaw(t, withUnscoped(t, test.member))) + + require.NoError(t, err) + assert.Equal(t, test.wantUnscope, cfg.Web.Unscoped) + assert.Equal(t, test.wantSet, cfg.Web.UnscopedSet) + }) + + t.Run("round trip: "+test.name, func(t *testing.T) { + path := writeRaw(t, withUnscoped(t, test.member)) + + cfg, err := Read(path) + require.NoError(t, err) + + out := filepath.Join(t.TempDir(), DefaultFileName) + require.NoError(t, Write(out, cfg)) + + written, err := os.ReadFile(out) + require.NoError(t, err) + + switch { + case test.wantSet && test.wantUnscope: + assert.Contains(t, string(written), `"unscoped": true`) + case test.wantSet: + assert.Contains(t, string(written), `"unscoped": false`, "an explicit false is written back") + default: + assert.NotContains(t, string(written), "unscoped", "an absent member stays absent") + } + + again, err := Read(out) + require.NoError(t, err) + assert.Equal(t, cfg.Web, again.Web) + }) + } +} + +func TestMarshalUnscoped(t *testing.T) { + tests := []struct { + name string + web Web + want string + }{ + {name: "neither", web: Web{BrowserSessionEndpoint: "https://auth.shop.example/poppy/W/browser-session"}}, + {name: "set to false", web: Web{UnscopedSet: true}, want: `"unscoped": false`}, + {name: "set to true", web: Web{Unscoped: true, UnscopedSet: true}, want: `"unscoped": true`}, + {name: "true without the set flag is still written", web: Web{Unscoped: true}, want: `"unscoped": true`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := exampleConfig() + cfg.Web = test.web + + got, err := marshal(cfg) + require.NoError(t, err) + + if test.want == "" { + assert.NotContains(t, string(got), "unscoped") + return + } + + assert.Contains(t, string(got), test.want) + }) + } +} + +func TestMergeWebUnscoped(t *testing.T) { + existing := func(unscoped, set bool) *Config { + cfg := exampleConfig() + cfg.Web.Unscoped, cfg.Web.UnscopedSet = unscoped, set + + return cfg + } + + tests := []struct { + name string + existing *Config + input *bool + wantUnscope bool + wantSet bool + }{ + {name: "no file, no input", existing: nil}, + {name: "no file, input true", existing: nil, input: boolPtr(true), wantUnscope: true, wantSet: true}, + {name: "no file, input false", existing: nil, input: boolPtr(false), wantSet: true}, + {name: "absent in the file, no input stays absent", existing: existing(false, false)}, + {name: "absent in the file, input false", existing: existing(false, false), input: boolPtr(false), wantSet: true}, + {name: "false in the file is kept", existing: existing(false, true), wantSet: true}, + {name: "true in the file is kept", existing: existing(true, true), wantUnscope: true, wantSet: true}, + {name: "input true overrides false", existing: existing(false, true), input: boolPtr(true), wantUnscope: true, wantSet: true}, + {name: "input false overrides true", existing: existing(true, true), input: boolPtr(false), wantSet: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + in := fullInputs() + in.WebUnscoped = test.input + + got, err := Merge(test.existing, derivedConfig(), in, MergeOptions{}) + + require.NoError(t, err) + assert.Equal(t, test.wantUnscope, got.Web.Unscoped) + assert.Equal(t, test.wantSet, got.Web.UnscopedSet) + }) + } + + t.Run("does not modify the existing file", func(t *testing.T) { + file := existing(false, false) + in := fullInputs() + in.WebUnscoped = boolPtr(true) + + _, err := Merge(file, derivedConfig(), in, MergeOptions{}) + + require.NoError(t, err) + assert.Equal(t, existing(false, false), file) + }) +} + +func TestDiffWebUnscoped(t *testing.T) { + cfg := func(unscoped, set bool) *Config { + c := exampleConfig() + c.Web.Unscoped, c.Web.UnscopedSet = unscoped, set + + return c + } + + tests := []struct { + name string + a, b *Config + want []Change + }{ + {name: "absent to false is a change", a: cfg(false, false), b: cfg(false, true), + want: []Change{{Path: "web.unscoped", Kind: ChangeAdded, New: "false"}}}, + {name: "absent to true", a: cfg(false, false), b: cfg(true, true), + want: []Change{{Path: "web.unscoped", Kind: ChangeAdded, New: "true"}}}, + {name: "false to true", a: cfg(false, true), b: cfg(true, true), + want: []Change{{Path: "web.unscoped", Kind: ChangeChanged, Old: "false", New: "true"}}}, + {name: "true to false", a: cfg(true, true), b: cfg(false, true), + want: []Change{{Path: "web.unscoped", Kind: ChangeChanged, Old: "true", New: "false"}}}, + {name: "false to false is none", a: cfg(false, true), b: cfg(false, true), want: []Change{}}, + {name: "absent to absent is none", a: cfg(false, false), b: cfg(false, false), want: []Change{}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, Diff(test.a, test.b)) + }) + } +} diff --git a/internal/poppy/doctor/checks_version.go b/internal/poppy/doctor/checks_version.go index 35ecd276e..af06a6da6 100644 --- a/internal/poppy/doctor/checks_version.go +++ b/internal/poppy/doctor/checks_version.go @@ -116,7 +116,7 @@ func checkBrowserSignIn(doc *discovery.Poppy, issuer *discovery.AuthorizationSer if isTrue(doc.Web.Unscoped) { c.warn("D16", specBrowserJoin, "web.unscoped is true: check by hand that the description of "+scope+" says it gives full account access", - "Describe "+scope+" as access to everything the account can do on the website.") + "Describe "+scope+" as full access to the account on the website, for example \"Browse the website, and see and do anything your account can do there\".") } if doc.Web.BrowserSessionEndpoint == "" || isTrue(doc.Web.Disabled) || doc.Auth.Disabled { diff --git a/internal/poppy/setup/command.go b/internal/poppy/setup/command.go index 34cfb38ef..cb4571d27 100644 --- a/internal/poppy/setup/command.go +++ b/internal/poppy/setup/command.go @@ -67,6 +67,10 @@ func Command(in Inputs, opts CommandOptions) []string { parts = append(parts, guestFlags(in)...) + if in.WebUnscoped != nil { + parts = append(parts, fmt.Sprintf("--web-unscoped=%t", *in.WebUnscoped)) + } + if in.ForceConfig { parts = append(parts, "--force-config") } diff --git a/internal/poppy/setup/derive.go b/internal/poppy/setup/derive.go index 73e2acc79..6c34bf204 100644 --- a/internal/poppy/setup/derive.go +++ b/internal/poppy/setup/derive.go @@ -46,6 +46,17 @@ func (s *State) CompanyDomain(in Inputs) (domain string, choices []string) { return "", hosts } +// AsksWebUnscoped tells whether web.unscoped is still a question for the owner: it +// was not given as an input, and the config file has no value for it. A file that +// says false was answered already. +func (s *State) AsksWebUnscoped(in Inputs) bool { + if in.WebUnscoped != nil { + return false + } + + return s.File == nil || (!s.File.Web.UnscopedSet && !s.File.Web.Unscoped) +} + // Ready checks that the tenant is set up for Poppy, for the settings that the // config file depends on: the company domain is listed, each API has Poppy on and // the browser join is on. It returns a *NotReadyError naming every setting that @@ -140,6 +151,7 @@ func derive(state *State, in Inputs, scopes map[string]apiScopes) (*config.Confi APIs: given, APIDescriptions: descriptions, Hints: in.Hints, + WebUnscoped: in.WebUnscoped, }, config.MergeOptions{ForceConfig: in.ForceConfig}) var ambiguous *config.AmbiguousAPIError diff --git a/internal/poppy/setup/inputs.go b/internal/poppy/setup/inputs.go index 2f895dde4..bf152abbf 100644 --- a/internal/poppy/setup/inputs.go +++ b/internal/poppy/setup/inputs.go @@ -85,6 +85,8 @@ type Flags struct { GuestScopes []string GuestAllScopes bool ForceConfig bool + // WebUnscoped is the web.unscoped flag. Nil means it was not passed. + WebUnscoped *bool } // Inputs holds the validated values of the flags and of the answers to the @@ -114,6 +116,10 @@ type Inputs struct { // is set, else no guest grant. GuestScopes map[string][]string ForceConfig bool + // WebUnscoped is the owner's answer for web.unscoped, from the flag or from + // the question. Nil means it was not given, and the config file's value is + // kept. + WebUnscoped *bool } // Parse validates the flag values. Missing required values are not an error @@ -129,6 +135,11 @@ func (f Flags) Parse() (Inputs, error) { ForceConfig: f.ForceConfig, } + if f.WebUnscoped != nil { + unscoped := *f.WebUnscoped + in.WebUnscoped = &unscoped + } + for _, name := range f.GuestScopes { if name = strings.TrimSpace(name); name != "" && !slices.Contains(in.GuestScopeNames, name) { in.GuestScopeNames = append(in.GuestScopeNames, name) diff --git a/internal/poppy/setup/web_unscoped_test.go b/internal/poppy/setup/web_unscoped_test.go new file mode 100644 index 000000000..de1c3768e --- /dev/null +++ b/internal/poppy/setup/web_unscoped_test.go @@ -0,0 +1,159 @@ +package setup + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/poppy/config" +) + +func boolPtr(b bool) *bool { return &b } + +func TestFlagsParseWebUnscoped(t *testing.T) { + tests := []struct { + name string + given *bool + want *bool + }{ + {name: "not passed", given: nil, want: nil}, + {name: "true", given: boolPtr(true), want: boolPtr(true)}, + {name: "false", given: boolPtr(false), want: boolPtr(false)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + in, err := Flags{WebUnscoped: test.given}.Parse() + + require.NoError(t, err) + assert.Equal(t, test.want, in.WebUnscoped) + }) + } + + t.Run("the inputs do not share the flag value", func(t *testing.T) { + given := true + in, err := Flags{WebUnscoped: &given}.Parse() + require.NoError(t, err) + + given = false + assert.True(t, *in.WebUnscoped) + }) +} + +func TestStateAsksWebUnscoped(t *testing.T) { + withFile := func(unscoped, set bool) *State { + state := configuredState(t) + state.File.Web.Unscoped, state.File.Web.UnscopedSet = unscoped, set + + return state + } + + tests := []struct { + name string + state *State + in Inputs + want bool + }{ + {name: "no file", state: &State{}, want: true}, + {name: "the file has no value", state: withFile(false, false), want: true}, + {name: "the file says false", state: withFile(false, true), want: false}, + {name: "the file says true", state: withFile(true, true), want: false}, + {name: "the flag was passed as true", state: withFile(false, false), in: Inputs{WebUnscoped: boolPtr(true)}, want: false}, + {name: "the flag was passed as false", state: &State{}, in: Inputs{WebUnscoped: boolPtr(false)}, want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, test.state.AsksWebUnscoped(test.in)) + }) + } +} + +func TestDeriveWebUnscoped(t *testing.T) { + tests := []struct { + name string + fileSet bool + fileValue bool + input *bool + wantUnscope bool + wantSet bool + }{ + {name: "absent stays absent", wantUnscope: false, wantSet: false}, + {name: "the file's false is kept", fileSet: true, wantSet: true}, + {name: "the file's true is kept", fileSet: true, fileValue: true, wantUnscope: true, wantSet: true}, + {name: "a given true", input: boolPtr(true), wantUnscope: true, wantSet: true}, + {name: "a given false", input: boolPtr(false), wantSet: true}, + {name: "a given false over the file's true", fileSet: true, fileValue: true, input: boolPtr(false), wantSet: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state, in := configuredState(t), configuredInputs() + state.File.Web.Unscoped, state.File.Web.UnscopedSet = test.fileValue, test.fileSet + in.WebUnscoped = test.input + + cfg, err := Derive(state, in) + + require.NoError(t, err) + assert.Equal(t, test.wantUnscope, cfg.Web.Unscoped) + assert.Equal(t, test.wantSet, cfg.Web.UnscopedSet) + }) + } +} + +func TestBuildWebUnscopedChangesTheFile(t *testing.T) { + t.Run("only the answer differs, so the plan writes the file", func(t *testing.T) { + state, in := configuredState(t), configuredInputs() + in.WebUnscoped = boolPtr(true) + + plan, err := Build(state, in) + + require.NoError(t, err) + assert.Equal(t, []Kind{KindFile}, kinds(plan)) + }) + + t.Run("the answer the file already has changes nothing", func(t *testing.T) { + state, in := configuredState(t), configuredInputs() + state.File.Web.Unscoped, state.File.Web.UnscopedSet = true, true + in.WebUnscoped = boolPtr(true) + + plan, err := Build(state, in) + + require.NoError(t, err) + assert.True(t, plan.Empty()) + }) + + t.Run("false is written when the file has no value", func(t *testing.T) { + state, in := configuredState(t), configuredInputs() + in.WebUnscoped = boolPtr(false) + + plan, err := Build(state, in) + + require.NoError(t, err) + assert.Equal(t, []Kind{KindFile}, kinds(plan)) + }) +} + +func TestCommandWebUnscoped(t *testing.T) { + base := Inputs{CompanyDomain: "shop.example", APIs: []string{shopAPI}, App: "app1", Out: config.DefaultFileName} + + tests := []struct { + name string + given *bool + want string + }{ + {name: "not given", want: `auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app app1`}, + {name: "true", given: boolPtr(true), want: `auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app app1 --web-unscoped=true`}, + {name: "false", given: boolPtr(false), want: `auth0 poppy setup --company-domain shop.example --api https://api.shop.example --app app1 --web-unscoped=false`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + in := base + in.WebUnscoped = test.given + + assert.Equal(t, test.want, FormatCommand("", Command(in, CommandOptions{}), 0)) + }) + } +}