From 025d4d8086d4ea639df95dee50c6bf240e6127ed Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 24 Aug 2026 14:09:49 +0530 Subject: [PATCH] feat: add Forms management commands --- Makefile | 2 +- README.md | 1 + docs/auth0_forms.md | 20 + docs/auth0_forms_create.md | 68 ++ docs/auth0_forms_delete.md | 59 ++ docs/auth0_forms_export.md | 55 + docs/auth0_forms_import.md | 60 ++ docs/auth0_forms_list.md | 58 ++ docs/auth0_forms_open.md | 47 + docs/auth0_forms_show.md | 55 + docs/auth0_forms_update.md | 60 ++ docs/index.md | 1 + internal/auth/auth.go | 2 +- internal/auth0/auth0.go | 4 +- internal/auth0/form.go | 53 +- internal/auth0/mock/form_mock.go | 106 +- internal/cli/forms.go | 1013 +++++++++++++++++++ internal/cli/forms_envelope.go | 398 ++++++++ internal/cli/forms_envelope_test.go | 206 ++++ internal/cli/forms_test.go | 527 ++++++++++ internal/cli/root.go | 1 + internal/cli/terraform.go | 2 +- internal/cli/terraform_fetcher.go | 26 +- internal/cli/terraform_fetcher_test.go | 42 +- internal/display/forms.go | 237 +++++ internal/display/forms_test.go | 50 + test/integration/fixtures/update-form.json | 10 + test/integration/forms-test-cases.yaml | 128 +++ test/integration/scripts/cleanup-forms.sh | 16 + test/integration/scripts/get-form-id.sh | 13 + test/integration/scripts/run-test-suites.sh | 29 +- test/integration/scripts/test-cleanup.sh | 1 + 32 files changed, 3217 insertions(+), 133 deletions(-) create mode 100644 docs/auth0_forms.md create mode 100644 docs/auth0_forms_create.md create mode 100644 docs/auth0_forms_delete.md create mode 100644 docs/auth0_forms_export.md create mode 100644 docs/auth0_forms_import.md create mode 100644 docs/auth0_forms_list.md create mode 100644 docs/auth0_forms_open.md create mode 100644 docs/auth0_forms_show.md create mode 100644 docs/auth0_forms_update.md create mode 100644 internal/cli/forms.go create mode 100644 internal/cli/forms_envelope.go create mode 100644 internal/cli/forms_envelope_test.go create mode 100644 internal/cli/forms_test.go create mode 100644 internal/display/forms.go create mode 100644 internal/display/forms_test.go create mode 100644 test/integration/fixtures/update-form.json create mode 100644 test/integration/forms-test-cases.yaml create mode 100755 test/integration/scripts/cleanup-forms.sh create mode 100755 test/integration/scripts/get-form-id.sh diff --git a/Makefile b/Makefile index 6434cb882..5adda9fa3 100644 --- a/Makefile +++ b/Makefile @@ -166,7 +166,7 @@ test-unit: ## Run unit tests ${call print, "Running unit tests"} @go test -v -race ${GO_PACKAGES} -coverprofile="coverage-unit-tests.out" -test-integration: install-with-cover $(GO_BIN)/auth0 $(GO_BIN)/commander ## Run integration tests. To run a specific test pass the FILTER var. Usage: `make test-integration FILTER="attack protection"` +test-integration: install-with-cover $(GO_BIN)/auth0 $(GO_BIN)/commander ## Run integration tests. To run a specific test pass the FILTER var. Usage: `make test-integration FILTER="attack protection"`. To run a single suite file pass the FILE var. Usage: `make test-integration FILE="./test/integration/forms-test-cases.yaml"` ${call print, "Running integration tests"} @mkdir -p "coverage" @PATH=$(GO_BIN):$$PATH \ diff --git a/README.md b/README.md index 2c867757e..41f3c17cb 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,7 @@ Select **y** to proceed with your default tenant, or **N** to choose a different - [auth0 completion](https://auth0.github.io/auth0-cli/auth0_completion.html) - Setup autocomplete features for this CLI on your terminal - [auth0 domains](https://auth0.github.io/auth0-cli/auth0_domains.html) - Manage custom domains - [auth0 email](https://auth0.github.io/auth0-cli/auth0_email.html) - Manage email settings +- [auth0 forms](https://auth0.github.io/auth0-cli/auth0_forms.html) - Manage Forms - [auth0 login](https://auth0.github.io/auth0-cli/auth0_login.html) - Authenticate the Auth0 CLI - [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 diff --git a/docs/auth0_forms.md b/docs/auth0_forms.md new file mode 100644 index 000000000..c40b08c34 --- /dev/null +++ b/docs/auth0_forms.md @@ -0,0 +1,20 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 forms + +Forms are customizable screens you can insert into a flow to collect input from users during authentication and other journeys. + +## Commands + +- [auth0 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + diff --git a/docs/auth0_forms_create.md b/docs/auth0_forms_create.md new file mode 100644 index 000000000..8df3842d8 --- /dev/null +++ b/docs/auth0_forms_create.md @@ -0,0 +1,68 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms create + +Create a new form. + +Interactive behavior: `auth0 forms create` asks only for the name and creates a minimal scaffold; it does not open an editor. You can then refine the form in the dashboard builder. + +Pass `--edit` to open an editor and author the form graph before it is created, or supply the whole body via `--file` (or piped stdin) with optional `--name` and `--language-*` overrides. Run `auth0 forms create --example > form.json` to generate an accepted file payload. + +## Usage +``` +auth0 forms create [flags] +``` + +## Examples + +``` + auth0 forms create + auth0 forms create --name "My Form" + auth0 forms create --name "My Form" --edit + auth0 forms create --example > form.json + auth0 forms create --file ./form.json + auth0 forms create --file ./form.json --name "My Form" --language-primary en + cat form.json | auth0 forms create -f - +``` + + +## Flags + +``` + --edit Open an editor to author the form graph after entering the name. + --example Print an example form JSON body and exit. + -f, --file string Path to a JSON file with the form body. Use '-' to read from stdin. + --json Output in json format. + --json-compact Output in compact json format. + --language-default string Default language of the Form (e.g. en). + --language-primary string Primary language of the Form (e.g. en). + --name string Name of the Form. +``` + + +## 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_delete.md b/docs/auth0_forms_delete.md new file mode 100644 index 000000000..a8f923bae --- /dev/null +++ b/docs/auth0_forms_delete.md @@ -0,0 +1,59 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms delete + +Delete a form. + +To delete interactively, use `auth0 forms delete` with no arguments. + +To delete non-interactively, supply the form id and the `--force` flag to skip confirmation. + +## Usage +``` +auth0 forms delete [flags] +``` + +## Examples + +``` + auth0 forms delete + auth0 forms rm + auth0 forms delete + auth0 forms delete --force + auth0 forms delete +``` + + +## Flags + +``` + --force 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_export.md b/docs/auth0_forms_export.md new file mode 100644 index 000000000..f33f52d35 --- /dev/null +++ b/docs/auth0_forms_export.md @@ -0,0 +1,55 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms export + +Export a form as JSON. Writes to stdout by default (pipe-friendly) or to a file with `--output`. The output uses the same envelope as the Auth0 Dashboard (`version`, `form`, `flows`, `connections`), bundling the flows and vault connections the form references with portable `#FLOW-N#`/`#CONN-N#` placeholders, so it can be imported by the CLI or opened in the Dashboard. + +## Usage +``` +auth0 forms export [flags] +``` + +## Examples + +``` + auth0 forms export + auth0 forms export --output ./form.json + auth0 forms export --json-compact + auth0 forms export | auth0 forms import -f - +``` + + +## Flags + +``` + --json-compact Output in compact json format. + -o, --output string Path to write the exported form. Writes to stdout when omitted. +``` + + +## 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_import.md b/docs/auth0_forms_import.md new file mode 100644 index 000000000..1378bf828 --- /dev/null +++ b/docs/auth0_forms_import.md @@ -0,0 +1,60 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms import + +Import a form from a JSON file (or piped stdin). Without `--id` a new form is created; with `--id` the existing form is replaced. + +Both a flat form graph and the Dashboard envelope (`version`, `form`, `flows`, `connections`) are accepted. For an envelope, the bundled flows are created and each `#CONN-N#` connection placeholder is mapped to an existing vault connection, either interactively or with `--connection`. + +## Usage +``` +auth0 forms import [flags] +``` + +## Examples + +``` + auth0 forms import --file ./form.json + auth0 forms import --file ./form.json --id + auth0 forms import --file ./form.json --connection '#CONN-1#=ac_123' + cat form.json | auth0 forms import -f - +``` + + +## Flags + +``` + --connection stringToString Map an exported connection placeholder to an existing vault connection ID, e.g. --connection '#CONN-1#=ac_123'. Repeatable. (default []) + -f, --file string Path to a JSON file with the form body. Use '-' to read from stdin. + --id string Id of an existing Form to replace. When omitted, a new form is created. + --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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_list.md b/docs/auth0_forms_list.md new file mode 100644 index 000000000..ca5602716 --- /dev/null +++ b/docs/auth0_forms_list.md @@ -0,0 +1,58 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms list + +List your existing forms. To create one, run: `auth0 forms create`. + +## Usage +``` +auth0 forms list [flags] +``` + +## Examples + +``` + auth0 forms list + auth0 forms ls + auth0 forms ls --number 100 + auth0 forms ls --json + auth0 forms ls --csv +``` + + +## Flags + +``` + --csv Output in csv format. + --json Output in json format. + --json-compact Output in compact json format. + -n, --number int Number of forms to retrieve. Fetched across pages. (default 100) +``` + + +## 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_open.md b/docs/auth0_forms_open.md new file mode 100644 index 000000000..a312960f1 --- /dev/null +++ b/docs/auth0_forms_open.md @@ -0,0 +1,47 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms open + +Open a form's page in the Auth0 Dashboard form builder. + +## Usage +``` +auth0 forms open [flags] +``` + +## Examples + +``` + auth0 forms open + auth0 forms open +``` + + + + +## 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_show.md b/docs/auth0_forms_show.md new file mode 100644 index 000000000..aeee9aa4e --- /dev/null +++ b/docs/auth0_forms_show.md @@ -0,0 +1,55 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms show + +Display information about a form. + +## Usage +``` +auth0 forms show [flags] +``` + +## Examples + +``` + auth0 forms show + auth0 forms show + auth0 forms show --json + auth0 forms show --json-compact +``` + + +## 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_update.md b/docs/auth0_forms_update.md new file mode 100644 index 000000000..d35a366fa --- /dev/null +++ b/docs/auth0_forms_update.md @@ -0,0 +1,60 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms update + +Update a form. + +Passing `--file` (or piped stdin) replaces every top-level field present in the file. Passing only scalar flags such as `--name` performs a merge that preserves the form's graph fields (nodes, style, translations). Server-managed fields such as `id`, `created_at`, and `updated_at` are removed before the update request is sent. + +## Usage +``` +auth0 forms update [flags] +``` + +## Examples + +``` + auth0 forms update --name "New Name" + auth0 forms update --file ./form.json + cat form.json | auth0 forms update -f - +``` + + +## Flags + +``` + -f, --file string Path to a JSON file with the form body. Use '-' to read from stdin. + --json Output in json format. + --json-compact Output in compact json format. + --language-default string Default language of the Form (e.g. en). + --language-primary string Primary language of the Form (e.g. en). + --name string Name of the Form. +``` + + +## 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/index.md b/docs/index.md index df88b74f0..cc5de36f9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -97,6 +97,7 @@ The help for any command can also be emitted as JSON by combining `--help` with - [auth0 domains](auth0_domains.md) - Manage custom domains - [auth0 email](auth0_email.md) - Manage email settings and configure email providers - [auth0 event-streams](auth0_event-streams.md) - Manage Event Stream +- [auth0 forms](auth0_forms.md) - Manage Forms - [auth0 login](auth0_login.md) - Authenticate the Auth0 CLI - [auth0 logout](auth0_logout.md) - Log out of a tenant's session - [auth0 logs](auth0_logs.md) - View tenant logs diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 89c5245f9..24c512f8f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -133,7 +133,7 @@ var RequiredScopes = []string{ "read:phone_templates", "create:email_templates", "read:email_templates", "update:email_templates", "create:email_provider", "read:email_provider", "update:email_provider", "delete:email_provider", - "read:flows", "read:forms", "read:flows_vault_connections", + "read:flows", "create:flows", "read:forms", "create:forms", "update:forms", "delete:forms", "read:flows_vault_connections", "read:connections", "update:connections", "read:connections_options", "update:connections_options", "read:client_keys", "read:logs", "read:tenant_settings", "update:tenant_settings", "read:custom_domains", "create:custom_domains", "update:custom_domains", "delete:custom_domains", diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index f566abae5..16d54aa2a 100644 --- a/internal/auth0/auth0.go +++ b/internal/auth0/auth0.go @@ -22,7 +22,6 @@ type API struct { EventStream EventStreamAPI Flow FlowAPI FlowVaultConnection FlowVaultConnectionAPI - Form FormAPI Log LogAPI LogStream LogStreamAPI Organization OrganizationAPI @@ -56,7 +55,6 @@ func NewAPI(m *management.Management) *API { EventStream: m.EventStream, Flow: m.Flow, FlowVaultConnection: m.Flow.Vault, - Form: m.Form, Log: m.Log, LogStream: m.LogStream, Organization: m.Organization, @@ -80,6 +78,7 @@ type APIV3 struct { ClientGrant ClientGrantAPIV3 ClientGrantOrganization ClientGrantOrganizationAPIV3 Events EventsAPIV3 + Form FormAPIV3 PhoneNotificationTemplate PhoneNotificationTemplateAPI Session SessionAPIV3 RefreshToken RefreshTokenAPIV3 @@ -95,6 +94,7 @@ func NewAPIV3(m *managementv3.Management) *APIV3 { ClientGrant: m.ClientGrants, ClientGrantOrganization: m.ClientGrants.Organizations, Events: m.Events, + Form: m.Forms, PhoneNotificationTemplate: m.Branding.Phone.Templates, Session: m.Sessions, RefreshToken: m.RefreshTokens, diff --git a/internal/auth0/form.go b/internal/auth0/form.go index 20d562b42..f6dea7cf1 100644 --- a/internal/auth0/form.go +++ b/internal/auth0/form.go @@ -5,22 +5,43 @@ package auth0 import ( "context" - "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/auth0/go-auth0/v3/management/option" ) -type FormAPI interface { - // Create a new form. - Create(ctx context.Context, r *management.Form, opts ...management.RequestOption) error - - // Read form details. - Read(ctx context.Context, id string, opts ...management.RequestOption) (r *management.Form, err error) - - // Update an existing action. - Update(ctx context.Context, id string, r *management.Form, opts ...management.RequestOption) error - - // Delete an action. - Delete(ctx context.Context, id string, opts ...management.RequestOption) error - - // List form. - List(ctx context.Context, opts ...management.RequestOption) (r *management.FormList, err error) +// FormSummaryPage aliases the paginated forms list response. The alias keeps the +// interface return type a single identifier so mockgen's source parser can handle +// it (it cannot parse the multi-type-parameter generic inline). +type FormSummaryPage = core.Page[*int, *managementv3.FormSummary, *managementv3.ListFormsOffsetPaginatedResponseContent] + +// FormAPIV3 is the V3 SDK interface for the /forms endpoint. +type FormAPIV3 interface { + // List forms. + // + // Required scope: `read:forms`. + List( + ctx context.Context, + request *managementv3.ListFormsRequestParameters, + opts ...option.RequestOption, + ) (*FormSummaryPage, error) + + // Get retrieves a form by its ID. + // + // Required scope: `read:forms`. + Get( + ctx context.Context, + id string, + request *managementv3.GetFormRequestParameters, + opts ...option.RequestOption, + ) (*managementv3.GetFormResponseContent, error) + + // Delete a form. + // + // Required scope: `delete:forms`. + Delete( + ctx context.Context, + id string, + opts ...option.RequestOption, + ) error } diff --git a/internal/auth0/mock/form_mock.go b/internal/auth0/mock/form_mock.go index 73161402a..aa69be870 100644 --- a/internal/auth0/mock/form_mock.go +++ b/internal/auth0/mock/form_mock.go @@ -8,54 +8,37 @@ import ( context "context" reflect "reflect" - management "github.com/auth0/go-auth0/management" + auth0 "github.com/auth0/auth0-cli/internal/auth0" + management "github.com/auth0/go-auth0/v3/management" + option "github.com/auth0/go-auth0/v3/management/option" gomock "github.com/golang/mock/gomock" ) -// MockFormAPI is a mock of FormAPI interface. -type MockFormAPI struct { +// MockFormAPIV3 is a mock of FormAPIV3 interface. +type MockFormAPIV3 struct { ctrl *gomock.Controller - recorder *MockFormAPIMockRecorder + recorder *MockFormAPIV3MockRecorder } -// MockFormAPIMockRecorder is the mock recorder for MockFormAPI. -type MockFormAPIMockRecorder struct { - mock *MockFormAPI +// MockFormAPIV3MockRecorder is the mock recorder for MockFormAPIV3. +type MockFormAPIV3MockRecorder struct { + mock *MockFormAPIV3 } -// NewMockFormAPI creates a new mock instance. -func NewMockFormAPI(ctrl *gomock.Controller) *MockFormAPI { - mock := &MockFormAPI{ctrl: ctrl} - mock.recorder = &MockFormAPIMockRecorder{mock} +// NewMockFormAPIV3 creates a new mock instance. +func NewMockFormAPIV3(ctrl *gomock.Controller) *MockFormAPIV3 { + mock := &MockFormAPIV3{ctrl: ctrl} + mock.recorder = &MockFormAPIV3MockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockFormAPI) EXPECT() *MockFormAPIMockRecorder { +func (m *MockFormAPIV3) EXPECT() *MockFormAPIV3MockRecorder { return m.recorder } -// Create mocks base method. -func (m *MockFormAPI) Create(ctx context.Context, r *management.Form, opts ...management.RequestOption) error { - m.ctrl.T.Helper() - varargs := []interface{}{ctx, r} - for _, a := range opts { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "Create", varargs...) - ret0, _ := ret[0].(error) - return ret0 -} - -// Create indicates an expected call of Create. -func (mr *MockFormAPIMockRecorder) Create(ctx, r interface{}, opts ...interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, r}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockFormAPI)(nil).Create), varargs...) -} - // Delete mocks base method. -func (m *MockFormAPI) Delete(ctx context.Context, id string, opts ...management.RequestOption) error { +func (m *MockFormAPIV3) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { m.ctrl.T.Helper() varargs := []interface{}{ctx, id} for _, a := range opts { @@ -67,67 +50,48 @@ func (m *MockFormAPI) Delete(ctx context.Context, id string, opts ...management. } // Delete indicates an expected call of Delete. -func (mr *MockFormAPIMockRecorder) Delete(ctx, id interface{}, opts ...interface{}) *gomock.Call { +func (mr *MockFormAPIV3MockRecorder) Delete(ctx, id interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{ctx, id}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockFormAPI)(nil).Delete), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockFormAPIV3)(nil).Delete), varargs...) } -// List mocks base method. -func (m *MockFormAPI) List(ctx context.Context, opts ...management.RequestOption) (*management.FormList, error) { +// Get mocks base method. +func (m *MockFormAPIV3) Get(ctx context.Context, id string, request *management.GetFormRequestParameters, opts ...option.RequestOption) (*management.GetFormResponseContent, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx} + varargs := []interface{}{ctx, id, request} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "List", varargs...) - ret0, _ := ret[0].(*management.FormList) + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(*management.GetFormResponseContent) ret1, _ := ret[1].(error) return ret0, ret1 } -// List indicates an expected call of List. -func (mr *MockFormAPIMockRecorder) List(ctx interface{}, opts ...interface{}) *gomock.Call { +// Get indicates an expected call of Get. +func (mr *MockFormAPIV3MockRecorder) Get(ctx, id, request interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockFormAPI)(nil).List), varargs...) + varargs := append([]interface{}{ctx, id, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockFormAPIV3)(nil).Get), varargs...) } -// Read mocks base method. -func (m *MockFormAPI) Read(ctx context.Context, id string, opts ...management.RequestOption) (*management.Form, error) { +// List mocks base method. +func (m *MockFormAPIV3) List(ctx context.Context, request *management.ListFormsRequestParameters, opts ...option.RequestOption) (*auth0.FormSummaryPage, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, id} + varargs := []interface{}{ctx, request} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "Read", varargs...) - ret0, _ := ret[0].(*management.Form) + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*auth0.FormSummaryPage) ret1, _ := ret[1].(error) return ret0, ret1 } -// Read indicates an expected call of Read. -func (mr *MockFormAPIMockRecorder) Read(ctx, id interface{}, opts ...interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, id}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockFormAPI)(nil).Read), varargs...) -} - -// Update mocks base method. -func (m *MockFormAPI) Update(ctx context.Context, id string, r *management.Form, opts ...management.RequestOption) error { - m.ctrl.T.Helper() - varargs := []interface{}{ctx, id, r} - for _, a := range opts { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "Update", varargs...) - ret0, _ := ret[0].(error) - return ret0 -} - -// Update indicates an expected call of Update. -func (mr *MockFormAPIMockRecorder) Update(ctx, id, r interface{}, opts ...interface{}) *gomock.Call { +// List indicates an expected call of List. +func (mr *MockFormAPIV3MockRecorder) List(ctx, request interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, id, r}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockFormAPI)(nil).Update), varargs...) + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockFormAPIV3)(nil).List), varargs...) } diff --git a/internal/cli/forms.go b/internal/cli/forms.go new file mode 100644 index 000000000..f2a5cedc4 --- /dev/null +++ b/internal/cli/forms.go @@ -0,0 +1,1013 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/pkg/browser" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/config" + "github.com/auth0/auth0-cli/internal/iostream" + "github.com/auth0/auth0-cli/internal/prompt" +) + +// formCreateSkeleton seeds the editor for interactive form creation. The name is +// prompted separately, so the seed only carries the empty graph containers, which +// are all valid on their own. +const formCreateSkeleton = `{ + "start": {}, + "nodes": [], + "ending": {} +} +` + +const formCreateExample = `{ + "name": "Customer Profile Form", + "languages": { + "primary": "en", + "default": "en" + }, + "start": { + "next_node": "step_profile", + "coordinates": { + "x": 0, + "y": 0 + } + }, + "nodes": [ + { + "id": "step_profile", + "type": "STEP", + "coordinates": { + "x": 300, + "y": 0 + }, + "alias": "Collect profile", + "config": { + "components": [ + { + "id": "full_name", + "category": "FIELD", + "type": "TEXT", + "label": "Full name", + "required": true, + "sensitive": false, + "config": { + "multiline": false + } + }, + { + "id": "continue_button", + "category": "BLOCK", + "type": "NEXT_BUTTON", + "config": { + "text": "Continue" + } + } + ], + "next_node": "$ending" + } + } + ], + "ending": { + "resume_flow": true, + "coordinates": { + "x": 600, + "y": 0 + } + } +} +` + +// formServerManagedFields cannot be sent in create or update request bodies. +var formServerManagedFields = []string{ + "id", + "created_at", + "updated_at", + "embedded_at", + "submitted_at", + "flow_count", + "links", +} + +var ( + formID = Argument{ + Name: "Id", + Help: "Id of the Form.", + } + + formName = Flag{ + Name: "Name", + LongForm: "name", + Help: "Name of the Form.", + } + + formFile = Flag{ + Name: "File", + LongForm: "file", + ShortForm: "f", + Help: "Path to a JSON file with the form body. Use '-' to read from stdin.", + } + + formLanguagePrimary = Flag{ + Name: "Language Primary", + LongForm: "language-primary", + Help: "Primary language of the Form (e.g. en).", + } + + formLanguageDefault = Flag{ + Name: "Language Default", + LongForm: "language-default", + Help: "Default language of the Form (e.g. en).", + } + + formOutput = Flag{ + Name: "Output", + LongForm: "output", + ShortForm: "o", + Help: "Path to write the exported form. Writes to stdout when omitted.", + } + + formImportID = Flag{ + Name: "Id", + LongForm: "id", + Help: "Id of an existing Form to replace. When omitted, a new form is created.", + } + + formEdit = Flag{ + Name: "Edit", + LongForm: "edit", + Help: "Open an editor to author the form graph after entering the name.", + } + + formExample = Flag{ + Name: "Example", + LongForm: "example", + Help: "Print an example form JSON body and exit.", + } +) + +func formsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "forms", + Short: "Manage Forms", + Long: "Forms are customizable screens you can insert into a flow to collect input " + + "from users during authentication and other journeys.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listFormsCmd(cli)) + cmd.AddCommand(showFormCmd(cli)) + cmd.AddCommand(createFormCmd(cli)) + cmd.AddCommand(updateFormCmd(cli)) + cmd.AddCommand(deleteFormCmd(cli)) + cmd.AddCommand(exportFormCmd(cli)) + cmd.AddCommand(importFormCmd(cli)) + cmd.AddCommand(openFormCmd(cli)) + + return cmd +} + +func listFormsCmd(cli *cli) *cobra.Command { + var inputs struct { + Number int + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + Short: "List your forms", + Long: "List your existing forms. To create one, run: `auth0 forms create`.", + Example: ` auth0 forms list + auth0 forms ls + auth0 forms ls --number 100 + auth0 forms ls --json + auth0 forms ls --csv`, + RunE: func(cmd *cobra.Command, args []string) error { + params := &managementv3.ListFormsRequestParameters{} + + var forms []*managementv3.FormSummary + if err := ansi.Waiting(func() (err error) { + forms, err = collectForms(cmd.Context(), cli, params, inputs.Number) + return err + }); err != nil { + return fmt.Errorf("failed to list forms: %w", err) + } + + return cli.renderer.FormsList(forms) + }, + } + + cmd.Flags().IntVarP(&inputs.Number, "number", "n", 100, "Number of forms to retrieve. Fetched across pages.") + 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.Flags().BoolVar(&cli.csv, "csv", false, "Output in csv format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + + return cmd +} + +func showFormCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(1), + Short: "Show a form", + Long: "Display information about a form.", + Example: ` auth0 forms show + auth0 forms show + auth0 forms show --json + auth0 forms show --json-compact`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + form, err := cli.formRawGet(cmd.Context(), inputs.ID) + if err != nil { + return fmt.Errorf("failed to read form with ID %q: %w", inputs.ID, err) + } + + return cli.renderer.FormShowRaw(form) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func createFormCmd(cli *cli) *cobra.Command { + var inputs struct { + Name string + File string + LanguagePrimary string + LanguageDefault string + Edit bool + Example bool + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.NoArgs, + Short: "Create a new form", + Long: "Create a new form.\n\n" + + "Interactive behavior: `auth0 forms create` asks only for the name and creates a minimal " + + "scaffold; it does not open an editor. You can then refine the form in the dashboard builder.\n\n" + + "Pass `--edit` to open an editor and author the form graph before it is created, or supply " + + "the whole body via `--file` (or piped stdin) with optional `--name` and `--language-*` " + + "overrides. Run `auth0 forms create --example > form.json` to generate an accepted file payload.", + Example: ` auth0 forms create + auth0 forms create --name "My Form" + auth0 forms create --name "My Form" --edit + auth0 forms create --example > form.json + auth0 forms create --file ./form.json + auth0 forms create --file ./form.json --name "My Form" --language-primary en + cat form.json | auth0 forms create -f -`, + RunE: func(cmd *cobra.Command, args []string) error { + if inputs.Example { + cli.renderer.FormExport(formCreateExample) + return nil + } + + body, err := readFormBody(inputs.File) + if err != nil { + return err + } + + rawBody := json.RawMessage(body) + if body == nil { + // No file or piped body: the name is a required scalar, so prompt for + // it explicitly (only when interactive and --name was not supplied). + if err := formName.Ask(cmd, &inputs.Name, nil); err != nil { + return err + } + if inputs.Name == "" { + return errors.New("a form name is required; supply --name, provide --file, or pipe JSON via stdin") + } + if inputs.Edit { + if !canPrompt(cmd) { + return errors.New("the --edit flag requires an interactive terminal") + } + if err := editFormJSON(cli, formCreateSkeleton, &rawBody); err != nil { + return err + } + } else { + rawBody = json.RawMessage(formCreateSkeleton) + } + } + + rawBody, err = applyRawFormOverrides( + rawBody, + inputs.Name, + inputs.LanguagePrimary, + inputs.LanguageDefault, + ) + if err != nil { + return fmt.Errorf("failed to parse form body: %w", err) + } + + name, err := rawFormStringField(rawBody, "name") + if err != nil { + return fmt.Errorf("failed to parse form body: %w", err) + } + if name == "" { + return errors.New("a form name is required; set it in the body or with --name") + } + + created, err := cli.formRawCreate(cmd.Context(), rawBody) + if err != nil { + return fmt.Errorf("failed to create form: %w", err) + } + if err := cli.renderer.FormCreateRaw(created); err != nil { + return err + } + + id, err := rawFormStringField(created, "id") + if err != nil { + return fmt.Errorf("failed to parse created form: %w", err) + } + formNextStepsHint(cli, id) + return nil + }, + } + + formName.RegisterString(cmd, &inputs.Name, "") + formFile.RegisterString(cmd, &inputs.File, "") + formLanguagePrimary.RegisterString(cmd, &inputs.LanguagePrimary, "") + formLanguageDefault.RegisterString(cmd, &inputs.LanguageDefault, "") + formEdit.RegisterBool(cmd, &inputs.Edit, false) + formExample.RegisterBool(cmd, &inputs.Example, false) + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func updateFormCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Name string + File string + LanguagePrimary string + LanguageDefault string + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(1), + Short: "Update a form", + Long: "Update a form.\n\n" + + "Passing `--file` (or piped stdin) replaces every top-level field present in the file. " + + "Passing only scalar flags such as `--name` performs a merge that preserves the form's " + + "graph fields (nodes, style, translations). Server-managed fields such as `id`, " + + "`created_at`, and `updated_at` are removed before the update request is sent.", + Example: ` auth0 forms update --name "New Name" + auth0 forms update --file ./form.json + cat form.json | auth0 forms update -f -`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.ID = args[0] + } else { + if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { + return err + } + } + + body, err := readFormBody(inputs.File) + if err != nil { + return err + } + + var rawBody json.RawMessage + + switch { + case body != nil: + // File / stdin: whole-file overwrite of present top-level fields. + rawBody, err = applyRawFormOverrides( + body, + inputs.Name, + inputs.LanguagePrimary, + inputs.LanguageDefault, + ) + if err != nil { + return fmt.Errorf("failed to parse form body: %w", err) + } + case inputs.Name != "" || inputs.LanguagePrimary != "" || inputs.LanguageDefault != "": + primary := inputs.LanguagePrimary + def := inputs.LanguageDefault + if primary != "" || def != "" { + // The API replaces the languages object, so retain the value that was + // not explicitly overridden. This scalar read is safe through v3. + var current *managementv3.GetFormResponseContent + if err := ansi.Waiting(func() (err error) { + current, err = cli.apiv3.Form.Get( + cmd.Context(), + inputs.ID, + &managementv3.GetFormRequestParameters{}, + ) + return err + }); err != nil { + return fmt.Errorf("failed to read form with ID %q: %w", inputs.ID, err) + } + languages := current.GetLanguages() + if primary == "" { + primary = languages.GetPrimary() + } + if def == "" { + def = languages.GetDefault() + } + } + + rawBody, err = applyRawFormOverrides(json.RawMessage(`{}`), inputs.Name, primary, def) + if err != nil { + return fmt.Errorf("failed to build form update: %w", err) + } + case canPrompt(cmd): + // Editor fallback: pre-load the exact wire body and full-replace. + current, err := cli.formRawGet(cmd.Context(), inputs.ID) + if err != nil { + return fmt.Errorf("failed to read form with ID %q: %w", inputs.ID, err) + } + + var seed bytes.Buffer + if err := json.Indent(&seed, current, "", " "); err != nil { + return fmt.Errorf("failed to parse form with ID %q: %w", inputs.ID, err) + } + + if err := editFormJSON(cli, seed.String(), &rawBody); err != nil { + return err + } + default: + return errors.New("nothing to update; supply --file, pipe JSON via stdin, or a scalar flag such as --name") + } + + updated, err := cli.formRawUpdate(cmd.Context(), inputs.ID, rawBody) + if err != nil { + return fmt.Errorf("failed to update form with ID %q: %w", inputs.ID, err) + } + if err := cli.renderer.FormUpdateRaw(updated); err != nil { + return err + } + formNextStepsHint(cli, inputs.ID) + return nil + }, + } + + formName.RegisterStringU(cmd, &inputs.Name, "") + formFile.RegisterStringU(cmd, &inputs.File, "") + formLanguagePrimary.RegisterStringU(cmd, &inputs.LanguagePrimary, "") + formLanguageDefault.RegisterStringU(cmd, &inputs.LanguageDefault, "") + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func deleteFormCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Args: cobra.ArbitraryArgs, + Short: "Delete a form", + Long: "Delete a form.\n\n" + + "To delete interactively, use `auth0 forms delete` with no arguments.\n\n" + + "To delete non-interactively, supply the form id and the `--force` flag to skip confirmation.", + Example: ` auth0 forms delete + auth0 forms rm + auth0 forms delete + auth0 forms delete --force + auth0 forms delete `, + RunE: func(cmd *cobra.Command, args []string) error { + var ids []string + if len(args) == 0 { + if err := formID.PickMany(cmd, &ids, cli.formPickerOptions); err != nil { + return err + } + } else { + ids = args + } + + if !cli.force && cli.agentMode { + return errDestructiveNoConfirm + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Are you sure you want to proceed?"); !confirmed { + return nil + } + } + + return ansi.ProgressBar("Deleting form(s)", ids, func(_ int, id string) error { + if id == "" { + return nil + } + if err := cli.apiv3.Form.Delete(cmd.Context(), id); err != nil { + return fmt.Errorf("failed to delete form with ID %q: %w", id, err) + } + return nil + }) + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +func exportFormCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Output string + Compact bool + } + + cmd := &cobra.Command{ + Use: "export", + Args: cobra.MaximumNArgs(1), + Short: "Export a form", + Long: "Export a form as JSON. Writes to stdout by default (pipe-friendly) or to a file " + + "with `--output`. The output uses the same envelope as the Auth0 Dashboard " + + "(`version`, `form`, `flows`, `connections`), bundling the flows and vault connections " + + "the form references with portable `#FLOW-N#`/`#CONN-N#` placeholders, so it can be " + + "imported by the CLI or opened in the Dashboard.", + Example: ` auth0 forms export + auth0 forms export --output ./form.json + auth0 forms export --json-compact + auth0 forms export | auth0 forms import -f -`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + form, err := cli.formRawGet(cmd.Context(), inputs.ID) + if err != nil { + return fmt.Errorf("failed to read form with ID %q: %w", inputs.ID, err) + } + + env, err := cli.buildFormEnvelope(cmd.Context(), form) + if err != nil { + return err + } + + var data []byte + if inputs.Compact { + data, err = json.Marshal(env) + } else { + data, err = json.MarshalIndent(env, "", " ") + } + if err != nil { + return fmt.Errorf("failed to marshal form: %w", err) + } + + if inputs.Output != "" { + if err := os.WriteFile(inputs.Output, data, 0600); err != nil { + return fmt.Errorf("failed to write form to %q: %w", inputs.Output, err) + } + cli.renderer.Infof("Exported form %s to %s", inputs.ID, inputs.Output) + return nil + } + + cli.renderer.FormExport(string(data)) + return nil + }, + } + + formOutput.RegisterString(cmd, &inputs.Output, "") + cmd.Flags().BoolVar(&inputs.Compact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func importFormCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + File string + Connections map[string]string + } + + cmd := &cobra.Command{ + Use: "import", + Args: cobra.NoArgs, + Short: "Import a form", + Long: "Import a form from a JSON file (or piped stdin). Without `--id` a new form is " + + "created; with `--id` the existing form is replaced.\n\n" + + "Both a flat form graph and the Dashboard envelope (`version`, `form`, `flows`, " + + "`connections`) are accepted. For an envelope, the bundled flows are created and each " + + "`#CONN-N#` connection placeholder is mapped to an existing vault connection, either " + + "interactively or with `--connection`.", + Example: ` auth0 forms import --file ./form.json + auth0 forms import --file ./form.json --id + auth0 forms import --file ./form.json --connection '#CONN-1#=ac_123' + cat form.json | auth0 forms import -f -`, + RunE: func(cmd *cobra.Command, args []string) error { + body, err := readFormBody(inputs.File) + if err != nil { + return err + } + if body == nil { + return errors.New("no form body provided; supply --file or pipe JSON via stdin") + } + + if isFormEnvelope(body) { + resolved, err := cli.resolveFormEnvelope(cmd, body, inputs.Connections) + if err != nil { + return err + } + body = resolved + } + + // Parse just enough to validate the JSON and read the name. The body is + // created/updated as raw JSON so STEP/ROUTER node config is preserved + // (the typed request models drop it via the lossy FormNode union). + var meta struct { + Name string `json:"name"` + } + if err := json.Unmarshal(body, &meta); err != nil { + return fmt.Errorf("failed to parse form body: %w", err) + } + + if inputs.ID == "" { + if meta.Name == "" { + return errors.New("a form name is required in the imported body") + } + + raw, err := cli.formRawCreate(cmd.Context(), body) + if err != nil { + return fmt.Errorf("failed to create form: %w", err) + } + + return cli.renderer.FormCreateRaw(raw) + } + + raw, err := cli.formRawUpdate(cmd.Context(), inputs.ID, body) + if err != nil { + return fmt.Errorf("failed to update form with ID %q: %w", inputs.ID, err) + } + + return cli.renderer.FormUpdateRaw(raw) + }, + } + + formFile.RegisterString(cmd, &inputs.File, "") + formImportID.RegisterString(cmd, &inputs.ID, "") + cmd.Flags().StringToStringVar(&inputs.Connections, "connection", nil, + "Map an exported connection placeholder to an existing vault connection ID, "+ + "e.g. --connection '#CONN-1#=ac_123'. Repeatable.") + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func openFormCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "open", + Args: cobra.MaximumNArgs(1), + Short: "Open a form in the Auth0 Dashboard", + Long: "Open a form's page in the Auth0 Dashboard form builder.", + Example: ` auth0 forms open + auth0 forms open `, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + openFormEditURL(cli, inputs.ID) + + return nil + }, + } + + return cmd +} + +// formsBuilderURL is the host for the Auth0 Forms visual builder. Forms live on a +// dedicated host rather than under the main management dashboard. +const formsBuilderURL = "https://forms.auth0.com" + +// openFormEditURL opens the form's builder page in a browser, or prints the URL +// when interactivity is disabled. +func openFormEditURL(cli *cli, id string) { + url := formatFormEditURL(cli.Config.DefaultTenant, &cli.Config, id) + if url == "" { + cli.renderer.Warnf("Failed to format the correct URL, please ensure you have run 'auth0 login' and try again.") + return + } + + if cli.noInput { + cli.renderer.Infof("Open the following URL in a browser: %s", url) + return + } + + if err := browser.OpenURL(url); err != nil { + cli.renderer.Warnf("Couldn't open the URL, please do it manually: %s", url) + } +} + +// formatFormEditURL builds the Forms builder URL, deriving the region and tenant +// name the same way formatManageTenantURL does for the management dashboard. +func formatFormEditURL(tenant string, cfg *config.Config, id string) string { + if len(tenant) == 0 || len(id) == 0 { + return "" + } + + s := strings.Split(tenant, ".") + if len(s) < 3 { + return "" + } + + region := "us" // A PUS1 tenant looks like dev-tti06f6y.auth0.com (3 parts). + if len(s) > 3 { + region = s[len(s)-3] + } + + tenantName := cfg.Tenants[tenant].Name + if len(tenantName) == 0 { + return "" + } + + return fmt.Sprintf("%s/tenants/%s/%s/forms/%s/edit", formsBuilderURL, region, tenantName, id) +} + +// editFormJSON opens an editor seeded with `seed` and unmarshals the result into +// `target`. When the buffer is not valid JSON it re-opens the editor with the +// user's edits intact rather than discarding them, so a typo never costs work. +func editFormJSON(cli *cli, seed string, target interface{}) error { + content := seed + for { + var edited string + if err := openCreateEditor(&edited, content, "form.*.json", nil, nil); err != nil { + return err + } + + if err := json.Unmarshal([]byte(edited), target); err != nil { + cli.renderer.Warnf("The form body is not valid JSON: %s", err) + if !prompt.Confirm("Re-open the editor to fix it?") { + return errors.New("aborted; the form was not saved") + } + content = edited + continue + } + + return nil + } +} + +// formNextStepsHint prints follow-up commands after a form is created or updated. +// It stays quiet in JSON output modes so scripted consumers get a clean stream. +func formNextStepsHint(cli *cli, id string) { + if id == "" || cli.json || cli.jsonCompact { + return + } + cli.renderer.Infof("Inspect it with: %s", ansi.Faint("auth0 forms show "+id)) + cli.renderer.Infof("Edit it in the dashboard with: %s", ansi.Faint("auth0 forms open "+id)) +} + +// readFormBody resolves a JSON body from an explicit --file, "-"/piped stdin, and +// returns nil when no such source is available so the caller can decide whether to +// fall back to an editor or error. +func readFormBody(filePath string) ([]byte, error) { + if filePath == "-" { + data, err := io.ReadAll(iostream.Input) + if err != nil { + return nil, fmt.Errorf("failed to read form body from stdin: %w", err) + } + return data, nil + } + if filePath != "" { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read form file %q: %w", filePath, err) + } + return data, nil + } + if piped := iostream.PipedInput(); len(piped) > 0 { + return piped, nil + } + return nil, nil +} + +// applyRawFormOverrides applies scalar flag overrides without deserializing the +// form graph into the v3 SDK's lossy union types. +func applyRawFormOverrides(body json.RawMessage, name, primary, def string) (json.RawMessage, error) { + var form map[string]json.RawMessage + if err := json.Unmarshal(body, &form); err != nil { + return nil, err + } + if form == nil { + return nil, errors.New("form body must be a JSON object") + } + + if name != "" { + encoded, err := json.Marshal(name) + if err != nil { + return nil, err + } + form["name"] = encoded + } + + if primary != "" || def != "" { + languages := make(map[string]json.RawMessage) + if existing := form["languages"]; len(existing) > 0 && string(existing) != "null" { + if err := json.Unmarshal(existing, &languages); err != nil { + return nil, fmt.Errorf("parse languages: %w", err) + } + } + if primary != "" { + encoded, err := json.Marshal(primary) + if err != nil { + return nil, err + } + languages["primary"] = encoded + } + if def != "" { + encoded, err := json.Marshal(def) + if err != nil { + return nil, err + } + languages["default"] = encoded + } + encoded, err := json.Marshal(languages) + if err != nil { + return nil, err + } + form["languages"] = encoded + } + + return json.Marshal(form) +} + +func rawFormStringField(body json.RawMessage, field string) (string, error) { + var form map[string]json.RawMessage + if err := json.Unmarshal(body, &form); err != nil { + return "", err + } + if form == nil { + return "", errors.New("form body must be a JSON object") + } + + raw, ok := form[field] + if !ok || string(raw) == "null" { + return "", nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("%s must be a string: %w", field, err) + } + return value, nil +} + +// formRawGet fetches a form through the v1 client's HTTP layer without using +// the v3 SDK's lossy form-node unions. +func (c *cli) formRawGet(ctx context.Context, id string) (json.RawMessage, error) { + return c.formRawRequest(ctx, http.MethodGet, c.api.HTTPClient.URI("forms", id), nil) +} + +// formRawCreate creates a form from raw JSON, preserving node config that the +// typed CreateFormRequestContent would drop. It returns the created form JSON. +func (c *cli) formRawCreate(ctx context.Context, body json.RawMessage) (json.RawMessage, error) { + return c.formRawRequest(ctx, http.MethodPost, c.api.HTTPClient.URI("forms"), body) +} + +// formRawUpdate replaces a form from raw JSON, preserving node config that the +// typed UpdateFormRequestContent would drop. It returns the updated form JSON. +func (c *cli) formRawUpdate(ctx context.Context, id string, body json.RawMessage) (json.RawMessage, error) { + var form map[string]json.RawMessage + if err := json.Unmarshal(body, &form); err != nil { + return nil, err + } + for _, field := range formServerManagedFields { + delete(form, field) + } + cleanBody, err := json.Marshal(form) + if err != nil { + return nil, err + } + + return c.formRawRequest(ctx, http.MethodPatch, c.api.HTTPClient.URI("forms", id), cleanBody) +} + +// formRawRequest sends a raw JSON request to the Management API and returns the +// response body, surfacing API errors the same way the `api` command does. +func (c *cli) formRawRequest( + ctx context.Context, + method string, + uri string, + body json.RawMessage, +) (json.RawMessage, error) { + var payload interface{} + if len(body) > 0 { + payload = body + } + + request, err := c.api.HTTPClient.NewRequest(ctx, method, uri, payload) + if err != nil { + return nil, err + } + + var out json.RawMessage + if err := ansi.Waiting(func() error { + response, err := c.api.HTTPClient.Do(request) + if err != nil { + return err + } + defer func() { + _ = response.Body.Close() + }() + + data, err := io.ReadAll(response.Body) + if err != nil { + return err + } + if response.StatusCode >= http.StatusBadRequest { + return newAPIResponseError(response.StatusCode, response.Header, data) + } + out = data + return nil + }); err != nil { + return nil, err + } + + return out, nil +} + +// collectForms pages through the forms list, collecting up to `limit` results +// (all results when limit <= 0). +func collectForms(ctx context.Context, cli *cli, params *managementv3.ListFormsRequestParameters, limit int) ([]*managementv3.FormSummary, error) { + page, err := cli.apiv3.Form.List(ctx, params) + if err != nil { + return nil, err + } + + var out []*managementv3.FormSummary + for page != nil { + for _, f := range page.Results { + out = append(out, f) + if limit > 0 && len(out) >= limit { + return out, nil + } + } + + page, err = page.GetNextPage(ctx) + if errors.Is(err, core.ErrNoPages) { + break + } + if err != nil { + return out, err + } + } + + return out, nil +} + +func (c *cli) formPickerOptions(ctx context.Context) (pickerOptions, error) { + forms, err := collectForms(ctx, c, &managementv3.ListFormsRequestParameters{}, 0) + if err != nil { + return nil, err + } + + var opts pickerOptions + for _, f := range forms { + label := fmt.Sprintf("%s %s", f.GetName(), ansi.Faint("("+f.GetID()+")")) + opts = append(opts, pickerOption{value: f.GetID(), label: label}) + } + + if len(opts) == 0 { + return nil, errors.New("there are currently no forms to choose from. Create one by running: `auth0 forms create`") + } + + return opts, nil +} diff --git a/internal/cli/forms_envelope.go b/internal/cli/forms_envelope.go new file mode 100644 index 000000000..3d336ee51 --- /dev/null +++ b/internal/cli/forms_envelope.go @@ -0,0 +1,398 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + + "github.com/auth0/go-auth0/management" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/prompt" +) + +// formEnvelopeVersion is the schema version the Auth0 Dashboard form builder +// stamps on exported forms. We emit the same value so exports interop. +const formEnvelopeVersion = "4.0.0" + +// formEnvelope mirrors the export shape produced by the Auth0 Dashboard form +// builder: the form graph plus the flows and vault connections it references, +// with real resource IDs replaced by portable #FLOW-N#/#CONN-N# placeholders. +type formEnvelope struct { + Version string `json:"version"` + Form json.RawMessage `json:"form"` + Flows map[string]json.RawMessage `json:"flows,omitempty"` + Connections map[string]envelopeConn `json:"connections,omitempty"` +} + +// envelopeConn is the connection descriptor emitted alongside a form. Vault +// connection secrets are never exported, so on import the placeholder is mapped +// to an existing connection rather than recreated. +type envelopeConn struct { + ID string `json:"id"` + AppID string `json:"app_id,omitempty"` + Name string `json:"name,omitempty"` +} + +// isFormEnvelope reports whether the given body is a Dashboard-style envelope +// rather than a flat form graph. An envelope always carries a top-level "form" +// object, whereas a flat body carries the form fields such as name and nodes +// at the top level. +func isFormEnvelope(body []byte) bool { + var probe struct { + Form json.RawMessage `json:"form"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return false + } + return len(probe.Form) > 0 +} + +// substituteIDs replaces every JSON string value that exactly matches a key in +// `replacements` with its mapped value, walking the whole tree. IDs are opaque +// unique tokens, so exact full-string matching is safe and order-independent. +func substituteIDs(raw json.RawMessage, replacements map[string]string) (json.RawMessage, error) { + if len(replacements) == 0 { + return raw, nil + } + + var tree interface{} + if err := json.Unmarshal(raw, &tree); err != nil { + return nil, err + } + + return json.Marshal(walkReplace(tree, replacements)) +} + +func walkReplace(node interface{}, replacements map[string]string) interface{} { + switch v := node.(type) { + case map[string]interface{}: + for key, val := range v { + v[key] = walkReplace(val, replacements) + } + return v + case []interface{}: + for i, val := range v { + v[i] = walkReplace(val, replacements) + } + return v + case string: + if replaced, ok := replacements[v]; ok { + return replaced + } + return v + default: + return node + } +} + +// collectConnectionIDs returns every value stored under a "connection_id" key +// anywhere in the given flow JSON, de-duplicated and sorted for stable ordering. +func collectConnectionIDs(raw json.RawMessage) ([]string, error) { + var tree interface{} + if err := json.Unmarshal(raw, &tree); err != nil { + return nil, err + } + + seen := map[string]bool{} + var walk func(node interface{}) + walk = func(node interface{}) { + switch v := node.(type) { + case map[string]interface{}: + for key, val := range v { + if key == "connection_id" { + if s, ok := val.(string); ok && s != "" { + seen[s] = true + } + } + walk(val) + } + case []interface{}: + for _, val := range v { + walk(val) + } + } + } + walk(tree) + + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + sort.Strings(out) + + return out, nil +} + +// collectFlowIDs returns the flow IDs referenced by the form's FLOW nodes, in +// node order and de-duplicated. Working from the raw form map avoids the v3 +// SDK's lossy FormNode union. +func collectFlowIDs(formMap map[string]interface{}) []string { + nodes, ok := formMap["nodes"].([]interface{}) + if !ok { + return nil + } + + var ids []string + seen := map[string]bool{} + for _, n := range nodes { + node, ok := n.(map[string]interface{}) + if !ok || node["type"] != "FLOW" { + continue + } + config, ok := node["config"].(map[string]interface{}) + if !ok { + continue + } + id, ok := config["flow_id"].(string) + if !ok || id == "" || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + + return ids +} + +// vaultConnectionPickerOptions lists the tenant's flow vault connections as +// selectable options for mapping envelope connection placeholders on import. +func (c *cli) vaultConnectionPickerOptions(ctx context.Context) (pickerOptions, error) { + var list *management.FlowVaultConnectionList + if err := ansi.Waiting(func() (err error) { + list, err = c.api.FlowVaultConnection.GetConnectionList(ctx) + return err + }); err != nil { + return nil, err + } + + var opts pickerOptions + for _, conn := range list.Connections { + label := fmt.Sprintf("%s %s", conn.GetName(), ansi.Faint("("+conn.GetID()+")")) + opts = append(opts, pickerOption{value: conn.GetID(), label: label}) + } + + if len(opts) == 0 { + return nil, errors.New("there are currently no vault connections to map to. Create one in the Auth0 Dashboard first") + } + + return opts, nil +} + +// resolveConnectionPlaceholders maps each #CONN-N# placeholder in the envelope +// to a real vault connection ID. It uses the provided mapping first and falls +// back to an interactive picker; without a terminal an unmapped placeholder is +// an error that tells the user to pass --connection. +func (c *cli) resolveConnectionPlaceholders( + cmd *cobra.Command, + env *formEnvelope, + mapping map[string]string, +) (map[string]string, error) { + placeholders := make([]string, 0, len(env.Connections)) + for ph := range env.Connections { + placeholders = append(placeholders, ph) + } + sort.Strings(placeholders) + + var options pickerOptions + resolved := make(map[string]string, len(placeholders)) + for _, ph := range placeholders { + if id := mapping[ph]; id != "" { + resolved[ph] = id + continue + } + + if !canPrompt(cmd) { + return nil, fmt.Errorf( + "cannot resolve connection %s: pass --connection '%s=' or run without --no-input", + ph, ph, + ) + } + + if options == nil { + opts, err := c.vaultConnectionPickerOptions(cmd.Context()) + if err != nil { + return nil, err + } + options = opts + } + + var label string + message := fmt.Sprintf("Select the vault connection for %s (%s):", ph, env.Connections[ph].Name) + if err := prompt.AskOne( + prompt.SelectInput("connection", message, "", options.labels(), options.defaultLabel(), true), + &label, + ); err != nil { + return nil, err + } + resolved[ph] = options.getValue(label) + } + + return resolved, nil +} + +// resolveFormEnvelope turns a Dashboard-style envelope into a flat form body +// ready for create/update: it maps connection placeholders to existing vault +// connections, creates the bundled flows (substituting the resolved connection +// IDs into them), and swaps the form's #FLOW-N# references for the new flow IDs. +func (c *cli) resolveFormEnvelope( + cmd *cobra.Command, + body []byte, + mapping map[string]string, +) (json.RawMessage, error) { + var env formEnvelope + if err := json.Unmarshal(body, &env); err != nil { + return nil, fmt.Errorf("failed to parse form body: %w", err) + } + if len(env.Form) == 0 { + return nil, errors.New("the imported envelope has no \"form\" object") + } + + connReplacements, err := c.resolveConnectionPlaceholders(cmd, &env, mapping) + if err != nil { + return nil, err + } + + // Create flows in placeholder order for a deterministic sequence. + placeholders := make([]string, 0, len(env.Flows)) + for ph := range env.Flows { + placeholders = append(placeholders, ph) + } + sort.Strings(placeholders) + + flowReplacements := make(map[string]string, len(placeholders)) + for _, ph := range placeholders { + flowRaw, err := substituteIDs(env.Flows[ph], connReplacements) + if err != nil { + return nil, err + } + + flow := &management.Flow{} + if err := json.Unmarshal(flowRaw, flow); err != nil { + return nil, fmt.Errorf("failed to parse flow %s: %w", ph, err) + } + if err := ansi.Waiting(func() error { + return c.api.Flow.Create(cmd.Context(), flow) + }); err != nil { + return nil, fmt.Errorf("failed to create flow %s: %w", ph, err) + } + flowReplacements[ph] = flow.GetID() + } + + return substituteIDs(env.Form, flowReplacements) +} + +// buildFormEnvelope turns a fetched form (raw wire JSON) into a Dashboard-style +// envelope: it reads every flow the form's FLOW nodes reference and every vault +// connection those flows reference, then swaps the real IDs for #FLOW-N#/#CONN-N# +// placeholders so the export is portable across tenants. The form is handled as +// raw JSON so STEP/ROUTER node config survives the round-trip. +func (c *cli) buildFormEnvelope( + ctx context.Context, + formRaw json.RawMessage, +) (*formEnvelope, error) { + var formMap map[string]interface{} + if err := json.Unmarshal(formRaw, &formMap); err != nil { + return nil, fmt.Errorf("failed to parse form: %w", err) + } + + // Referenced flow IDs, in node order, de-duplicated. + flowIDs := collectFlowIDs(formMap) + + // Read each flow and gather the connections its actions reference. + flowsByID := make(map[string]json.RawMessage, len(flowIDs)) + connSet := map[string]bool{} + for _, id := range flowIDs { + var flow *management.Flow + if err := ansi.Waiting(func() (err error) { + flow, err = c.api.Flow.Read(ctx, id) + return err + }); err != nil { + return nil, fmt.Errorf("failed to read flow with ID %q: %w", id, err) + } + + raw, err := json.Marshal(flow) + if err != nil { + return nil, fmt.Errorf("failed to marshal flow with ID %q: %w", id, err) + } + flowsByID[id] = raw + + connIDs, err := collectConnectionIDs(raw) + if err != nil { + return nil, err + } + for _, cid := range connIDs { + connSet[cid] = true + } + } + + connIDs := make([]string, 0, len(connSet)) + for id := range connSet { + connIDs = append(connIDs, id) + } + sort.Strings(connIDs) + + // Assign placeholders and build the real-ID -> placeholder replacement map. + replacements := make(map[string]string, len(flowIDs)+len(connIDs)) + flowPlaceholder := make(map[string]string, len(flowIDs)) + for i, id := range flowIDs { + ph := fmt.Sprintf("#FLOW-%d#", i+1) + replacements[id] = ph + flowPlaceholder[id] = ph + } + connPlaceholder := make(map[string]string, len(connIDs)) + for i, id := range connIDs { + ph := fmt.Sprintf("#CONN-%d#", i+1) + replacements[id] = ph + connPlaceholder[id] = ph + } + + // Drop volatile fields and swap in placeholders. + for _, field := range formServerManagedFields { + delete(formMap, field) + } + formBody, err := json.Marshal(formMap) + if err != nil { + return nil, err + } + formBody, err = substituteIDs(formBody, replacements) + if err != nil { + return nil, err + } + + env := &formEnvelope{Version: formEnvelopeVersion, Form: formBody} + + if len(flowsByID) > 0 { + env.Flows = make(map[string]json.RawMessage, len(flowsByID)) + for id, raw := range flowsByID { + substituted, err := substituteIDs(raw, replacements) + if err != nil { + return nil, err + } + env.Flows[flowPlaceholder[id]] = substituted + } + } + + if len(connIDs) > 0 { + env.Connections = make(map[string]envelopeConn, len(connIDs)) + for _, id := range connIDs { + var conn *management.FlowVaultConnection + if err := ansi.Waiting(func() (err error) { + conn, err = c.api.FlowVaultConnection.GetConnection(ctx, id) + return err + }); err != nil { + return nil, fmt.Errorf("failed to read vault connection with ID %q: %w", id, err) + } + env.Connections[connPlaceholder[id]] = envelopeConn{ + ID: conn.GetID(), + AppID: conn.GetAppID(), + Name: conn.GetName(), + } + } + } + + return env, nil +} diff --git a/internal/cli/forms_envelope_test.go b/internal/cli/forms_envelope_test.go new file mode 100644 index 000000000..33f8cfaea --- /dev/null +++ b/internal/cli/forms_envelope_test.go @@ -0,0 +1,206 @@ +package cli + +import ( + "context" + "encoding/json" + "testing" + + "github.com/auth0/go-auth0/management" + "github.com/golang/mock/gomock" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/auth0/mock" +) + +func TestIsFormEnvelope(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + {name: "envelope with form object", body: `{"version":"4.0.0","form":{"name":"x"}}`, want: true}, + {name: "flat form graph", body: `{"name":"x","nodes":[]}`, want: false}, + {name: "form as non-object is still detected", body: `{"form":{}}`, want: true}, + {name: "invalid json", body: `not-json`, want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, isFormEnvelope([]byte(test.body))) + }) + } +} + +func TestSubstituteIDs(t *testing.T) { + t.Run("replaces exact string matches anywhere in the tree", func(t *testing.T) { + in := json.RawMessage(`{"flow_id":"fl_1","nested":{"connection_id":"ac_1","keep":"fl_1x"},"list":["fl_1","other"]}`) + out, err := substituteIDs(in, map[string]string{"fl_1": "#FLOW-1#", "ac_1": "#CONN-1#"}) + require.NoError(t, err) + + var got map[string]interface{} + require.NoError(t, json.Unmarshal(out, &got)) + assert.Equal(t, "#FLOW-1#", got["flow_id"]) + nested := got["nested"].(map[string]interface{}) + assert.Equal(t, "#CONN-1#", nested["connection_id"]) + assert.Equal(t, "fl_1x", nested["keep"]) // Substring must not be replaced. + list := got["list"].([]interface{}) + assert.Equal(t, "#FLOW-1#", list[0]) + assert.Equal(t, "other", list[1]) + }) + + t.Run("returns the input unchanged when there are no replacements", func(t *testing.T) { + in := json.RawMessage(`{"a":"b"}`) + out, err := substituteIDs(in, nil) + require.NoError(t, err) + assert.Equal(t, in, out) + }) +} + +func TestCollectConnectionIDs(t *testing.T) { + raw := json.RawMessage(`{ + "name": "flow", + "actions": [ + {"params": {"connection_id": "ac_2"}}, + {"params": {"connection_id": "ac_1"}}, + {"params": {"connection_id": "ac_1"}}, + {"params": {"other": "x"}} + ] + }`) + + got, err := collectConnectionIDs(raw) + require.NoError(t, err) + assert.Equal(t, []string{"ac_1", "ac_2"}, got) // De-duplicated and sorted. +} + +func TestBuildFormEnvelope(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + form := json.RawMessage(`{ + "id": "ap_form1", + "name": "Test Form", + "nodes": [ + {"id": "step_1", "type": "STEP", "config": {"next_node": "flow_1", "components": [{"id": "job_title", "type": "TEXT", "category": "FIELD"}]}}, + {"id": "flow_1", "type": "FLOW", "config": {"flow_id": "fl_real", "next_node": "$ending"}} + ], + "start": {"next_node": "step_1"}, + "ending": {} + }`) + + flowMock := mock.NewMockFlowAPI(ctrl) + flowMock.EXPECT().Read(gomock.Any(), "fl_real").Return(&management.Flow{ + Name: auth0.String("My Flow"), + Actions: []interface{}{ + map[string]interface{}{ + "type": "AUTH0", + "params": map[string]interface{}{"connection_id": "ac_real"}, + }, + }, + }, nil) + + connMock := mock.NewMockFlowVaultConnectionAPI(ctrl) + connMock.EXPECT().GetConnection(gomock.Any(), "ac_real").Return(&management.FlowVaultConnection{ + ID: auth0.String("ac_real"), + AppID: auth0.String("AUTH0"), + Name: auth0.String("My Connection"), + }, nil) + + cli := &cli{api: &auth0.API{Flow: flowMock, FlowVaultConnection: connMock}} + + env, err := cli.buildFormEnvelope(context.Background(), form) + require.NoError(t, err) + + assert.Equal(t, formEnvelopeVersion, env.Version) + + // Connection descriptor keeps the real values under the placeholder key. + require.Contains(t, env.Connections, "#CONN-1#") + assert.Equal(t, "ac_real", env.Connections["#CONN-1#"].ID) + assert.Equal(t, "AUTH0", env.Connections["#CONN-1#"].AppID) + assert.Equal(t, "My Connection", env.Connections["#CONN-1#"].Name) + + // The flow node's flow_id is replaced with the placeholder, and volatile + // fields are dropped from the form block. + var formMap map[string]interface{} + require.NoError(t, json.Unmarshal(env.Form, &formMap)) + assert.NotContains(t, formMap, "id") + nodes := formMap["nodes"].([]interface{}) + flowNode := nodes[1].(map[string]interface{}) + flowConfig := flowNode["config"].(map[string]interface{}) + assert.Equal(t, "#FLOW-1#", flowConfig["flow_id"]) + + // STEP node config (components) is preserved, not dropped by the SDK's union. + stepNode := nodes[0].(map[string]interface{}) + stepConfig := stepNode["config"].(map[string]interface{}) + components := stepConfig["components"].([]interface{}) + require.Len(t, components, 1) + assert.Equal(t, "job_title", components[0].(map[string]interface{})["id"]) + + // The flow's connection_id is replaced with the placeholder. + require.Contains(t, env.Flows, "#FLOW-1#") + var flowMap map[string]interface{} + require.NoError(t, json.Unmarshal(env.Flows["#FLOW-1#"], &flowMap)) + action := flowMap["actions"].([]interface{})[0].(map[string]interface{}) + params := action["params"].(map[string]interface{}) + assert.Equal(t, "#CONN-1#", params["connection_id"]) +} + +func TestResolveFormEnvelope(t *testing.T) { + envelope := []byte(`{ + "version": "4.0.0", + "form": { + "name": "Test Form", + "nodes": [ + {"id": "flow_1", "type": "FLOW", "config": {"flow_id": "#FLOW-1#"}} + ] + }, + "flows": { + "#FLOW-1#": { + "name": "My Flow", + "actions": [{"params": {"connection_id": "#CONN-1#"}}] + } + }, + "connections": { + "#CONN-1#": {"id": "ac_placeholder", "app_id": "AUTH0", "name": "REPLACE_WITH_M2M_CONNECTION"} + } + }`) + + t.Run("maps connections, creates flows, and substitutes flow IDs", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + flowMock := mock.NewMockFlowAPI(ctrl) + flowMock.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, r *management.Flow, _ ...management.RequestOption) error { + // The connection placeholder is resolved before the flow is created. + action := r.Actions[0].(map[string]interface{}) + params := action["params"].(map[string]interface{}) + assert.Equal(t, "ac_mapped", params["connection_id"]) + r.ID = auth0.String("fl_created") + return nil + }) + + cli := &cli{api: &auth0.API{Flow: flowMock}} + + body, err := cli.resolveFormEnvelope(&cobra.Command{}, envelope, map[string]string{"#CONN-1#": "ac_mapped"}) + require.NoError(t, err) + + var formMap map[string]interface{} + require.NoError(t, json.Unmarshal(body, &formMap)) + node := formMap["nodes"].([]interface{})[0].(map[string]interface{}) + config := node["config"].(map[string]interface{}) + assert.Equal(t, "fl_created", config["flow_id"]) + }) + + t.Run("errors when a connection cannot be resolved without a terminal", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cli := &cli{api: &auth0.API{Flow: mock.NewMockFlowAPI(ctrl)}} + + _, err := cli.resolveFormEnvelope(&cobra.Command{}, envelope, nil) + assert.ErrorContains(t, err, "cannot resolve connection #CONN-1#") + }) +} diff --git a/internal/cli/forms_test.go b/internal/cli/forms_test.go new file mode 100644 index 000000000..8f8610efb --- /dev/null +++ b/internal/cli/forms_test.go @@ -0,0 +1,527 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/auth0/mock" + "github.com/auth0/auth0-cli/internal/config" + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/iostream" +) + +func TestApplyRawFormOverrides(t *testing.T) { + body := json.RawMessage(`{ + "name":"Original", + "languages":{"primary":"en","default":"fr"}, + "start":{}, + "nodes":[ + {"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}, + {"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}} + ], + "ending":null + }`) + + got, err := applyRawFormOverrides(body, "Renamed", "de", "") + require.NoError(t, err) + + var form map[string]json.RawMessage + require.NoError(t, json.Unmarshal(got, &form)) + + assert.JSONEq(t, `"Renamed"`, string(form["name"])) + assert.JSONEq(t, `{"primary":"de","default":"fr"}`, string(form["languages"])) + assert.JSONEq(t, `{}`, string(form["start"])) + assert.JSONEq(t, `null`, string(form["ending"])) + assert.Contains(t, string(form["nodes"]), `"components"`) + assert.Contains(t, string(form["nodes"]), `"condition"`) +} + +func TestApplyRawFormOverridesRejectsNonObject(t *testing.T) { + _, err := applyRawFormOverrides(json.RawMessage(`[]`), "", "", "") + assert.ErrorContains(t, err, "cannot unmarshal array") +} + +func TestCreateFormCmdUsesRawClientForSimpleScaffold(t *testing.T) { + httpClient := &formHTTPClientStub{ + response: json.RawMessage(`{"id":"ap_simple","name":"Simple Form","start":{},"nodes":[],"ending":{}}`), + } + stdout := &bytes.Buffer{} + c := &cli{ + api: &auth0.API{HTTPClient: httpClient}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } + + cmd := createFormCmd(c) + cmd.SetArgs([]string{"--name", "Simple Form"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPost, httpClient.method) + require.IsType(t, json.RawMessage{}, httpClient.payload) + assert.JSONEq(t, `{"name":"Simple Form","start":{},"nodes":[],"ending":{}}`, string(httpClient.payload.(json.RawMessage))) + assert.Contains(t, stdout.String(), "Simple Form") +} + +func TestCreateFormCmdUsesRawClientForRichFile(t *testing.T) { + body := []byte(`{ + "name":"Rich Form", + "nodes":[{"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}] + }`) + path := filepath.Join(t.TempDir(), "form.json") + require.NoError(t, os.WriteFile(path, body, 0600)) + + httpClient := &formHTTPClientStub{ + response: json.RawMessage(`{ + "id":"ap_rich", + "name":"Rich Form", + "nodes":[{"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}] + }`), + } + stdout := &bytes.Buffer{} + c := &cli{ + api: &auth0.API{HTTPClient: httpClient}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } + + cmd := createFormCmd(c) + cmd.SetArgs([]string{"--file", path}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPost, httpClient.method) + require.IsType(t, json.RawMessage{}, httpClient.payload) + assert.Contains(t, string(httpClient.payload.(json.RawMessage)), `"components"`) + assert.Contains(t, stdout.String(), "1 nodes") +} + +func TestShowFormCmdUsesRawClient(t *testing.T) { + httpClient := &formHTTPClientStub{ + response: json.RawMessage(`{ + "id":"ap_rich", + "name":"Rich Form", + "flow_count":2, + "links":{"self":"https://example.test/forms/ap_rich"}, + "nodes":[{"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}}] + }`), + } + stdout := &bytes.Buffer{} + c := &cli{ + api: &auth0.API{HTTPClient: httpClient}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } + + cmd := showFormCmd(c) + cmd.SetArgs([]string{"ap_rich"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodGet, httpClient.method) + assert.Contains(t, stdout.String(), "1 nodes") +} + +func TestUpdateFormCmdUsesRawClientForScalarUpdate(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI.EXPECT(). + Get(gomock.Any(), "ap_simple", gomock.Any()). + Return(&managementv3.GetFormResponseContent{ + ID: "ap_simple", + Name: "Original", + Languages: &managementv3.FormLanguages{ + Primary: auth0.String("en"), + Default: auth0.String("fr"), + }, + }, nil) + httpClient := &formHTTPClientStub{ + response: json.RawMessage(`{"id":"ap_simple","name":"Renamed","languages":{"primary":"de","default":"fr"}}`), + } + + stdout := &bytes.Buffer{} + c := &cli{ + api: &auth0.API{HTTPClient: httpClient}, + apiv3: &auth0.APIV3{Form: formAPI}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } + + cmd := updateFormCmd(c) + cmd.SetArgs([]string{"ap_simple", "--name", "Renamed", "--language-primary", "de"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPatch, httpClient.method) + require.IsType(t, json.RawMessage{}, httpClient.payload) + assert.JSONEq(t, `{"name":"Renamed","languages":{"primary":"de","default":"fr"}}`, string(httpClient.payload.(json.RawMessage))) + assert.Contains(t, stdout.String(), "Renamed") +} + +func TestUpdateFormCmdUsesRawClientForRichFile(t *testing.T) { + body := []byte(`{ + "id":"ap_rich", + "name":"Rich Form", + "nodes":[{"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}}], + "ending":null, + "created_at":"2026-08-24T00:00:00Z", + "updated_at":"2026-08-24T00:00:00Z", + "flow_count":0, + "links":{} + }`) + path := filepath.Join(t.TempDir(), "form.json") + require.NoError(t, os.WriteFile(path, body, 0600)) + + httpClient := &formHTTPClientStub{ + response: json.RawMessage(`{ + "id":"ap_rich", + "name":"Rich Form", + "nodes":[{"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}}], + "ending":null + }`), + } + stdout := &bytes.Buffer{} + c := &cli{ + api: &auth0.API{HTTPClient: httpClient}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } + + cmd := updateFormCmd(c) + cmd.SetArgs([]string{"ap_rich", "--file", path}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPatch, httpClient.method) + require.IsType(t, json.RawMessage{}, httpClient.payload) + payload := httpClient.payload.(json.RawMessage) + assert.Contains(t, string(payload), `"condition"`) + assert.Contains(t, string(payload), `"ending":null`) + var form map[string]json.RawMessage + require.NoError(t, json.Unmarshal(payload, &form)) + assert.NotContains(t, form, "id") + assert.NotContains(t, form, "created_at") + assert.NotContains(t, form, "updated_at") + assert.NotContains(t, form, "flow_count") + assert.NotContains(t, form, "links") + assert.Contains(t, stdout.String(), "1 nodes") +} + +func TestReadFormBody(t *testing.T) { + t.Run("reads from a file", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "form.json") + want := []byte(`{"name":"My Form"}`) + assert.NoError(t, os.WriteFile(path, want, 0600)) + + got, err := readFormBody(path) + assert.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("errors on a missing file", func(t *testing.T) { + _, err := readFormBody(filepath.Join(t.TempDir(), "missing.json")) + assert.ErrorContains(t, err, "failed to read form file") + }) + + t.Run("reads from stdin when file is '-'", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "stdin.json") + want := []byte(`{"name":"Piped Form"}`) + assert.NoError(t, os.WriteFile(path, want, 0600)) + + f, err := os.Open(path) + assert.NoError(t, err) + defer f.Close() + + original := iostream.Input + iostream.Input = f + defer func() { iostream.Input = original }() + + got, err := readFormBody("-") + assert.NoError(t, err) + assert.Equal(t, want, got) + }) +} + +func TestFormPickerOptions(t *testing.T) { + tests := []struct { + name string + forms []*managementv3.FormSummary + apiError error + assertOutput func(t testing.TB, options pickerOptions) + assertError func(t testing.TB, err error) + }{ + { + name: "happy path", + forms: []*managementv3.FormSummary{ + {ID: "some-id-1", Name: "some-name-1"}, + {ID: "some-id-2", Name: "some-name-2"}, + }, + assertOutput: func(t testing.TB, options pickerOptions) { + assert.Len(t, options, 2) + assert.Equal(t, "some-name-1 (some-id-1)", options[0].label) + assert.Equal(t, "some-id-1", options[0].value) + assert.Equal(t, "some-name-2 (some-id-2)", options[1].label) + assert.Equal(t, "some-id-2", options[1].value) + }, + assertError: func(t testing.TB, err error) { + t.Fail() + }, + }, + { + name: "no forms", + forms: []*managementv3.FormSummary{}, + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.ErrorContains(t, err, "there are currently no forms to choose from. Create one by running: `auth0 forms create`") + }, + }, + { + name: "API error", + apiError: errors.New("error"), + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.Error(t, err) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + formAPI := mock.NewMockFormAPIV3(ctrl) + if test.apiError != nil { + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, test.apiError) + } else { + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return( + &auth0.FormSummaryPage{ + Results: test.forms, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return nil, core.ErrNoPages + }, + }, nil) + } + + cli := &cli{ + apiv3: &auth0.APIV3{Form: formAPI}, + } + + options, err := cli.formPickerOptions(context.Background()) + + if err != nil { + test.assertError(t, err) + } else { + test.assertOutput(t, options) + } + }) + } +} + +func TestCollectForms(t *testing.T) { + t.Run("pages across responses until exhausted", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + secondPage := &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{{ID: "id-3", Name: "Form 3"}}, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return nil, core.ErrNoPages + }, + } + firstPage := &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{ + {ID: "id-1", Name: "Form 1"}, + {ID: "id-2", Name: "Form 2"}, + }, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return secondPage, nil + }, + } + + formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(firstPage, nil) + + cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} + + forms, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 0) + assert.NoError(t, err) + assert.Len(t, forms, 3) + assert.Equal(t, "id-3", forms[2].GetID()) + }) + + t.Run("stops at the requested limit without paging further", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + firstPage := &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{ + {ID: "id-1", Name: "Form 1"}, + {ID: "id-2", Name: "Form 2"}, + }, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + t.Fatal("should not page past the limit") + return nil, nil + }, + } + + formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(firstPage, nil) + + cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} + + forms, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 1) + assert.NoError(t, err) + assert.Len(t, forms, 1) + assert.Equal(t, "id-1", forms[0].GetID()) + }) + + t.Run("returns the list error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} + + _, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 0) + assert.EqualError(t, err, "boom") + }) +} + +func TestFormatFormEditURL(t *testing.T) { + cfg := &config.Config{ + Tenants: config.Tenants{ + "example.us.auth0.com": {Name: "example"}, + "my-tenant.eu.auth0.com": {Name: "my-tenant"}, + "dev-tti06f6y.auth0.com": {Name: "dev-tti06f6y"}, + "no-name.us.auth0.com": {Name: ""}, + }, + } + + tests := []struct { + name string + tenant string + id string + expected string + }{ + { + name: "derives the region from a four-part domain", + tenant: "example.us.auth0.com", + id: "ap_123", + expected: "https://forms.auth0.com/tenants/us/example/forms/ap_123/edit", + }, + { + name: "supports non-us regions", + tenant: "my-tenant.eu.auth0.com", + id: "ap_456", + expected: "https://forms.auth0.com/tenants/eu/my-tenant/forms/ap_456/edit", + }, + { + name: "defaults to us for a three-part PUS1 domain", + tenant: "dev-tti06f6y.auth0.com", + id: "ap_789", + expected: "https://forms.auth0.com/tenants/us/dev-tti06f6y/forms/ap_789/edit", + }, + { + name: "returns empty when the tenant is unknown", + tenant: "example.us.auth0.com", + id: "", + expected: "", + }, + { + name: "returns empty when the tenant is missing", + tenant: "", + id: "ap_123", + expected: "", + }, + { + name: "returns empty when the domain has too few parts", + tenant: "invalid", + id: "ap_123", + expected: "", + }, + { + name: "returns empty when the tenant name is unknown", + tenant: "no-name.us.auth0.com", + id: "ap_123", + expected: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, formatFormEditURL(test.tenant, cfg, test.id)) + }) + } +} + +type formHTTPClientStub struct { + method string + payload interface{} + response json.RawMessage +} + +func (s *formHTTPClientStub) NewRequest( + ctx context.Context, + method string, + uri string, + payload interface{}, + _ ...management.RequestOption, +) (*http.Request, error) { + s.method = method + s.payload = payload + return http.NewRequestWithContext(ctx, method, uri, nil) +} + +func (s *formHTTPClientStub) Do(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(s.response))), + }, nil +} + +func (s *formHTTPClientStub) Request( + context.Context, + string, + string, + interface{}, + ...management.RequestOption, +) error { + return nil +} + +func (s *formHTTPClientStub) URI(path ...string) string { + return "https://example.test/api/v2/" + strings.Join(path, "/") +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 0d818c088..cc7596a37 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -280,6 +280,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(apiCmd(cli)) rootCmd.AddCommand(terraformCmd(cli)) rootCmd.AddCommand(eventStreamsCmd(cli)) + rootCmd.AddCommand(formsCmd(cli)) rootCmd.AddCommand(networkACLCmd(cli)) rootCmd.AddCommand(tenantSettingsCmd(cli)) rootCmd.AddCommand(tokenExchangeCmd(cli)) diff --git a/internal/cli/terraform.go b/internal/cli/terraform.go index 5ddee1b59..d7c45ee5d 100644 --- a/internal/cli/terraform.go +++ b/internal/cli/terraform.go @@ -98,7 +98,7 @@ func (i *terraformInputs) parseResourceFetchers(api *auth0.API, apiv3 *auth0.API case "auth0_flow_vault_connection": fetchers = append(fetchers, &flowVaultConnectionResourceFetcher{api}) case "auth0_form": - fetchers = append(fetchers, &formResourceFetcher{api}) + fetchers = append(fetchers, &formResourceFetcher{apiv3}) case "auth0_guardian": fetchers = append(fetchers, &guardianResourceFetcher{}) case "auth0_log_stream": diff --git a/internal/cli/terraform_fetcher.go b/internal/cli/terraform_fetcher.go index 05655e5f2..a999c64d9 100644 --- a/internal/cli/terraform_fetcher.go +++ b/internal/cli/terraform_fetcher.go @@ -2,11 +2,13 @@ package cli import ( "context" + "errors" "net/http" "strings" "github.com/auth0/go-auth0/management" managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" "github.com/google/uuid" "github.com/auth0/auth0-cli/internal/auth0" @@ -86,7 +88,7 @@ type ( } formResourceFetcher struct { - api *auth0.API + apiv3 *auth0.APIV3 } guardianResourceFetcher struct{} @@ -432,16 +434,26 @@ func (f *flowVaultConnectionResourceFetcher) FetchData(ctx context.Context) (imp func (f *formResourceFetcher) FetchData(ctx context.Context) (importDataList, error) { var data importDataList - forms, err := f.api.Form.List(ctx) + page, err := f.apiv3.Form.List(ctx, &managementv3.ListFormsRequestParameters{}) if err != nil { return data, err } - for _, form := range forms.Forms { - data = append(data, importDataItem{ - ResourceName: "auth0_form." + sanitizeResourceName(form.GetName()), - ImportID: form.GetID(), - }) + for page != nil { + for _, form := range page.Results { + data = append(data, importDataItem{ + ResourceName: "auth0_form." + sanitizeResourceName(form.GetName()), + ImportID: form.GetID(), + }) + } + + page, err = page.GetNextPage(ctx) + if errors.Is(err, core.ErrNoPages) { + break + } + if err != nil { + return data, err + } } return data, nil diff --git a/internal/cli/terraform_fetcher_test.go b/internal/cli/terraform_fetcher_test.go index 574763c3f..6c46984dc 100644 --- a/internal/cli/terraform_fetcher_test.go +++ b/internal/cli/terraform_fetcher_test.go @@ -1076,29 +1076,27 @@ func TestFormResourceFetcher_FetchData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - formAPI := mock.NewMockFormAPI(ctrl) + formAPI := mock.NewMockFormAPIV3(ctrl) formAPI.EXPECT(). List(gomock.Any(), gomock.Any()).Return( - &management.FormList{ - List: management.List{ - Start: 0, - Limit: 1, - Total: 2, - }, - Forms: []*management.Form{ + &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{ { - ID: auth0.String("form_id1"), - Name: auth0.String("Form 1"), + ID: "form_id1", + Name: "Form 1", }, { - ID: auth0.String("form_id2"), - Name: auth0.String("Form 2"), + ID: "form_id2", + Name: "Form 2", }, }, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return nil, core.ErrNoPages + }, }, nil) fetcher := formResourceFetcher{ - api: &auth0.API{ + apiv3: &auth0.APIV3{ Form: formAPI, }, } @@ -1123,20 +1121,18 @@ func TestFormResourceFetcher_FetchData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - formAPI := mock.NewMockFormAPI(ctrl) + formAPI := mock.NewMockFormAPIV3(ctrl) formAPI.EXPECT(). List(gomock.Any(), gomock.Any()).Return( - &management.FormList{ - List: management.List{ - Start: 0, - Limit: 0, - Total: 0, + &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{}, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return nil, core.ErrNoPages }, - Forms: []*management.Form{}, }, nil) fetcher := formResourceFetcher{ - api: &auth0.API{ + apiv3: &auth0.APIV3{ Form: formAPI, }, } @@ -1150,13 +1146,13 @@ func TestFormResourceFetcher_FetchData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - formAPI := mock.NewMockFormAPI(ctrl) + formAPI := mock.NewMockFormAPIV3(ctrl) formAPI.EXPECT(). List(gomock.Any(), gomock.Any()). Return(nil, fmt.Errorf("failed to read form")) fetcher := formResourceFetcher{ - api: &auth0.API{ + apiv3: &auth0.APIV3{ Form: formAPI, }, } diff --git a/internal/display/forms.go b/internal/display/forms.go new file mode 100644 index 000000000..45839ecbb --- /dev/null +++ b/internal/display/forms.go @@ -0,0 +1,237 @@ +package display + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + managementv3 "github.com/auth0/go-auth0/v3/management" + + "github.com/auth0/auth0-cli/internal/ansi" +) + +type formView struct { + ID string + Name string + LanguagePrimary string + LanguageDefault string + NodeCount int + TranslationLang int + HasStyle bool + CreatedAt string + UpdatedAt string + SubmittedAt string + + raw interface{} +} + +func (v *formView) AsTableHeader() []string { + return []string{"ID", "Name", "Submitted", "Updated"} +} + +func (v *formView) AsTableRow() []string { + return []string{ansi.Faint(v.ID), v.Name, v.SubmittedAt, v.UpdatedAt} +} + +func (v *formView) KeyValues() [][]string { + kvs := [][]string{ + {"ID", ansi.Faint(v.ID)}, + {"NAME", v.Name}, + {"LANGUAGES", formLanguageSummary(v.LanguagePrimary, v.LanguageDefault)}, + {"NODES", fmt.Sprintf("%d nodes", v.NodeCount)}, + {"TRANSLATIONS", fmt.Sprintf("%d languages", v.TranslationLang)}, + {"STYLE", boolToPresence(v.HasStyle)}, + } + + kvs = append(kvs, + []string{"CREATED AT", v.CreatedAt}, + []string{"UPDATED AT", v.UpdatedAt}, + ) + + if v.SubmittedAt != "" { + kvs = append(kvs, []string{"SUBMITTED AT", v.SubmittedAt}) + } + + return kvs +} + +func (v *formView) Object() interface{} { + return v.raw +} + +// formSummaryView renders a single row in the forms list. +type formSummaryView struct { + ID string + Name string + SubmittedAt string + UpdatedAt string + + raw interface{} +} + +func (v *formSummaryView) AsTableHeader() []string { + return []string{"ID", "Name", "Submitted", "Updated"} +} + +func (v *formSummaryView) AsTableRow() []string { + return []string{ansi.Faint(v.ID), v.Name, v.SubmittedAt, v.UpdatedAt} +} + +func (v *formSummaryView) Object() interface{} { + return v.raw +} + +// FormsList renders the list of forms. +func (r *Renderer) FormsList(forms []*managementv3.FormSummary) error { + resource := "forms" + + r.Heading(resource) + + if len(forms) == 0 { + r.EmptyState(resource, "Use 'auth0 forms create' to add one") + return nil + } + + var res []View + for _, f := range forms { + res = append(res, makeFormSummaryView(f)) + } + + r.Results(res) + + return nil +} + +// FormShowRaw renders a full-fidelity form response read through the v1 HTTP +// client, avoiding the v3 SDK's lossy form-node unions. +func (r *Renderer) FormShowRaw(form json.RawMessage) error { + return r.renderRawForm("form", form) +} + +// FormCreateRaw renders a full-fidelity create response. +func (r *Renderer) FormCreateRaw(form json.RawMessage) error { + return r.renderRawForm("form created", form) +} + +// FormUpdateRaw renders a full-fidelity update response. +func (r *Renderer) FormUpdateRaw(form json.RawMessage) error { + return r.renderRawForm("form updated", form) +} + +func (r *Renderer) renderRawForm(heading string, form json.RawMessage) error { + view, err := makeFormViewFromRaw(form) + if err != nil { + return fmt.Errorf("failed to parse form response: %w", err) + } + r.Heading(heading) + r.Result(view) + return nil +} + +func makeFormSummaryView(f *managementv3.FormSummary) *formSummaryView { + return &formSummaryView{ + ID: f.GetID(), + Name: f.GetName(), + SubmittedAt: f.GetSubmittedAt(), + UpdatedAt: timeAgo(f.GetUpdatedAt()), + raw: mergeExtraProperties(f, f.GetExtraProperties()), + } +} + +func makeFormViewFromRaw(raw json.RawMessage) (*formView, error) { + var form struct { + ID string `json:"id"` + Name string `json:"name"` + Languages struct { + Primary string `json:"primary"` + Default string `json:"default"` + } `json:"languages"` + Nodes []json.RawMessage `json:"nodes"` + Translations map[string]json.RawMessage `json:"translations"` + Style json.RawMessage `json:"style"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SubmittedAt string `json:"submitted_at"` + } + if err := json.Unmarshal(raw, &form); err != nil { + return nil, err + } + + return &formView{ + ID: form.ID, + Name: form.Name, + LanguagePrimary: form.Languages.Primary, + LanguageDefault: form.Languages.Default, + NodeCount: len(form.Nodes), + TranslationLang: len(form.Translations), + HasStyle: rawJSONPresent(form.Style), + CreatedAt: rawTimeAgo(form.CreatedAt), + UpdatedAt: rawTimeAgo(form.UpdatedAt), + SubmittedAt: form.SubmittedAt, + raw: raw, + }, nil +} + +func rawJSONPresent(raw json.RawMessage) bool { + value := strings.TrimSpace(string(raw)) + return value != "" && value != "null" +} + +func rawTimeAgo(value time.Time) string { + if value.IsZero() { + return "" + } + return timeAgo(value) +} + +// FormExport writes a form body verbatim (uncolored) to the result writer so it +// stays pipe- and import-friendly. +func (r *Renderer) FormExport(body string) { + fmt.Fprintln(r.ResultWriter, body) +} + +func formLanguageSummary(primary, def string) string { + switch { + case primary == "" && def == "": + return "-" + case def == "": + return fmt.Sprintf("primary: %s", primary) + case primary == "": + return fmt.Sprintf("default: %s", def) + default: + return fmt.Sprintf("primary: %s, default: %s", primary, def) + } +} + +func boolToPresence(present bool) string { + if present { + return "set" + } + return "none" +} + +// mergeExtraProperties rebuilds the full API wire object for JSON output. The +// generated SDK captures fields it does not model (such as flow_count and links +// on forms) into an extra-properties map that its own MarshalJSON drops, so +// re-marshaling the typed value alone would silently lose them. Marshaling the +// typed value and overlaying the extras keeps --json faithful to the API. +func mergeExtraProperties(obj interface{}, extra map[string]interface{}) interface{} { + if len(extra) == 0 { + return obj + } + data, err := json.Marshal(obj) + if err != nil { + return obj + } + var merged map[string]interface{} + if err := json.Unmarshal(data, &merged); err != nil { + return obj + } + for key, value := range extra { + if _, ok := merged[key]; !ok { + merged[key] = value + } + } + return merged +} diff --git a/internal/display/forms_test.go b/internal/display/forms_test.go new file mode 100644 index 000000000..fa08d85c6 --- /dev/null +++ b/internal/display/forms_test.go @@ -0,0 +1,50 @@ +package display + +import ( + "bytes" + "encoding/json" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormShowRawPreservesRichGraphJSON(t *testing.T) { + stdout := &bytes.Buffer{} + renderer := &Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + Format: OutputFormatJSON, + } + body := json.RawMessage(`{ + "id":"ap_rich", + "name":"Rich Form", + "flow_count":2, + "links":{"self":"https://example.test/forms/ap_rich"}, + "nodes":[ + {"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}, + {"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}} + ] + }`) + + require.NoError(t, renderer.FormShowRaw(body)) + + var got map[string]interface{} + require.NoError(t, json.Unmarshal(stdout.Bytes(), &got)) + assert.Equal(t, float64(2), got["flow_count"]) + assert.Contains(t, got, "links") + + nodes := got["nodes"].([]interface{}) + step := nodes[0].(map[string]interface{}) + assert.Contains(t, step["config"].(map[string]interface{}), "components") + router := nodes[1].(map[string]interface{}) + rules := router["config"].(map[string]interface{})["rules"].([]interface{}) + assert.Contains(t, rules[0].(map[string]interface{}), "condition") +} + +func TestFormShowRawRejectsInvalidJSON(t *testing.T) { + renderer := &Renderer{MessageWriter: io.Discard, ResultWriter: io.Discard} + err := renderer.FormShowRaw(json.RawMessage(`not-json`)) + assert.ErrorContains(t, err, "failed to parse form response") +} diff --git a/test/integration/fixtures/update-form.json b/test/integration/fixtures/update-form.json new file mode 100644 index 000000000..008d6630c --- /dev/null +++ b/test/integration/fixtures/update-form.json @@ -0,0 +1,10 @@ +{ + "name": "integration-test-form-fixture-updated", + "languages": { + "primary": "en", + "default": "en" + }, + "start": {}, + "nodes": [], + "ending": {} +} diff --git a/test/integration/forms-test-cases.yaml b/test/integration/forms-test-cases.yaml new file mode 100644 index 000000000..7478a81df --- /dev/null +++ b/test/integration/forms-test-cases.yaml @@ -0,0 +1,128 @@ +config: + inherit-env: true + retries: 1 + +tests: + 001 - it successfully lists all forms (json): + command: auth0 forms list --json + exit-code: 0 + + 002 - it successfully creates a form via --name: + command: auth0 forms create --name integration-test-form-created --no-input + exit-code: 0 + stdout: + contains: + - ID + - NAME + - integration-test-form-created + + 003 - it successfully creates a form and outputs in json: + command: auth0 forms create --name integration-test-form-json --no-input --json + exit-code: 0 + stdout: + json: + name: "integration-test-form-json" + + 004 - it successfully creates a form from the embedded example: + command: auth0 forms create --example | auth0 forms create -f - --name integration-test-form-example --no-input --json + exit-code: 0 + stdout: + json: + name: "integration-test-form-example" + languages.primary: "en" + languages.default: "en" + + 005 - it fails to create a form without a name: + command: echo '{"start":{},"nodes":[],"ending":{}}' | auth0 forms create -f - --no-input + exit-code: 1 + stderr: + contains: + - form name is required + + 006 - it fails to create a form from invalid json: + command: echo 'not-json' | auth0 forms create -f - --no-input + exit-code: 1 + stderr: + contains: + - parse form body + + 007 - it successfully lists all forms with data: + command: auth0 forms list + exit-code: 0 + stdout: + contains: + - ID + - NAME + - UPDATED + + 008 - given a test form, it successfully shows the form details: + command: auth0 forms show $(./test/integration/scripts/get-form-id.sh) + exit-code: 0 + stdout: + contains: + - ID + - NAME + - integration-test-form + + 009 - given a test form, it successfully shows the form details (json): + command: auth0 forms show $(./test/integration/scripts/get-form-id.sh) --json + exit-code: 0 + stdout: + json: + name: "integration-test-form" + + 010 - given a test form, it successfully updates the form name: + command: auth0 forms update $(./test/integration/scripts/get-form-id.sh) --name integration-test-form-updated --json + exit-code: 0 + stdout: + json: + name: "integration-test-form-updated" + + 011 - given a test form, it successfully updates the form from a fixture file: + command: auth0 forms update $(./test/integration/scripts/get-form-id.sh) -f ./test/integration/fixtures/update-form.json --json + exit-code: 0 + stdout: + json: + name: "integration-test-form-fixture-updated" + languages.primary: "en" + languages.default: "en" + + 012 - given a test form, it successfully exports the form as an envelope: + command: auth0 forms export $(./test/integration/scripts/get-form-id.sh) + exit-code: 0 + stdout: + contains: + - '"version"' + - '"form"' + - '"name"' + + 013 - given a test form, it successfully round-trips export to import: + command: auth0 forms export $(./test/integration/scripts/get-form-id.sh) | auth0 forms import --id $(./test/integration/scripts/get-form-id.sh) -f - --json + exit-code: 0 + stdout: + json: + name: "integration-test-form-fixture-updated" + + 014 - given a test form, it prints the builder URL for open: + command: auth0 forms open $(./test/integration/scripts/get-form-id.sh) --no-input + exit-code: 0 + stderr: + contains: + - forms.auth0.com + - /edit + + 015 - agent mode refuses to delete a form without force: + command: AUTH0_AGENT_MODE=true auth0 forms delete $(./test/integration/scripts/get-form-id.sh) + exit-code: 1 + stderr: + contains: + - destructive command + - --force + + 016 - given a test form, it successfully deletes the form: + command: auth0 forms delete $(./test/integration/scripts/get-form-id.sh) --force + exit-code: 0 + + 017 - it cleans up all forms created by this suite: + command: ./test/integration/scripts/cleanup-forms.sh + exit-code: 0 diff --git a/test/integration/scripts/cleanup-forms.sh b/test/integration/scripts/cleanup-forms.sh new file mode 100755 index 000000000..ec2a4816c --- /dev/null +++ b/test/integration/scripts/cleanup-forms.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -euo pipefail + +ids=() +while IFS= read -r id; do + if [[ -n "$id" ]]; then + ids+=("$id") + fi +done < <(auth0 forms list --json --no-input | jq -r '.[] | select(.name | startswith("integration-test-")) | .id') + +if (( ${#ids[@]} > 0 )); then + auth0 forms delete --force "${ids[@]}" +fi + +rm -f ./test/integration/identifiers/form-id diff --git a/test/integration/scripts/get-form-id.sh b/test/integration/scripts/get-form-id.sh new file mode 100755 index 000000000..2a9f67960 --- /dev/null +++ b/test/integration/scripts/get-form-id.sh @@ -0,0 +1,13 @@ +#! /bin/bash + +FILE=./test/integration/identifiers/form-id +if [ -f "$FILE" ]; then + cat $FILE + exit 0 +fi + +form=$( auth0 forms create --name "integration-test-form" --json --no-input ) + +mkdir -p ./test/integration/identifiers +echo "$form" | jq -r '.["id"]' > $FILE +cat $FILE diff --git a/test/integration/scripts/run-test-suites.sh b/test/integration/scripts/run-test-suites.sh index 4b0e7ea28..f637862d1 100644 --- a/test/integration/scripts/run-test-suites.sh +++ b/test/integration/scripts/run-test-suites.sh @@ -14,21 +14,28 @@ auth0 login \ set +e -# The quickstart integration tests are excluded from the default suite, so run -# each remaining test-cases file individually (in alphabetical order, matching -# --dir) instead of the whole directory. exit_code=0 -for suite in ./test/integration/*.yaml; do - if [[ "$(basename "$suite")" == "quickstarts-test-cases.yaml" ]]; then - echo "Skipping $suite" - continue - fi - - commander test --filter "$FILTER" "$suite" +if [[ -n "${FILE}" ]]; then + commander test --filter "$FILTER" "${FILE}" if [[ $? -ne 0 ]]; then exit_code=1 fi -done +else + # The quickstart integration tests are excluded from the default suite, so run + # each remaining test-cases file individually (in alphabetical order, matching + # --dir) instead of the whole directory. + for suite in ./test/integration/*.yaml; do + if [[ "$(basename "$suite")" == "quickstarts-test-cases.yaml" ]]; then + echo "Skipping $suite" + continue + fi + + commander test --filter "$FILTER" "$suite" + if [[ $? -ne 0 ]]; then + exit_code=1 + fi + done +fi bash ./test/integration/scripts/test-cleanup.sh diff --git a/test/integration/scripts/test-cleanup.sh b/test/integration/scripts/test-cleanup.sh index 61ff1d57f..079e76f6d 100755 --- a/test/integration/scripts/test-cleanup.sh +++ b/test/integration/scripts/test-cleanup.sh @@ -32,6 +32,7 @@ delete_resources "actions" "integration-test-" "id" delete_resources "actions modules" "integration-test-module" "id" delete_resources "token-exchange" "integration-test-" "id" delete_resources "event-streams" "integration-test-" "id" +delete_resources "forms" "integration-test-" "id" delete_resources "logs streams" "integration-test-" "id" auth0 domains delete $(./test/integration/scripts/get-custom-domain-id.sh) --no-input