diff --git a/docs/auth0_experiments.md b/docs/auth0_experiments.md new file mode 100644 index 000000000..6e9280ce7 --- /dev/null +++ b/docs/auth0_experiments.md @@ -0,0 +1,29 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 experiments + +Experiments run A/B tests by tying a feature flag, its variations, and traffic allocations together. + +Typical workflow: + 1. Create a feature flag and its variations + 2. Optionally create segments for targeted allocation + 3. Create an experiment + 4. Validate it + 5. Start it + +## Commands + +- [auth0 experiments archive](auth0_experiments_archive.md) - Archive an experiment +- [auth0 experiments complete](auth0_experiments_complete.md) - Complete an experiment +- [auth0 experiments create](auth0_experiments_create.md) - Create a new experiment +- [auth0 experiments delete](auth0_experiments_delete.md) - Delete an experiment +- [auth0 experiments list](auth0_experiments_list.md) - List your experiments +- [auth0 experiments pause](auth0_experiments_pause.md) - Pause an experiment +- [auth0 experiments show](auth0_experiments_show.md) - Show an experiment +- [auth0 experiments start](auth0_experiments_start.md) - Start an experiment +- [auth0 experiments update](auth0_experiments_update.md) - Update an experiment +- [auth0 experiments validate](auth0_experiments_validate.md) - Validate an experiment + diff --git a/docs/auth0_experiments_archive.md b/docs/auth0_experiments_archive.md new file mode 100644 index 000000000..8db68b62f --- /dev/null +++ b/docs/auth0_experiments_archive.md @@ -0,0 +1,48 @@ +--- +layout: default +parent: auth0 experiments +has_toc: false +--- +# auth0 experiments archive + +Archive a completed experiment. + +## Usage +``` +auth0 experiments archive [flags] +``` + +## Examples + +``` + auth0 experiments archive + auth0 experiments archive +``` + + + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 experiments archive](auth0_experiments_archive.md) - Archive an experiment +- [auth0 experiments complete](auth0_experiments_complete.md) - Complete an experiment +- [auth0 experiments create](auth0_experiments_create.md) - Create a new experiment +- [auth0 experiments delete](auth0_experiments_delete.md) - Delete an experiment +- [auth0 experiments list](auth0_experiments_list.md) - List your experiments +- [auth0 experiments pause](auth0_experiments_pause.md) - Pause an experiment +- [auth0 experiments show](auth0_experiments_show.md) - Show an experiment +- [auth0 experiments start](auth0_experiments_start.md) - Start an experiment +- [auth0 experiments update](auth0_experiments_update.md) - Update an experiment +- [auth0 experiments validate](auth0_experiments_validate.md) - Validate an experiment + + diff --git a/docs/auth0_experiments_complete.md b/docs/auth0_experiments_complete.md new file mode 100644 index 000000000..443c8e4c2 --- /dev/null +++ b/docs/auth0_experiments_complete.md @@ -0,0 +1,48 @@ +--- +layout: default +parent: auth0 experiments +has_toc: false +--- +# auth0 experiments complete + +Mark an experiment as completed. It can then be archived. + +## Usage +``` +auth0 experiments complete [flags] +``` + +## Examples + +``` + auth0 experiments complete + auth0 experiments complete +``` + + + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 experiments archive](auth0_experiments_archive.md) - Archive an experiment +- [auth0 experiments complete](auth0_experiments_complete.md) - Complete an experiment +- [auth0 experiments create](auth0_experiments_create.md) - Create a new experiment +- [auth0 experiments delete](auth0_experiments_delete.md) - Delete an experiment +- [auth0 experiments list](auth0_experiments_list.md) - List your experiments +- [auth0 experiments pause](auth0_experiments_pause.md) - Pause an experiment +- [auth0 experiments show](auth0_experiments_show.md) - Show an experiment +- [auth0 experiments start](auth0_experiments_start.md) - Start an experiment +- [auth0 experiments update](auth0_experiments_update.md) - Update an experiment +- [auth0 experiments validate](auth0_experiments_validate.md) - Validate an experiment + + diff --git a/docs/auth0_experiments_create.md b/docs/auth0_experiments_create.md new file mode 100644 index 000000000..239a37c7f --- /dev/null +++ b/docs/auth0_experiments_create.md @@ -0,0 +1,64 @@ +--- +layout: default +parent: auth0 experiments +has_toc: false +--- +# auth0 experiments create + +Create a new experiment. + +To create interactively, use `auth0 experiments create` with no flags. + +To create non-interactively, supply all required flags. + +## Usage +``` +auth0 experiments create [flags] +``` + +## Examples + +``` + auth0 experiments create + auth0 experiments create --name "button-color" --feature-flag-id ff_abc --authentication-flow login --allocation-strategy percentage --allocations '[{"variation_id":"vid_1","weight":0.5,"is_control":true},{"variation_id":"vid_2","weight":0.5,"is_control":false}]' +``` + + +## Flags + +``` + -s, --allocation-strategy string Allocation strategy: percentage or segment. + --allocations string JSON array of allocation items ({variation_id, weight, is_control} for percentage; {variation_id, segment_id, is_control} for segment). + -a, --authentication-flow string Authentication flow this experiment applies to (e.g. login, signup). + -d, --description string Description of the experiment. + -f, --feature-flag-id string ID of the feature flag to experiment on. + --json Output in json format. + --json-compact Output in compact json format. + -n, --name string Name of the experiment. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 experiments archive](auth0_experiments_archive.md) - Archive an experiment +- [auth0 experiments complete](auth0_experiments_complete.md) - Complete an experiment +- [auth0 experiments create](auth0_experiments_create.md) - Create a new experiment +- [auth0 experiments delete](auth0_experiments_delete.md) - Delete an experiment +- [auth0 experiments list](auth0_experiments_list.md) - List your experiments +- [auth0 experiments pause](auth0_experiments_pause.md) - Pause an experiment +- [auth0 experiments show](auth0_experiments_show.md) - Show an experiment +- [auth0 experiments start](auth0_experiments_start.md) - Start an experiment +- [auth0 experiments update](auth0_experiments_update.md) - Update an experiment +- [auth0 experiments validate](auth0_experiments_validate.md) - Validate an experiment + + diff --git a/docs/auth0_experiments_delete.md b/docs/auth0_experiments_delete.md new file mode 100644 index 000000000..dff3ab219 --- /dev/null +++ b/docs/auth0_experiments_delete.md @@ -0,0 +1,58 @@ +--- +layout: default +parent: auth0 experiments +has_toc: false +--- +# auth0 experiments delete + +Delete an experiment. + +Active experiments must be paused or completed before deleting. + +To delete non-interactively, supply the experiment ID and use `--force` to skip confirmation. + +## Usage +``` +auth0 experiments delete [flags] +``` + +## Examples + +``` + auth0 experiments delete + auth0 experiments delete + auth0 experiments delete --force +``` + + +## Flags + +``` + --force Skip confirmation. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 experiments archive](auth0_experiments_archive.md) - Archive an experiment +- [auth0 experiments complete](auth0_experiments_complete.md) - Complete an experiment +- [auth0 experiments create](auth0_experiments_create.md) - Create a new experiment +- [auth0 experiments delete](auth0_experiments_delete.md) - Delete an experiment +- [auth0 experiments list](auth0_experiments_list.md) - List your experiments +- [auth0 experiments pause](auth0_experiments_pause.md) - Pause an experiment +- [auth0 experiments show](auth0_experiments_show.md) - Show an experiment +- [auth0 experiments start](auth0_experiments_start.md) - Start an experiment +- [auth0 experiments update](auth0_experiments_update.md) - Update an experiment +- [auth0 experiments validate](auth0_experiments_validate.md) - Validate an experiment + + diff --git a/docs/auth0_experiments_list.md b/docs/auth0_experiments_list.md new file mode 100644 index 000000000..74557311f --- /dev/null +++ b/docs/auth0_experiments_list.md @@ -0,0 +1,61 @@ +--- +layout: default +parent: auth0 experiments +has_toc: false +--- +# auth0 experiments list + +List all experiments. To create one, run: `auth0 experiments create`. + +## Usage +``` +auth0 experiments list [flags] +``` + +## Examples + +``` + auth0 experiments list + auth0 experiments ls + auth0 experiments list --json + auth0 experiments list --status active + auth0 experiments list --feature-flag-id +``` + + +## Flags + +``` + --authentication-flow string Filter by authentication flow. + --csv Output in csv format. + --feature-flag-id string Filter by feature flag ID. + --json Output in json format. + --json-compact Output in compact json format. + --status string Filter by status (draft, active, paused, completed, archived). +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 experiments archive](auth0_experiments_archive.md) - Archive an experiment +- [auth0 experiments complete](auth0_experiments_complete.md) - Complete an experiment +- [auth0 experiments create](auth0_experiments_create.md) - Create a new experiment +- [auth0 experiments delete](auth0_experiments_delete.md) - Delete an experiment +- [auth0 experiments list](auth0_experiments_list.md) - List your experiments +- [auth0 experiments pause](auth0_experiments_pause.md) - Pause an experiment +- [auth0 experiments show](auth0_experiments_show.md) - Show an experiment +- [auth0 experiments start](auth0_experiments_start.md) - Start an experiment +- [auth0 experiments update](auth0_experiments_update.md) - Update an experiment +- [auth0 experiments validate](auth0_experiments_validate.md) - Validate an experiment + + diff --git a/docs/auth0_experiments_pause.md b/docs/auth0_experiments_pause.md new file mode 100644 index 000000000..24f73d08c --- /dev/null +++ b/docs/auth0_experiments_pause.md @@ -0,0 +1,48 @@ +--- +layout: default +parent: auth0 experiments +has_toc: false +--- +# auth0 experiments pause + +Pause a running experiment. It can be resumed with `auth0 experiments start`. + +## Usage +``` +auth0 experiments pause [flags] +``` + +## Examples + +``` + auth0 experiments pause + auth0 experiments pause +``` + + + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 experiments archive](auth0_experiments_archive.md) - Archive an experiment +- [auth0 experiments complete](auth0_experiments_complete.md) - Complete an experiment +- [auth0 experiments create](auth0_experiments_create.md) - Create a new experiment +- [auth0 experiments delete](auth0_experiments_delete.md) - Delete an experiment +- [auth0 experiments list](auth0_experiments_list.md) - List your experiments +- [auth0 experiments pause](auth0_experiments_pause.md) - Pause an experiment +- [auth0 experiments show](auth0_experiments_show.md) - Show an experiment +- [auth0 experiments start](auth0_experiments_start.md) - Start an experiment +- [auth0 experiments update](auth0_experiments_update.md) - Update an experiment +- [auth0 experiments validate](auth0_experiments_validate.md) - Validate an experiment + + diff --git a/docs/auth0_experiments_show.md b/docs/auth0_experiments_show.md new file mode 100644 index 000000000..bc028b91d --- /dev/null +++ b/docs/auth0_experiments_show.md @@ -0,0 +1,55 @@ +--- +layout: default +parent: auth0 experiments +has_toc: false +--- +# auth0 experiments show + +Display details about an experiment including its allocations and validation status. + +## Usage +``` +auth0 experiments show [flags] +``` + +## Examples + +``` + auth0 experiments show + auth0 experiments show + auth0 experiments show --json +``` + + +## Flags + +``` + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 experiments archive](auth0_experiments_archive.md) - Archive an experiment +- [auth0 experiments complete](auth0_experiments_complete.md) - Complete an experiment +- [auth0 experiments create](auth0_experiments_create.md) - Create a new experiment +- [auth0 experiments delete](auth0_experiments_delete.md) - Delete an experiment +- [auth0 experiments list](auth0_experiments_list.md) - List your experiments +- [auth0 experiments pause](auth0_experiments_pause.md) - Pause an experiment +- [auth0 experiments show](auth0_experiments_show.md) - Show an experiment +- [auth0 experiments start](auth0_experiments_start.md) - Start an experiment +- [auth0 experiments update](auth0_experiments_update.md) - Update an experiment +- [auth0 experiments validate](auth0_experiments_validate.md) - Validate an experiment + + diff --git a/docs/auth0_experiments_start.md b/docs/auth0_experiments_start.md new file mode 100644 index 000000000..c0f02ce9b --- /dev/null +++ b/docs/auth0_experiments_start.md @@ -0,0 +1,48 @@ +--- +layout: default +parent: auth0 experiments +has_toc: false +--- +# auth0 experiments start + +Transition an experiment from draft to active. Runs full validation before activating. + +## Usage +``` +auth0 experiments start [flags] +``` + +## Examples + +``` + auth0 experiments start + auth0 experiments start +``` + + + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 experiments archive](auth0_experiments_archive.md) - Archive an experiment +- [auth0 experiments complete](auth0_experiments_complete.md) - Complete an experiment +- [auth0 experiments create](auth0_experiments_create.md) - Create a new experiment +- [auth0 experiments delete](auth0_experiments_delete.md) - Delete an experiment +- [auth0 experiments list](auth0_experiments_list.md) - List your experiments +- [auth0 experiments pause](auth0_experiments_pause.md) - Pause an experiment +- [auth0 experiments show](auth0_experiments_show.md) - Show an experiment +- [auth0 experiments start](auth0_experiments_start.md) - Start an experiment +- [auth0 experiments update](auth0_experiments_update.md) - Update an experiment +- [auth0 experiments validate](auth0_experiments_validate.md) - Validate an experiment + + diff --git a/docs/auth0_experiments_update.md b/docs/auth0_experiments_update.md new file mode 100644 index 000000000..982445e48 --- /dev/null +++ b/docs/auth0_experiments_update.md @@ -0,0 +1,65 @@ +--- +layout: default +parent: auth0 experiments +has_toc: false +--- +# auth0 experiments update + +Update an experiment. + +Note: feature flag, authentication flow, and allocation strategy cannot be changed after creation. + +To update interactively, use `auth0 experiments update` with no arguments. + +## Usage +``` +auth0 experiments update [flags] +``` + +## Examples + +``` + auth0 experiments update + auth0 experiments update + auth0 experiments update --name "new-name" + auth0 experiments update --assignment-subject device + auth0 experiments update --allocations '[{"variation_id":"vid","weight":1.0,"is_control":true}]' +``` + + +## Flags + +``` + --allocations string JSON array of allocation items ({variation_id, weight, is_control} for percentage; {variation_id, segment_id, is_control} for segment). + --assignment-subject string Subject used for variation assignment (e.g. device). + -d, --description string Description of the experiment. + --json Output in json format. + --json-compact Output in compact json format. + -n, --name string Name of the experiment. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 experiments archive](auth0_experiments_archive.md) - Archive an experiment +- [auth0 experiments complete](auth0_experiments_complete.md) - Complete an experiment +- [auth0 experiments create](auth0_experiments_create.md) - Create a new experiment +- [auth0 experiments delete](auth0_experiments_delete.md) - Delete an experiment +- [auth0 experiments list](auth0_experiments_list.md) - List your experiments +- [auth0 experiments pause](auth0_experiments_pause.md) - Pause an experiment +- [auth0 experiments show](auth0_experiments_show.md) - Show an experiment +- [auth0 experiments start](auth0_experiments_start.md) - Start an experiment +- [auth0 experiments update](auth0_experiments_update.md) - Update an experiment +- [auth0 experiments validate](auth0_experiments_validate.md) - Validate an experiment + + diff --git a/docs/auth0_experiments_validate.md b/docs/auth0_experiments_validate.md new file mode 100644 index 000000000..b72a2c33b --- /dev/null +++ b/docs/auth0_experiments_validate.md @@ -0,0 +1,55 @@ +--- +layout: default +parent: auth0 experiments +has_toc: false +--- +# auth0 experiments validate + +Check whether an experiment is ready to be activated. Returns validation status and any blocking errors. + +## Usage +``` +auth0 experiments validate [flags] +``` + +## Examples + +``` + auth0 experiments validate + auth0 experiments validate + auth0 experiments validate --json +``` + + +## Flags + +``` + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 experiments archive](auth0_experiments_archive.md) - Archive an experiment +- [auth0 experiments complete](auth0_experiments_complete.md) - Complete an experiment +- [auth0 experiments create](auth0_experiments_create.md) - Create a new experiment +- [auth0 experiments delete](auth0_experiments_delete.md) - Delete an experiment +- [auth0 experiments list](auth0_experiments_list.md) - List your experiments +- [auth0 experiments pause](auth0_experiments_pause.md) - Pause an experiment +- [auth0 experiments show](auth0_experiments_show.md) - Show an experiment +- [auth0 experiments start](auth0_experiments_start.md) - Start an experiment +- [auth0 experiments update](auth0_experiments_update.md) - Update an experiment +- [auth0 experiments validate](auth0_experiments_validate.md) - Validate an experiment + + diff --git a/docs/auth0_feature-flags.md b/docs/auth0_feature-flags.md new file mode 100644 index 000000000..3a275a75e --- /dev/null +++ b/docs/auth0_feature-flags.md @@ -0,0 +1,20 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 feature-flags + +Feature flags define named parameters (string, boolean, number) that experiments vary across user groups. + +## Commands + +- [auth0 feature-flags activate](auth0_feature-flags_activate.md) - Activate a feature flag +- [auth0 feature-flags archive](auth0_feature-flags_archive.md) - Archive a feature flag +- [auth0 feature-flags create](auth0_feature-flags_create.md) - Create a new feature flag +- [auth0 feature-flags delete](auth0_feature-flags_delete.md) - Delete a feature flag +- [auth0 feature-flags list](auth0_feature-flags_list.md) - List your feature flags +- [auth0 feature-flags show](auth0_feature-flags_show.md) - Show a feature flag +- [auth0 feature-flags update](auth0_feature-flags_update.md) - Update a feature flag +- [auth0 feature-flags variations](auth0_feature-flags_variations.md) - Manage variations of a feature flag + diff --git a/docs/auth0_feature-flags_activate.md b/docs/auth0_feature-flags_activate.md new file mode 100644 index 000000000..d5d6379b7 --- /dev/null +++ b/docs/auth0_feature-flags_activate.md @@ -0,0 +1,46 @@ +--- +layout: default +parent: auth0 feature-flags +has_toc: false +--- +# auth0 feature-flags activate + +Transition a feature flag from draft to active status. + +## Usage +``` +auth0 feature-flags activate [flags] +``` + +## Examples + +``` + auth0 feature-flags activate + auth0 feature-flags activate +``` + + + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags activate](auth0_feature-flags_activate.md) - Activate a feature flag +- [auth0 feature-flags archive](auth0_feature-flags_archive.md) - Archive a feature flag +- [auth0 feature-flags create](auth0_feature-flags_create.md) - Create a new feature flag +- [auth0 feature-flags delete](auth0_feature-flags_delete.md) - Delete a feature flag +- [auth0 feature-flags list](auth0_feature-flags_list.md) - List your feature flags +- [auth0 feature-flags show](auth0_feature-flags_show.md) - Show a feature flag +- [auth0 feature-flags update](auth0_feature-flags_update.md) - Update a feature flag +- [auth0 feature-flags variations](auth0_feature-flags_variations.md) - Manage variations of a feature flag + + diff --git a/docs/auth0_feature-flags_archive.md b/docs/auth0_feature-flags_archive.md new file mode 100644 index 000000000..86bee3332 --- /dev/null +++ b/docs/auth0_feature-flags_archive.md @@ -0,0 +1,51 @@ +--- +layout: default +parent: auth0 feature-flags +has_toc: false +--- +# auth0 feature-flags archive + +Transition a feature flag to archived status. + +## Usage +``` +auth0 feature-flags archive [flags] +``` + +## Examples + +``` + auth0 feature-flags archive + auth0 feature-flags archive +``` + + +## Flags + +``` + --force Skip confirmation. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags activate](auth0_feature-flags_activate.md) - Activate a feature flag +- [auth0 feature-flags archive](auth0_feature-flags_archive.md) - Archive a feature flag +- [auth0 feature-flags create](auth0_feature-flags_create.md) - Create a new feature flag +- [auth0 feature-flags delete](auth0_feature-flags_delete.md) - Delete a feature flag +- [auth0 feature-flags list](auth0_feature-flags_list.md) - List your feature flags +- [auth0 feature-flags show](auth0_feature-flags_show.md) - Show a feature flag +- [auth0 feature-flags update](auth0_feature-flags_update.md) - Update a feature flag +- [auth0 feature-flags variations](auth0_feature-flags_variations.md) - Manage variations of a feature flag + + diff --git a/docs/auth0_feature-flags_create.md b/docs/auth0_feature-flags_create.md new file mode 100644 index 000000000..dbd668f6a --- /dev/null +++ b/docs/auth0_feature-flags_create.md @@ -0,0 +1,60 @@ +--- +layout: default +parent: auth0 feature-flags +has_toc: false +--- +# auth0 feature-flags create + +Create a new feature flag. + +To create interactively, use `auth0 feature-flags create` with no flags. + +To create non-interactively, supply name and parameters through the flags. + +## Usage +``` +auth0 feature-flags create [flags] +``` + +## Examples + +``` + auth0 feature-flags create + auth0 feature-flags create --name "dark-mode" --parameters '{"enabled":{"type":"boolean","value":false}}' + auth0 feature-flags create -n "checkout-flow" -p '{"variant":{"type":"string","value":"control"}}' +``` + + +## Flags + +``` + -d, --description string Description of the feature flag. + --json Output in json format. + --json-compact Output in compact json format. + -n, --name string Name of the feature flag. + -p, --parameters string Parameters schema as JSON. Example: '{"color":{"type":"string","value":"blue"}}' +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags activate](auth0_feature-flags_activate.md) - Activate a feature flag +- [auth0 feature-flags archive](auth0_feature-flags_archive.md) - Archive a feature flag +- [auth0 feature-flags create](auth0_feature-flags_create.md) - Create a new feature flag +- [auth0 feature-flags delete](auth0_feature-flags_delete.md) - Delete a feature flag +- [auth0 feature-flags list](auth0_feature-flags_list.md) - List your feature flags +- [auth0 feature-flags show](auth0_feature-flags_show.md) - Show a feature flag +- [auth0 feature-flags update](auth0_feature-flags_update.md) - Update a feature flag +- [auth0 feature-flags variations](auth0_feature-flags_variations.md) - Manage variations of a feature flag + + diff --git a/docs/auth0_feature-flags_delete.md b/docs/auth0_feature-flags_delete.md new file mode 100644 index 000000000..2ee67c666 --- /dev/null +++ b/docs/auth0_feature-flags_delete.md @@ -0,0 +1,56 @@ +--- +layout: default +parent: auth0 feature-flags +has_toc: false +--- +# auth0 feature-flags delete + +Delete a feature flag. + +To delete interactively, use `auth0 feature-flags delete` with no arguments. + +To delete non-interactively, supply the feature flag ID and use `--force` to skip confirmation. + +## Usage +``` +auth0 feature-flags delete [flags] +``` + +## Examples + +``` + auth0 feature-flags delete + auth0 feature-flags delete + auth0 feature-flags delete --force +``` + + +## Flags + +``` + --force Skip confirmation. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags activate](auth0_feature-flags_activate.md) - Activate a feature flag +- [auth0 feature-flags archive](auth0_feature-flags_archive.md) - Archive a feature flag +- [auth0 feature-flags create](auth0_feature-flags_create.md) - Create a new feature flag +- [auth0 feature-flags delete](auth0_feature-flags_delete.md) - Delete a feature flag +- [auth0 feature-flags list](auth0_feature-flags_list.md) - List your feature flags +- [auth0 feature-flags show](auth0_feature-flags_show.md) - Show a feature flag +- [auth0 feature-flags update](auth0_feature-flags_update.md) - Update a feature flag +- [auth0 feature-flags variations](auth0_feature-flags_variations.md) - Manage variations of a feature flag + + diff --git a/docs/auth0_feature-flags_list.md b/docs/auth0_feature-flags_list.md new file mode 100644 index 000000000..ea2a77177 --- /dev/null +++ b/docs/auth0_feature-flags_list.md @@ -0,0 +1,57 @@ +--- +layout: default +parent: auth0 feature-flags +has_toc: false +--- +# auth0 feature-flags list + +List all feature flags. To create one, run: `auth0 feature-flags create`. + +## Usage +``` +auth0 feature-flags list [flags] +``` + +## Examples + +``` + auth0 feature-flags list + auth0 feature-flags ls + auth0 feature-flags list --json + auth0 feature-flags list --status active +``` + + +## Flags + +``` + --csv Output in csv format. + --json Output in json format. + --json-compact Output in compact json format. + --status string Filter by status (draft, active, archived). + --type string Filter by type (auth0, self). +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags activate](auth0_feature-flags_activate.md) - Activate a feature flag +- [auth0 feature-flags archive](auth0_feature-flags_archive.md) - Archive a feature flag +- [auth0 feature-flags create](auth0_feature-flags_create.md) - Create a new feature flag +- [auth0 feature-flags delete](auth0_feature-flags_delete.md) - Delete a feature flag +- [auth0 feature-flags list](auth0_feature-flags_list.md) - List your feature flags +- [auth0 feature-flags show](auth0_feature-flags_show.md) - Show a feature flag +- [auth0 feature-flags update](auth0_feature-flags_update.md) - Update a feature flag +- [auth0 feature-flags variations](auth0_feature-flags_variations.md) - Manage variations of a feature flag + + diff --git a/docs/auth0_feature-flags_show.md b/docs/auth0_feature-flags_show.md new file mode 100644 index 000000000..f75f2c3ec --- /dev/null +++ b/docs/auth0_feature-flags_show.md @@ -0,0 +1,53 @@ +--- +layout: default +parent: auth0 feature-flags +has_toc: false +--- +# auth0 feature-flags show + +Display details about a feature flag including its parameters. + +## Usage +``` +auth0 feature-flags show [flags] +``` + +## Examples + +``` + auth0 feature-flags show + auth0 feature-flags show + auth0 feature-flags show --json +``` + + +## Flags + +``` + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags activate](auth0_feature-flags_activate.md) - Activate a feature flag +- [auth0 feature-flags archive](auth0_feature-flags_archive.md) - Archive a feature flag +- [auth0 feature-flags create](auth0_feature-flags_create.md) - Create a new feature flag +- [auth0 feature-flags delete](auth0_feature-flags_delete.md) - Delete a feature flag +- [auth0 feature-flags list](auth0_feature-flags_list.md) - List your feature flags +- [auth0 feature-flags show](auth0_feature-flags_show.md) - Show a feature flag +- [auth0 feature-flags update](auth0_feature-flags_update.md) - Update a feature flag +- [auth0 feature-flags variations](auth0_feature-flags_variations.md) - Manage variations of a feature flag + + diff --git a/docs/auth0_feature-flags_update.md b/docs/auth0_feature-flags_update.md new file mode 100644 index 000000000..ff5a700fc --- /dev/null +++ b/docs/auth0_feature-flags_update.md @@ -0,0 +1,61 @@ +--- +layout: default +parent: auth0 feature-flags +has_toc: false +--- +# auth0 feature-flags update + +Update a feature flag. + +To update interactively, use `auth0 feature-flags update` with no arguments. + +To update non-interactively, supply the feature flag ID and fields to change through the flags. + +## Usage +``` +auth0 feature-flags update [flags] +``` + +## Examples + +``` + auth0 feature-flags update + auth0 feature-flags update + auth0 feature-flags update --name "new-name" + auth0 feature-flags update --parameters '{"enabled":{"type":"boolean","value":true}}' +``` + + +## Flags + +``` + -d, --description string Description of the feature flag. + --json Output in json format. + --json-compact Output in compact json format. + -n, --name string Name of the feature flag. + -p, --parameters string Parameters schema as JSON. Example: '{"color":{"type":"string","value":"blue"}}' +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags activate](auth0_feature-flags_activate.md) - Activate a feature flag +- [auth0 feature-flags archive](auth0_feature-flags_archive.md) - Archive a feature flag +- [auth0 feature-flags create](auth0_feature-flags_create.md) - Create a new feature flag +- [auth0 feature-flags delete](auth0_feature-flags_delete.md) - Delete a feature flag +- [auth0 feature-flags list](auth0_feature-flags_list.md) - List your feature flags +- [auth0 feature-flags show](auth0_feature-flags_show.md) - Show a feature flag +- [auth0 feature-flags update](auth0_feature-flags_update.md) - Update a feature flag +- [auth0 feature-flags variations](auth0_feature-flags_variations.md) - Manage variations of a feature flag + + diff --git a/docs/auth0_feature-flags_variations.md b/docs/auth0_feature-flags_variations.md new file mode 100644 index 000000000..aa5cb9f3a --- /dev/null +++ b/docs/auth0_feature-flags_variations.md @@ -0,0 +1,17 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 feature-flags variations + +Variations define the different parameter overrides for a feature flag (e.g. control vs treatment arms). + +## Commands + +- [auth0 feature-flags variations create](auth0_feature-flags_variations_create.md) - Create a new variation +- [auth0 feature-flags variations delete](auth0_feature-flags_variations_delete.md) - Delete a variation +- [auth0 feature-flags variations list](auth0_feature-flags_variations_list.md) - List variations of a feature flag +- [auth0 feature-flags variations show](auth0_feature-flags_variations_show.md) - Show a variation +- [auth0 feature-flags variations update](auth0_feature-flags_variations_update.md) - Update a variation + diff --git a/docs/auth0_feature-flags_variations_create.md b/docs/auth0_feature-flags_variations_create.md new file mode 100644 index 000000000..008b54baa --- /dev/null +++ b/docs/auth0_feature-flags_variations_create.md @@ -0,0 +1,57 @@ +--- +layout: default +parent: auth0 feature-flags variations +has_toc: false +--- +# auth0 feature-flags variations create + +Create a new variation for a feature flag. + +To create interactively, use `auth0 feature-flags variations create` with no flags. + +To create non-interactively, supply the feature flag ID, name, and overrides through the flags. + +## Usage +``` +auth0 feature-flags variations create [flags] +``` + +## Examples + +``` + auth0 feature-flags variations create + auth0 feature-flags variations create + auth0 feature-flags variations create --name "treatment" --overrides '{"color":{"value":"red"}}' +``` + + +## Flags + +``` + -d, --description string Description of the variation. + --json Output in json format. + --json-compact Output in compact json format. + -n, --name string Name of the variation. + -o, --overrides string Parameter overrides as JSON. Example: '{"color":{"value":"red"}}' +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags variations create](auth0_feature-flags_variations_create.md) - Create a new variation +- [auth0 feature-flags variations delete](auth0_feature-flags_variations_delete.md) - Delete a variation +- [auth0 feature-flags variations list](auth0_feature-flags_variations_list.md) - List variations of a feature flag +- [auth0 feature-flags variations show](auth0_feature-flags_variations_show.md) - Show a variation +- [auth0 feature-flags variations update](auth0_feature-flags_variations_update.md) - Update a variation + + diff --git a/docs/auth0_feature-flags_variations_delete.md b/docs/auth0_feature-flags_variations_delete.md new file mode 100644 index 000000000..4b1b19a09 --- /dev/null +++ b/docs/auth0_feature-flags_variations_delete.md @@ -0,0 +1,51 @@ +--- +layout: default +parent: auth0 feature-flags variations +has_toc: false +--- +# auth0 feature-flags variations delete + +Delete a variation. + +To delete interactively, use `auth0 feature-flags variations delete` with no arguments. + +## Usage +``` +auth0 feature-flags variations delete [flags] +``` + +## Examples + +``` + auth0 feature-flags variations delete + auth0 feature-flags variations delete + auth0 feature-flags variations delete --force +``` + + +## Flags + +``` + --force Skip confirmation. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags variations create](auth0_feature-flags_variations_create.md) - Create a new variation +- [auth0 feature-flags variations delete](auth0_feature-flags_variations_delete.md) - Delete a variation +- [auth0 feature-flags variations list](auth0_feature-flags_variations_list.md) - List variations of a feature flag +- [auth0 feature-flags variations show](auth0_feature-flags_variations_show.md) - Show a variation +- [auth0 feature-flags variations update](auth0_feature-flags_variations_update.md) - Update a variation + + diff --git a/docs/auth0_feature-flags_variations_list.md b/docs/auth0_feature-flags_variations_list.md new file mode 100644 index 000000000..b7e54de34 --- /dev/null +++ b/docs/auth0_feature-flags_variations_list.md @@ -0,0 +1,51 @@ +--- +layout: default +parent: auth0 feature-flags variations +has_toc: false +--- +# auth0 feature-flags variations list + +List all variations for a given feature flag. + +## Usage +``` +auth0 feature-flags variations list [flags] +``` + +## Examples + +``` + auth0 feature-flags variations list + auth0 feature-flags variations list + auth0 feature-flags variations list --json +``` + + +## Flags + +``` + --csv Output in csv format. + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags variations create](auth0_feature-flags_variations_create.md) - Create a new variation +- [auth0 feature-flags variations delete](auth0_feature-flags_variations_delete.md) - Delete a variation +- [auth0 feature-flags variations list](auth0_feature-flags_variations_list.md) - List variations of a feature flag +- [auth0 feature-flags variations show](auth0_feature-flags_variations_show.md) - Show a variation +- [auth0 feature-flags variations update](auth0_feature-flags_variations_update.md) - Update a variation + + diff --git a/docs/auth0_feature-flags_variations_show.md b/docs/auth0_feature-flags_variations_show.md new file mode 100644 index 000000000..d6ccaa7f3 --- /dev/null +++ b/docs/auth0_feature-flags_variations_show.md @@ -0,0 +1,50 @@ +--- +layout: default +parent: auth0 feature-flags variations +has_toc: false +--- +# auth0 feature-flags variations show + +Display details about a specific variation. + +## Usage +``` +auth0 feature-flags variations show [flags] +``` + +## Examples + +``` + auth0 feature-flags variations show + auth0 feature-flags variations show + auth0 feature-flags variations show --json +``` + + +## Flags + +``` + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags variations create](auth0_feature-flags_variations_create.md) - Create a new variation +- [auth0 feature-flags variations delete](auth0_feature-flags_variations_delete.md) - Delete a variation +- [auth0 feature-flags variations list](auth0_feature-flags_variations_list.md) - List variations of a feature flag +- [auth0 feature-flags variations show](auth0_feature-flags_variations_show.md) - Show a variation +- [auth0 feature-flags variations update](auth0_feature-flags_variations_update.md) - Update a variation + + diff --git a/docs/auth0_feature-flags_variations_update.md b/docs/auth0_feature-flags_variations_update.md new file mode 100644 index 000000000..db7e25ed3 --- /dev/null +++ b/docs/auth0_feature-flags_variations_update.md @@ -0,0 +1,57 @@ +--- +layout: default +parent: auth0 feature-flags variations +has_toc: false +--- +# auth0 feature-flags variations update + +Update a variation. + +To update interactively, use `auth0 feature-flags variations update` with no arguments. + +To update non-interactively, supply the IDs and fields to change through the flags. + +## Usage +``` +auth0 feature-flags variations update [flags] +``` + +## Examples + +``` + auth0 feature-flags variations update + auth0 feature-flags variations update + auth0 feature-flags variations update --name "new-name" +``` + + +## Flags + +``` + -d, --description string Description of the variation. + --json Output in json format. + --json-compact Output in compact json format. + -n, --name string Name of the variation. + -o, --overrides string Parameter overrides as JSON. Example: '{"color":{"value":"red"}}' +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 feature-flags variations create](auth0_feature-flags_variations_create.md) - Create a new variation +- [auth0 feature-flags variations delete](auth0_feature-flags_variations_delete.md) - Delete a variation +- [auth0 feature-flags variations list](auth0_feature-flags_variations_list.md) - List variations of a feature flag +- [auth0 feature-flags variations show](auth0_feature-flags_variations_show.md) - Show a variation +- [auth0 feature-flags variations update](auth0_feature-flags_variations_update.md) - Update a variation + + diff --git a/docs/auth0_segments.md b/docs/auth0_segments.md new file mode 100644 index 000000000..d996f7deb --- /dev/null +++ b/docs/auth0_segments.md @@ -0,0 +1,17 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 segments + +Segments define groups of users matched by rules (email domain, attribute presence, etc.) for use in experiments. + +## Commands + +- [auth0 segments create](auth0_segments_create.md) - Create a new segment +- [auth0 segments delete](auth0_segments_delete.md) - Delete a segment +- [auth0 segments list](auth0_segments_list.md) - List your segments +- [auth0 segments show](auth0_segments_show.md) - Show a segment +- [auth0 segments update](auth0_segments_update.md) - Update a segment + diff --git a/docs/auth0_segments_create.md b/docs/auth0_segments_create.md new file mode 100644 index 000000000..9a18b947b --- /dev/null +++ b/docs/auth0_segments_create.md @@ -0,0 +1,57 @@ +--- +layout: default +parent: auth0 segments +has_toc: false +--- +# auth0 segments create + +Create a new segment. + +To create interactively, use `auth0 segments create` with no flags. + +To create non-interactively, supply name and rules through the flags. + +## Usage +``` +auth0 segments create [flags] +``` + +## Examples + +``` + auth0 segments create + auth0 segments create --name "Beta Users" --rules '[{"match":{"contains":["@beta.example.com"]}}]' + auth0 segments create -n "Internal" -r '[{"match":{"ends_with":["@mycompany.com"]}}]' +``` + + +## Flags + +``` + -d, --description string Description of the segment. + --json Output in json format. + --json-compact Output in compact json format. + -n, --name string Name of the segment. + -r, --rules string Rules for matching users. JSON array. Example: '[{"match":{"contains":["@example.com"]}}]' +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 segments create](auth0_segments_create.md) - Create a new segment +- [auth0 segments delete](auth0_segments_delete.md) - Delete a segment +- [auth0 segments list](auth0_segments_list.md) - List your segments +- [auth0 segments show](auth0_segments_show.md) - Show a segment +- [auth0 segments update](auth0_segments_update.md) - Update a segment + + diff --git a/docs/auth0_segments_delete.md b/docs/auth0_segments_delete.md new file mode 100644 index 000000000..f5b17102c --- /dev/null +++ b/docs/auth0_segments_delete.md @@ -0,0 +1,55 @@ +--- +layout: default +parent: auth0 segments +has_toc: false +--- +# auth0 segments delete + +Delete a segment. + +To delete interactively, use `auth0 segments delete` with no arguments. + +To delete non-interactively, supply the segment ID and use `--force` to skip confirmation. + +## Usage +``` +auth0 segments delete [flags] +``` + +## Examples + +``` + auth0 segments delete + auth0 segments rm + auth0 segments delete + auth0 segments delete --force + auth0 segments delete --force +``` + + +## Flags + +``` + --force Skip confirmation. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 segments create](auth0_segments_create.md) - Create a new segment +- [auth0 segments delete](auth0_segments_delete.md) - Delete a segment +- [auth0 segments list](auth0_segments_list.md) - List your segments +- [auth0 segments show](auth0_segments_show.md) - Show a segment +- [auth0 segments update](auth0_segments_update.md) - Update a segment + + diff --git a/docs/auth0_segments_list.md b/docs/auth0_segments_list.md new file mode 100644 index 000000000..db6d946eb --- /dev/null +++ b/docs/auth0_segments_list.md @@ -0,0 +1,52 @@ +--- +layout: default +parent: auth0 segments +has_toc: false +--- +# auth0 segments list + +List all segments. To create one, run: `auth0 segments create`. + +## Usage +``` +auth0 segments list [flags] +``` + +## Examples + +``` + auth0 segments list + auth0 segments ls + auth0 segments list --json + auth0 segments list --csv +``` + + +## Flags + +``` + --csv Output in csv format. + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 segments create](auth0_segments_create.md) - Create a new segment +- [auth0 segments delete](auth0_segments_delete.md) - Delete a segment +- [auth0 segments list](auth0_segments_list.md) - List your segments +- [auth0 segments show](auth0_segments_show.md) - Show a segment +- [auth0 segments update](auth0_segments_update.md) - Update a segment + + diff --git a/docs/auth0_segments_show.md b/docs/auth0_segments_show.md new file mode 100644 index 000000000..aed15efa5 --- /dev/null +++ b/docs/auth0_segments_show.md @@ -0,0 +1,50 @@ +--- +layout: default +parent: auth0 segments +has_toc: false +--- +# auth0 segments show + +Display details about a segment including its rules. + +## Usage +``` +auth0 segments show [flags] +``` + +## Examples + +``` + auth0 segments show + auth0 segments show + auth0 segments show --json +``` + + +## Flags + +``` + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 segments create](auth0_segments_create.md) - Create a new segment +- [auth0 segments delete](auth0_segments_delete.md) - Delete a segment +- [auth0 segments list](auth0_segments_list.md) - List your segments +- [auth0 segments show](auth0_segments_show.md) - Show a segment +- [auth0 segments update](auth0_segments_update.md) - Update a segment + + diff --git a/docs/auth0_segments_update.md b/docs/auth0_segments_update.md new file mode 100644 index 000000000..31abd83cf --- /dev/null +++ b/docs/auth0_segments_update.md @@ -0,0 +1,58 @@ +--- +layout: default +parent: auth0 segments +has_toc: false +--- +# auth0 segments update + +Update a segment. + +To update interactively, use `auth0 segments update` with no arguments. + +To update non-interactively, supply the segment ID and fields to change through the flags. + +## Usage +``` +auth0 segments update [flags] +``` + +## Examples + +``` + auth0 segments update + auth0 segments update + auth0 segments update --name "New Name" + auth0 segments update --rules '[{"match":{"contains":["@newdomain.com"]}}]' +``` + + +## Flags + +``` + -d, --description string Description of the segment. + --json Output in json format. + --json-compact Output in compact json format. + -n, --name string Name of the segment. + -r, --rules string Rules for matching users. JSON array. Example: '[{"match":{"contains":["@example.com"]}}]' +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 segments create](auth0_segments_create.md) - Create a new segment +- [auth0 segments delete](auth0_segments_delete.md) - Delete a segment +- [auth0 segments list](auth0_segments_list.md) - List your segments +- [auth0 segments show](auth0_segments_show.md) - Show a segment +- [auth0 segments update](auth0_segments_update.md) - Update a segment + + diff --git a/docs/index.md b/docs/index.md index 0454ce86b..c37424e98 100644 --- a/docs/index.md +++ b/docs/index.md @@ -88,6 +88,8 @@ Authenticating as a user is not supported for **private cloud** tenants. Instead - [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 experiments](auth0_experiments.md) - Manage experimentation experiments +- [auth0 feature-flags](auth0_feature-flags.md) - Manage experimentation feature flags - [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 @@ -98,6 +100,7 @@ Authenticating as a user is not supported for **private cloud** tenants. Instead - [auth0 quickstarts](auth0_quickstarts.md) - Quickstart support for getting bootstrapped - [auth0 roles](auth0_roles.md) - Manage resources for roles - [auth0 rules](auth0_rules.md) - Manage resources for rules +- [auth0 segments](auth0_segments.md) - Manage experimentation segments - [auth0 tenant-settings](auth0_tenant-settings.md) - Manage tenant settings - [auth0 tenants](auth0_tenants.md) - Manage configured tenants - [auth0 terraform](auth0_terraform.md) - Manage terraform configuration for your Auth0 Tenant diff --git a/go.mod b/go.mod index 962b56fde..b75c85105 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/PuerkitoBio/rehttp v1.4.0 github.com/atotto/clipboard v0.1.4 github.com/auth0/go-auth0 v1.43.0 - github.com/auth0/go-auth0/v2 v2.13.0 + github.com/auth0/go-auth0/v2 v2.14.0-beta.0 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/glamour v1.0.0 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e diff --git a/go.sum b/go.sum index b6c15577e..89b3e6d61 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/auth0/go-auth0 v1.43.0 h1:sbtJHqukY1esWqlvyRpwIZ/If1m4h8e24TW2UztMXtA= github.com/auth0/go-auth0 v1.43.0/go.mod h1:32sQB1uAn+99fJo6N819EniKq8h785p0ag0lMWhiTaE= -github.com/auth0/go-auth0/v2 v2.13.0 h1:Lf1cPRypkb879mHin1GlGS6NtWkO47Efo0Bq2HIMez4= -github.com/auth0/go-auth0/v2 v2.13.0/go.mod h1:Q/Y3VZVoI3sw87VyTPhx2TQL6Sq4Q/iCP67rW2gcn+M= +github.com/auth0/go-auth0/v2 v2.14.0-beta.0 h1:6DGgI/1z3KLxW8YbR4bQfz7Oq/Wne85XkaskGGw/ntA= +github.com/auth0/go-auth0/v2 v2.14.0-beta.0/go.mod h1:Q/Y3VZVoI3sw87VyTPhx2TQL6Sq4Q/iCP67rW2gcn+M= github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0 h1:0NmehRCgyk5rljDQLKUO+cRJCnduDyn11+zGZIc9Z48= github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0/go.mod h1:6L7zgvqo0idzI7IO8de6ZC051AfXb5ipkIJ7bIA2tGA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index 7e298dce1..9f4694a43 100644 --- a/internal/auth0/auth0.go +++ b/internal/auth0/auth0.go @@ -81,6 +81,10 @@ type APIV2 struct { AttackProtectionBotDetection AttackProtectionBotDetectionAPIV2 Events EventsAPIV2 PhoneNotificationTemplate PhoneNotificationTemplateAPI + Experiments ExperimentsAPI + FeatureFlags FeatureFlagsAPI + Variations VariationsAPI + Segments SegmentsAPI } func NewAPIV2(m *managementv2.Management) *APIV2 { @@ -88,6 +92,10 @@ func NewAPIV2(m *managementv2.Management) *APIV2 { AttackProtectionBotDetection: m.AttackProtection.BotDetection, Events: m.Events, PhoneNotificationTemplate: m.Branding.Phone.Templates, + Experiments: m.Experimentation.Experiments, + FeatureFlags: m.Experimentation.FeatureFlags, + Variations: m.Experimentation.FeatureFlags.Variations, + Segments: m.Experimentation.Segments, } } diff --git a/internal/auth0/experiments.go b/internal/auth0/experiments.go new file mode 100644 index 000000000..2d404e855 --- /dev/null +++ b/internal/auth0/experiments.go @@ -0,0 +1,22 @@ +//go:generate mockgen -source=experiments.go -destination=mock/experiments_mock.go -package=mock + +package auth0 + +import ( + "context" + + management "github.com/auth0/go-auth0/v2/management" + managementcore "github.com/auth0/go-auth0/v2/management/core" + managementoption "github.com/auth0/go-auth0/v2/management/option" +) + +// ExperimentsAPI describes the interface for experiment operations. +type ExperimentsAPI interface { + List(ctx context.Context, request *management.ListExperimentsRequestParameters, opts ...managementoption.RequestOption) (*managementcore.Page[*string, *management.ExperimentListItem, *management.ListExperimentsResponseContent], error) + Create(ctx context.Context, request *management.CreateExperimentRequestContent, opts ...managementoption.RequestOption) (*management.CreateExperimentResponseContent, error) + Get(ctx context.Context, id string, opts ...managementoption.RequestOption) (*management.GetExperimentResponseContent, error) + Update(ctx context.Context, id string, request *management.UpdateExperimentRequestParameters, opts ...managementoption.RequestOption) (*management.UpdateExperimentResponseContent, error) + Delete(ctx context.Context, id string, opts ...managementoption.RequestOption) error + UpdateStatus(ctx context.Context, id string, request *management.UpdateExperimentStatusRequestContent, opts ...managementoption.RequestOption) (*management.UpdateExperimentStatusResponseContent, error) + Validate(ctx context.Context, id string, opts ...managementoption.RequestOption) (*management.ValidateExperimentResponseContent, error) +} diff --git a/internal/auth0/feature_flags.go b/internal/auth0/feature_flags.go new file mode 100644 index 000000000..c134bd985 --- /dev/null +++ b/internal/auth0/feature_flags.go @@ -0,0 +1,30 @@ +//go:generate mockgen -source=feature_flags.go -destination=mock/feature_flags_mock.go -package=mock + +package auth0 + +import ( + "context" + + management "github.com/auth0/go-auth0/v2/management" + managementcore "github.com/auth0/go-auth0/v2/management/core" + managementoption "github.com/auth0/go-auth0/v2/management/option" +) + +// FeatureFlagsAPI describes the interface for feature flag operations. +type FeatureFlagsAPI interface { + List(ctx context.Context, request *management.ListFeatureFlagsRequestParameters, opts ...managementoption.RequestOption) (*managementcore.Page[*string, *management.FeatureFlag, *management.ListFeatureFlagsResponseContent], error) + Create(ctx context.Context, request *management.CreateFeatureFlagRequestContent, opts ...managementoption.RequestOption) (*management.CreateFeatureFlagResponseContent, error) + Get(ctx context.Context, id string, opts ...managementoption.RequestOption) (*management.GetFeatureFlagResponseContent, error) + Update(ctx context.Context, id string, request *management.UpdateFeatureFlagRequestContent, opts ...managementoption.RequestOption) (*management.UpdateFeatureFlagResponseContent, error) + Delete(ctx context.Context, id string, opts ...managementoption.RequestOption) error + UpdateStatus(ctx context.Context, id string, request *management.UpdateFeatureFlagStatusRequestContent, opts ...managementoption.RequestOption) (*management.UpdateFeatureFlagStatusResponseContent, error) +} + +// VariationsAPI describes the interface for variation operations (nested under feature flags). +type VariationsAPI interface { + List(ctx context.Context, featureFlagID string, opts ...managementoption.RequestOption) (*management.ListVariationsResponseContent, error) + Create(ctx context.Context, featureFlagID string, request *management.CreateVariationRequestContent, opts ...managementoption.RequestOption) (*management.CreateVariationResponseContent, error) + Get(ctx context.Context, featureFlagID string, variationID string, opts ...managementoption.RequestOption) (*management.GetVariationResponseContent, error) + Update(ctx context.Context, featureFlagID string, variationID string, request *management.UpdateVariationRequestContent, opts ...managementoption.RequestOption) (*management.UpdateVariationResponseContent, error) + Delete(ctx context.Context, featureFlagID string, variationID string, opts ...managementoption.RequestOption) error +} diff --git a/internal/auth0/mock/experiments_mock.go b/internal/auth0/mock/experiments_mock.go new file mode 100644 index 000000000..ecf8ad077 --- /dev/null +++ b/internal/auth0/mock/experiments_mock.go @@ -0,0 +1,177 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: experiments.go + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + management "github.com/auth0/go-auth0/v2/management" + managementcore "github.com/auth0/go-auth0/v2/management/core" + managementoption "github.com/auth0/go-auth0/v2/management/option" + gomock "github.com/golang/mock/gomock" +) + +// MockExperimentsAPI is a mock of ExperimentsAPI interface. +type MockExperimentsAPI struct { + ctrl *gomock.Controller + recorder *MockExperimentsAPIMockRecorder +} + +// MockExperimentsAPIMockRecorder is the mock recorder for MockExperimentsAPI. +type MockExperimentsAPIMockRecorder struct { + mock *MockExperimentsAPI +} + +// NewMockExperimentsAPI creates a new mock instance. +func NewMockExperimentsAPI(ctrl *gomock.Controller) *MockExperimentsAPI { + mock := &MockExperimentsAPI{ctrl: ctrl} + mock.recorder = &MockExperimentsAPIMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockExperimentsAPI) EXPECT() *MockExperimentsAPIMockRecorder { + return m.recorder +} + +// List mocks base method. +func (m *MockExperimentsAPI) List(ctx context.Context, request *management.ListExperimentsRequestParameters, opts ...managementoption.RequestOption) (*managementcore.Page[*string, *management.ExperimentListItem, *management.ListExperimentsResponseContent], error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*managementcore.Page[*string, *management.ExperimentListItem, *management.ListExperimentsResponseContent]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockExperimentsAPIMockRecorder) List(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockExperimentsAPI)(nil).List), varargs...) +} + +// Create mocks base method. +func (m *MockExperimentsAPI) Create(ctx context.Context, request *management.CreateExperimentRequestContent, opts ...managementoption.RequestOption) (*management.CreateExperimentResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(*management.CreateExperimentResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockExperimentsAPIMockRecorder) Create(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockExperimentsAPI)(nil).Create), varargs...) +} + +// Get mocks base method. +func (m *MockExperimentsAPI) Get(ctx context.Context, id string, opts ...managementoption.RequestOption) (*management.GetExperimentResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(*management.GetExperimentResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockExperimentsAPIMockRecorder) Get(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, "Get", reflect.TypeOf((*MockExperimentsAPI)(nil).Get), varargs...) +} + +// Update mocks base method. +func (m *MockExperimentsAPI) Update(ctx context.Context, id string, request *management.UpdateExperimentRequestParameters, opts ...managementoption.RequestOption) (*management.UpdateExperimentResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(*management.UpdateExperimentResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockExperimentsAPIMockRecorder) Update(ctx, id, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockExperimentsAPI)(nil).Update), varargs...) +} + +// Delete mocks base method. +func (m *MockExperimentsAPI) Delete(ctx context.Context, id string, opts ...managementoption.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockExperimentsAPIMockRecorder) 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((*MockExperimentsAPI)(nil).Delete), varargs...) +} + +// UpdateStatus mocks base method. +func (m *MockExperimentsAPI) UpdateStatus(ctx context.Context, id string, request *management.UpdateExperimentStatusRequestContent, opts ...managementoption.RequestOption) (*management.UpdateExperimentStatusResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "UpdateStatus", varargs...) + ret0, _ := ret[0].(*management.UpdateExperimentStatusResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateStatus indicates an expected call of UpdateStatus. +func (mr *MockExperimentsAPIMockRecorder) UpdateStatus(ctx, id, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockExperimentsAPI)(nil).UpdateStatus), varargs...) +} + +// Validate mocks base method. +func (m *MockExperimentsAPI) Validate(ctx context.Context, id string, opts ...managementoption.RequestOption) (*management.ValidateExperimentResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Validate", varargs...) + ret0, _ := ret[0].(*management.ValidateExperimentResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Validate indicates an expected call of Validate. +func (mr *MockExperimentsAPIMockRecorder) Validate(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, "Validate", reflect.TypeOf((*MockExperimentsAPI)(nil).Validate), varargs...) +} diff --git a/internal/auth0/mock/feature_flags_mock.go b/internal/auth0/mock/feature_flags_mock.go new file mode 100644 index 000000000..3d84d5c40 --- /dev/null +++ b/internal/auth0/mock/feature_flags_mock.go @@ -0,0 +1,279 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: feature_flags.go + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + management "github.com/auth0/go-auth0/v2/management" + managementcore "github.com/auth0/go-auth0/v2/management/core" + managementoption "github.com/auth0/go-auth0/v2/management/option" + gomock "github.com/golang/mock/gomock" +) + +// MockFeatureFlagsAPI is a mock of FeatureFlagsAPI interface. +type MockFeatureFlagsAPI struct { + ctrl *gomock.Controller + recorder *MockFeatureFlagsAPIMockRecorder +} + +// MockFeatureFlagsAPIMockRecorder is the mock recorder for MockFeatureFlagsAPI. +type MockFeatureFlagsAPIMockRecorder struct { + mock *MockFeatureFlagsAPI +} + +// NewMockFeatureFlagsAPI creates a new mock instance. +func NewMockFeatureFlagsAPI(ctrl *gomock.Controller) *MockFeatureFlagsAPI { + mock := &MockFeatureFlagsAPI{ctrl: ctrl} + mock.recorder = &MockFeatureFlagsAPIMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFeatureFlagsAPI) EXPECT() *MockFeatureFlagsAPIMockRecorder { + return m.recorder +} + +// List mocks base method. +func (m *MockFeatureFlagsAPI) List(ctx context.Context, request *management.ListFeatureFlagsRequestParameters, opts ...managementoption.RequestOption) (*managementcore.Page[*string, *management.FeatureFlag, *management.ListFeatureFlagsResponseContent], error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*managementcore.Page[*string, *management.FeatureFlag, *management.ListFeatureFlagsResponseContent]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockFeatureFlagsAPIMockRecorder) List(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockFeatureFlagsAPI)(nil).List), varargs...) +} + +// Create mocks base method. +func (m *MockFeatureFlagsAPI) Create(ctx context.Context, request *management.CreateFeatureFlagRequestContent, opts ...managementoption.RequestOption) (*management.CreateFeatureFlagResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(*management.CreateFeatureFlagResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockFeatureFlagsAPIMockRecorder) Create(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockFeatureFlagsAPI)(nil).Create), varargs...) +} + +// Get mocks base method. +func (m *MockFeatureFlagsAPI) Get(ctx context.Context, id string, opts ...managementoption.RequestOption) (*management.GetFeatureFlagResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(*management.GetFeatureFlagResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockFeatureFlagsAPIMockRecorder) Get(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, "Get", reflect.TypeOf((*MockFeatureFlagsAPI)(nil).Get), varargs...) +} + +// Update mocks base method. +func (m *MockFeatureFlagsAPI) Update(ctx context.Context, id string, request *management.UpdateFeatureFlagRequestContent, opts ...managementoption.RequestOption) (*management.UpdateFeatureFlagResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(*management.UpdateFeatureFlagResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockFeatureFlagsAPIMockRecorder) Update(ctx, id, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockFeatureFlagsAPI)(nil).Update), varargs...) +} + +// Delete mocks base method. +func (m *MockFeatureFlagsAPI) Delete(ctx context.Context, id string, opts ...managementoption.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockFeatureFlagsAPIMockRecorder) 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((*MockFeatureFlagsAPI)(nil).Delete), varargs...) +} + +// UpdateStatus mocks base method. +func (m *MockFeatureFlagsAPI) UpdateStatus(ctx context.Context, id string, request *management.UpdateFeatureFlagStatusRequestContent, opts ...managementoption.RequestOption) (*management.UpdateFeatureFlagStatusResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "UpdateStatus", varargs...) + ret0, _ := ret[0].(*management.UpdateFeatureFlagStatusResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateStatus indicates an expected call of UpdateStatus. +func (mr *MockFeatureFlagsAPIMockRecorder) UpdateStatus(ctx, id, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockFeatureFlagsAPI)(nil).UpdateStatus), varargs...) +} + +// MockVariationsAPI is a mock of VariationsAPI interface. +type MockVariationsAPI struct { + ctrl *gomock.Controller + recorder *MockVariationsAPIMockRecorder +} + +// MockVariationsAPIMockRecorder is the mock recorder for MockVariationsAPI. +type MockVariationsAPIMockRecorder struct { + mock *MockVariationsAPI +} + +// NewMockVariationsAPI creates a new mock instance. +func NewMockVariationsAPI(ctrl *gomock.Controller) *MockVariationsAPI { + mock := &MockVariationsAPI{ctrl: ctrl} + mock.recorder = &MockVariationsAPIMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockVariationsAPI) EXPECT() *MockVariationsAPIMockRecorder { + return m.recorder +} + +// List mocks base method. +func (m *MockVariationsAPI) List(ctx context.Context, featureFlagID string, opts ...managementoption.RequestOption) (*management.ListVariationsResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, featureFlagID} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*management.ListVariationsResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockVariationsAPIMockRecorder) List(ctx, featureFlagID interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, featureFlagID}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockVariationsAPI)(nil).List), varargs...) +} + +// Create mocks base method. +func (m *MockVariationsAPI) Create(ctx context.Context, featureFlagID string, request *management.CreateVariationRequestContent, opts ...managementoption.RequestOption) (*management.CreateVariationResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, featureFlagID, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(*management.CreateVariationResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockVariationsAPIMockRecorder) Create(ctx, featureFlagID, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, featureFlagID, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockVariationsAPI)(nil).Create), varargs...) +} + +// Get mocks base method. +func (m *MockVariationsAPI) Get(ctx context.Context, featureFlagID string, variationID string, opts ...managementoption.RequestOption) (*management.GetVariationResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, featureFlagID, variationID} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(*management.GetVariationResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockVariationsAPIMockRecorder) Get(ctx, featureFlagID, variationID interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, featureFlagID, variationID}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockVariationsAPI)(nil).Get), varargs...) +} + +// Update mocks base method. +func (m *MockVariationsAPI) Update(ctx context.Context, featureFlagID string, variationID string, request *management.UpdateVariationRequestContent, opts ...managementoption.RequestOption) (*management.UpdateVariationResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, featureFlagID, variationID, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(*management.UpdateVariationResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockVariationsAPIMockRecorder) Update(ctx, featureFlagID, variationID, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, featureFlagID, variationID, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockVariationsAPI)(nil).Update), varargs...) +} + +// Delete mocks base method. +func (m *MockVariationsAPI) Delete(ctx context.Context, featureFlagID string, variationID string, opts ...managementoption.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, featureFlagID, variationID} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockVariationsAPIMockRecorder) Delete(ctx, featureFlagID, variationID interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, featureFlagID, variationID}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockVariationsAPI)(nil).Delete), varargs...) +} diff --git a/internal/auth0/mock/segments_mock.go b/internal/auth0/mock/segments_mock.go new file mode 100644 index 000000000..fd6a7f698 --- /dev/null +++ b/internal/auth0/mock/segments_mock.go @@ -0,0 +1,137 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: segments.go + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + management "github.com/auth0/go-auth0/v2/management" + managementcore "github.com/auth0/go-auth0/v2/management/core" + managementoption "github.com/auth0/go-auth0/v2/management/option" + gomock "github.com/golang/mock/gomock" +) + +// MockSegmentsAPI is a mock of SegmentsAPI interface. +type MockSegmentsAPI struct { + ctrl *gomock.Controller + recorder *MockSegmentsAPIMockRecorder +} + +// MockSegmentsAPIMockRecorder is the mock recorder for MockSegmentsAPI. +type MockSegmentsAPIMockRecorder struct { + mock *MockSegmentsAPI +} + +// NewMockSegmentsAPI creates a new mock instance. +func NewMockSegmentsAPI(ctrl *gomock.Controller) *MockSegmentsAPI { + mock := &MockSegmentsAPI{ctrl: ctrl} + mock.recorder = &MockSegmentsAPIMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSegmentsAPI) EXPECT() *MockSegmentsAPIMockRecorder { + return m.recorder +} + +// List mocks base method. +func (m *MockSegmentsAPI) List(ctx context.Context, request *management.ListSegmentsRequestParameters, opts ...managementoption.RequestOption) (*managementcore.Page[*string, *management.Segment, *management.ListSegmentsResponseContent], error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*managementcore.Page[*string, *management.Segment, *management.ListSegmentsResponseContent]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockSegmentsAPIMockRecorder) List(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockSegmentsAPI)(nil).List), varargs...) +} + +// Create mocks base method. +func (m *MockSegmentsAPI) Create(ctx context.Context, request *management.CreateSegmentRequestContent, opts ...managementoption.RequestOption) (*management.CreateSegmentResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(*management.CreateSegmentResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockSegmentsAPIMockRecorder) Create(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSegmentsAPI)(nil).Create), varargs...) +} + +// Get mocks base method. +func (m *MockSegmentsAPI) Get(ctx context.Context, id string, opts ...managementoption.RequestOption) (*management.GetSegmentResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(*management.GetSegmentResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockSegmentsAPIMockRecorder) Get(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, "Get", reflect.TypeOf((*MockSegmentsAPI)(nil).Get), varargs...) +} + +// Update mocks base method. +func (m *MockSegmentsAPI) Update(ctx context.Context, id string, request *management.UpdateSegmentRequestContent, opts ...managementoption.RequestOption) (*management.UpdateSegmentResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(*management.UpdateSegmentResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockSegmentsAPIMockRecorder) Update(ctx, id, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSegmentsAPI)(nil).Update), varargs...) +} + +// Delete mocks base method. +func (m *MockSegmentsAPI) Delete(ctx context.Context, id string, opts ...managementoption.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockSegmentsAPIMockRecorder) 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((*MockSegmentsAPI)(nil).Delete), varargs...) +} diff --git a/internal/auth0/segments.go b/internal/auth0/segments.go new file mode 100644 index 000000000..517af93cd --- /dev/null +++ b/internal/auth0/segments.go @@ -0,0 +1,20 @@ +//go:generate mockgen -source=segments.go -destination=mock/segments_mock.go -package=mock + +package auth0 + +import ( + "context" + + management "github.com/auth0/go-auth0/v2/management" + managementcore "github.com/auth0/go-auth0/v2/management/core" + managementoption "github.com/auth0/go-auth0/v2/management/option" +) + +// SegmentsAPI describes the interface for segment operations. +type SegmentsAPI interface { + List(ctx context.Context, request *management.ListSegmentsRequestParameters, opts ...managementoption.RequestOption) (*managementcore.Page[*string, *management.Segment, *management.ListSegmentsResponseContent], error) + Create(ctx context.Context, request *management.CreateSegmentRequestContent, opts ...managementoption.RequestOption) (*management.CreateSegmentResponseContent, error) + Get(ctx context.Context, id string, opts ...managementoption.RequestOption) (*management.GetSegmentResponseContent, error) + Update(ctx context.Context, id string, request *management.UpdateSegmentRequestContent, opts ...managementoption.RequestOption) (*management.UpdateSegmentResponseContent, error) + Delete(ctx context.Context, id string, opts ...managementoption.RequestOption) error +} diff --git a/internal/cli/experiments.go b/internal/cli/experiments.go new file mode 100644 index 000000000..3d324074d --- /dev/null +++ b/internal/cli/experiments.go @@ -0,0 +1,742 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + management "github.com/auth0/go-auth0/v2/management" + managementcore "github.com/auth0/go-auth0/v2/management/core" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/prompt" +) + +var ( + experimentID = Argument{ + Name: "Experiment ID", + Help: "ID of the experiment.", + } + + experimentName = Flag{ + Name: "Name", + LongForm: "name", + ShortForm: "n", + Help: "Name of the experiment.", + IsRequired: true, + } + + experimentDescription = Flag{ + Name: "Description", + LongForm: "description", + ShortForm: "d", + Help: "Description of the experiment.", + } + + experimentFeatureFlagID = Flag{ + Name: "Feature Flag ID", + LongForm: "feature-flag-id", + ShortForm: "f", + Help: "ID of the feature flag to experiment on.", + IsRequired: true, + } + + experimentAuthFlow = Flag{ + Name: "Authentication Flow", + LongForm: "authentication-flow", + ShortForm: "a", + Help: "Authentication flow this experiment applies to (e.g. login, signup).", + IsRequired: true, + } + + experimentAllocationStrategy = Flag{ + Name: "Allocation Strategy", + LongForm: "allocation-strategy", + ShortForm: "s", + Help: "Allocation strategy: percentage or segment.", + IsRequired: true, + } + + experimentAllocations = Flag{ + Name: "Allocations", + LongForm: "allocations", + ShortForm: "A", + Help: "JSON array of allocation items ({variation_id, weight, is_control} for percentage; {variation_id, segment_id, is_control} for segment).", + } + + experimentAssignmentConfig = Flag{ + Name: "Assignment Config", + LongForm: "assignment-config", + Help: `JSON object configuring how users are assigned to variations (e.g. '{"subject":"device"}').`, + } +) + +func experimentsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "experiments", + Short: "Manage experimentation experiments", + Long: "Experiments run A/B tests by tying a feature flag, its variations, and traffic allocations together.\n\n" + + "Typical workflow:\n" + + " 1. Create a feature flag and its variations\n" + + " 2. Optionally create segments for targeted allocation\n" + + " 3. Create an experiment\n" + + " 4. Validate it\n" + + " 5. Start it", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listExperimentsCmd(cli)) + cmd.AddCommand(createExperimentCmd(cli)) + cmd.AddCommand(showExperimentCmd(cli)) + cmd.AddCommand(updateExperimentCmd(cli)) + cmd.AddCommand(deleteExperimentCmd(cli)) + cmd.AddCommand(validateExperimentCmd(cli)) + cmd.AddCommand(startExperimentCmd(cli)) + cmd.AddCommand(pauseExperimentCmd(cli)) + cmd.AddCommand(completeExperimentCmd(cli)) + cmd.AddCommand(archiveExperimentCmd(cli)) + + return cmd +} + +func listExperimentsCmd(cli *cli) *cobra.Command { + var inputs struct { + Status string + FeatureFlagID string + AuthenticationFlow string + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + Short: "List your experiments", + Long: "List all experiments. To create one, run: `auth0 experiments create`.", + Example: ` auth0 experiments list + auth0 experiments ls + auth0 experiments list --json + auth0 experiments list --status active + auth0 experiments list --feature-flag-id `, + RunE: func(cmd *cobra.Command, args []string) error { + req := &management.ListExperimentsRequestParameters{} + if inputs.Status != "" { + s := management.ExperimentStatusEnum(inputs.Status) + req.Status = &s + } + if inputs.FeatureFlagID != "" { + req.FeatureFlagID = &inputs.FeatureFlagID + } + if inputs.AuthenticationFlow != "" { + req.AuthenticationFlow = &inputs.AuthenticationFlow + } + + var allExperiments []*management.ExperimentListItem + + if err := ansi.Waiting(func() error { + page, err := cli.apiv2.Experiments.List(cmd.Context(), req) + if err != nil { + return err + } + allExperiments = append(allExperiments, page.Results...) + for { + next, err := page.GetNextPage(cmd.Context()) + if errors.Is(err, managementcore.ErrNoPages) { + break + } + if err != nil { + return err + } + allExperiments = append(allExperiments, next.Results...) + page = next + } + return nil + }); err != nil { + return fmt.Errorf("failed to list experiments: %w", err) + } + + cli.renderer.ExperimentList(allExperiments) + return nil + }, + } + + 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.Flags().StringVar(&inputs.Status, "status", "", "Filter by status (draft, active, paused, completed, archived).") + cmd.Flags().StringVar(&inputs.FeatureFlagID, "feature-flag-id", "", "Filter by feature flag ID.") + cmd.Flags().StringVar(&inputs.AuthenticationFlow, "authentication-flow", "", "Filter by authentication flow.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + + return cmd +} + +func showExperimentCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(1), + Short: "Show an experiment", + Long: "Display details about an experiment including its allocations and validation status.", + Example: ` auth0 experiments show + auth0 experiments show + auth0 experiments show --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := experimentID.Pick(cmd, &inputs.ID, cli.experimentPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + var exp *management.GetExperimentResponseContent + if err := ansi.Waiting(func() (err error) { + exp, err = cli.apiv2.Experiments.Get(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to get experiment %q: %w", inputs.ID, err) + } + + cli.renderer.ExperimentShow(exp) + return nil + }, + } + + 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 createExperimentCmd(cli *cli) *cobra.Command { + var inputs struct { + Name string + Description string + FeatureFlagID string + AuthenticationFlow string + AllocationStrategy string + Allocations string + AssignmentConfig string + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.NoArgs, + Short: "Create a new experiment", + Long: "Create a new experiment.\n\n" + + "To create interactively, use `auth0 experiments create` with no flags.\n\n" + + "To create non-interactively, supply all required flags.", + Example: ` auth0 experiments create + auth0 experiments create --name "button-color" --feature-flag-id ff_abc --authentication-flow login --allocation-strategy percentage --assignment-config '{"subject":"device"}' --allocations '[{"variation_id":"vid_1","weight":0.5,"is_control":true},{"variation_id":"vid_2","weight":0.5,"is_control":false}]'`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := experimentName.Ask(cmd, &inputs.Name, nil); err != nil { + return err + } + + if err := experimentDescription.Ask(cmd, &inputs.Description, nil); err != nil { + return err + } + + // Feature flag — interactive picker if not provided via flag. + if inputs.FeatureFlagID == "" && canPrompt(cmd) { + if err := experimentFeatureFlagID.Pick(cmd, &inputs.FeatureFlagID, cli.featureFlagPickerOptions); err != nil { + return err + } + } else if inputs.FeatureFlagID == "" { + return fmt.Errorf("--feature-flag-id is required") + } + + if err := experimentAuthFlow.Ask(cmd, &inputs.AuthenticationFlow, nil); err != nil { + return err + } + + // Allocation strategy — dropdown. + if inputs.AllocationStrategy == "" && canPrompt(cmd) { + strategyOptions := []string{"percentage", "segment"} + if err := experimentAllocationStrategy.Select(cmd, &inputs.AllocationStrategy, strategyOptions, nil); err != nil { + return err + } + } else if inputs.AllocationStrategy == "" { + return fmt.Errorf("--allocation-strategy is required") + } + + // Allocations — build interactively from variation picker, or accept raw JSON. + if inputs.Allocations == "" && canPrompt(cmd) { + allocs, err := cli.buildAllocationsInteractively(cmd, inputs.FeatureFlagID, inputs.AllocationStrategy) + if err != nil { + return err + } + b, err := json.Marshal(allocs) + if err != nil { + return err + } + inputs.Allocations = string(b) + } else if inputs.Allocations == "" { + return fmt.Errorf("--allocations is required") + } + + // Assignment config — required. Prompt interactively if not provided via flag. + if inputs.AssignmentConfig == "" && canPrompt(cmd) { + subjectOptions := []string{"device"} + var chosen string + if err := experimentAssignmentConfig.Select(cmd, &chosen, subjectOptions, nil); err != nil { + return err + } + inputs.AssignmentConfig = fmt.Sprintf(`{"subject":%q}`, chosen) + } else if inputs.AssignmentConfig == "" { + return fmt.Errorf("--assignment-config is required") + } + + var ac management.AssignmentConfig + if err := json.Unmarshal([]byte(inputs.AssignmentConfig), &ac); err != nil { + return fmt.Errorf("invalid JSON for --assignment-config: %w", err) + } + + var allocations []*management.AllocationRequestItem + if err := json.Unmarshal([]byte(inputs.Allocations), &allocations); err != nil { + return fmt.Errorf("invalid JSON for --allocations (ensure the value is quoted in your shell): %w", err) + } + + strategy := management.AllocationStrategyEnum(inputs.AllocationStrategy) + req := &management.CreateExperimentRequestContent{ + Name: inputs.Name, + FeatureFlagID: inputs.FeatureFlagID, + AuthenticationFlow: inputs.AuthenticationFlow, + AllocationStrategy: strategy, + AssignmentConfig: &ac, + Allocations: allocations, + } + if inputs.Description != "" { + req.Description = &inputs.Description + } + + var result *management.CreateExperimentResponseContent + if err := ansi.Waiting(func() (err error) { + result, err = cli.apiv2.Experiments.Create(cmd.Context(), req) + return err + }); err != nil { + return fmt.Errorf("failed to create experiment: %w", err) + } + + return cli.renderer.ExperimentCreate(result) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + experimentName.RegisterString(cmd, &inputs.Name, "") + experimentDescription.RegisterString(cmd, &inputs.Description, "") + experimentFeatureFlagID.RegisterString(cmd, &inputs.FeatureFlagID, "") + experimentAuthFlow.RegisterString(cmd, &inputs.AuthenticationFlow, "") + experimentAllocationStrategy.RegisterString(cmd, &inputs.AllocationStrategy, "") + experimentAllocations.RegisterString(cmd, &inputs.Allocations, "") + experimentAssignmentConfig.RegisterString(cmd, &inputs.AssignmentConfig, "") + + return cmd +} + +func updateExperimentCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Name string + Description string + Allocations string + AssignmentConfig string + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(1), + Short: "Update an experiment", + Long: "Update an experiment.\n\n" + + "Note: feature flag, authentication flow, and allocation strategy cannot be changed after creation.\n\n" + + "To update interactively, use `auth0 experiments update` with no arguments.", + Example: ` auth0 experiments update + auth0 experiments update + auth0 experiments update --name "new-name" + auth0 experiments update --assignment-config '{"subject":"device"}' + auth0 experiments update --allocations '[{"variation_id":"vid","weight":1.0,"is_control":true}]'`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.ID = args[0] + } else { + if err := experimentID.Pick(cmd, &inputs.ID, cli.experimentPickerOptions); err != nil { + return err + } + } + + if err := experimentName.AskU(cmd, &inputs.Name, nil); err != nil { + return err + } + if err := experimentDescription.AskU(cmd, &inputs.Description, nil); err != nil { + return err + } + if err := experimentAllocations.AskU(cmd, &inputs.Allocations, nil); err != nil { + return err + } + + req := &management.UpdateExperimentRequestParameters{} + updated := false + + if inputs.Name != "" { + req.Name = &inputs.Name + updated = true + } + if inputs.Description != "" { + req.Description = &inputs.Description + updated = true + } + if inputs.AssignmentConfig != "" { + var ac management.AssignmentConfig + if err := json.Unmarshal([]byte(inputs.AssignmentConfig), &ac); err != nil { + return fmt.Errorf("invalid JSON for --assignment-config: %w", err) + } + req.AssignmentConfig = &ac + updated = true + } + if inputs.Allocations != "" { + var allocations []*management.AllocationRequestItem + if err := json.Unmarshal([]byte(inputs.Allocations), &allocations); err != nil { + return fmt.Errorf("invalid JSON for --allocations: %w", err) + } + req.Allocations = allocations + updated = true + } + + if !updated { + return fmt.Errorf("nothing to update — provide at least one flag (--name, --description, --assignment-config, --allocations)") + } + + var result *management.UpdateExperimentResponseContent + if err := ansi.Waiting(func() (err error) { + result, err = cli.apiv2.Experiments.Update(cmd.Context(), inputs.ID, req) + return err + }); err != nil { + return fmt.Errorf("failed to update experiment %q: %w", inputs.ID, err) + } + + return cli.renderer.ExperimentUpdate(result) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + experimentName.RegisterStringU(cmd, &inputs.Name, "") + experimentDescription.RegisterStringU(cmd, &inputs.Description, "") + experimentAllocations.RegisterStringU(cmd, &inputs.Allocations, "") + experimentAssignmentConfig.RegisterStringU(cmd, &inputs.AssignmentConfig, "") + + return cmd +} + +func deleteExperimentCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Short: "Delete an experiment", + Long: "Delete an experiment.\n\n" + + "Active experiments must be paused or completed before deleting.\n\n" + + "To delete non-interactively, supply the experiment ID and use `--force` to skip confirmation.", + Example: ` auth0 experiments delete + auth0 experiments delete + auth0 experiments delete --force`, + RunE: func(cmd *cobra.Command, args []string) error { + var ids []string + if len(args) == 0 { + if err := experimentID.PickMany(cmd, &ids, cli.experimentPickerOptions); err != nil { + return err + } + } else { + ids = args + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Are you sure you want to proceed?"); !confirmed { + return nil + } + } + + return ansi.ProgressBar("Deleting experiment(s)", ids, func(_ int, id string) error { + if id != "" { + if err := cli.apiv2.Experiments.Delete(cmd.Context(), id); err != nil { + return fmt.Errorf("failed to delete experiment %q: %w", id, err) + } + } + return nil + }) + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +func validateExperimentCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "validate", + Args: cobra.MaximumNArgs(1), + Short: "Validate an experiment", + Long: "Check whether an experiment is ready to be activated. Returns validation status and any blocking errors.", + Example: ` auth0 experiments validate + auth0 experiments validate + auth0 experiments validate --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := experimentID.Pick(cmd, &inputs.ID, cli.experimentPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + var result *management.ValidateExperimentResponseContent + if err := ansi.Waiting(func() (err error) { + result, err = cli.apiv2.Experiments.Validate(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to validate experiment %q: %w", inputs.ID, err) + } + + cli.renderer.ExperimentValidate(inputs.ID, result) + return nil + }, + } + + 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 startExperimentCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "start", + Args: cobra.MaximumNArgs(1), + Short: "Start an experiment", + Long: "Transition an experiment from draft to active. Runs full validation before activating.", + Example: ` auth0 experiments start + auth0 experiments start `, + RunE: func(cmd *cobra.Command, args []string) error { + return updateExperimentStatus(cmd, cli, args, &inputs.ID, "active") + }, + } + + return cmd +} + +func pauseExperimentCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "pause", + Args: cobra.MaximumNArgs(1), + Short: "Pause an experiment", + Long: "Pause a running experiment. It can be resumed with `auth0 experiments start`.", + Example: ` auth0 experiments pause + auth0 experiments pause `, + RunE: func(cmd *cobra.Command, args []string) error { + return updateExperimentStatus(cmd, cli, args, &inputs.ID, "paused") + }, + } + + return cmd +} + +func completeExperimentCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "complete", + Args: cobra.MaximumNArgs(1), + Short: "Complete an experiment", + Long: "Mark an experiment as completed. It can then be archived.", + Example: ` auth0 experiments complete + auth0 experiments complete `, + RunE: func(cmd *cobra.Command, args []string) error { + return updateExperimentStatus(cmd, cli, args, &inputs.ID, "completed") + }, + } + + return cmd +} + +func archiveExperimentCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "archive", + Args: cobra.MaximumNArgs(1), + Short: "Archive an experiment", + Long: "Archive a completed experiment.", + Example: ` auth0 experiments archive + auth0 experiments archive `, + RunE: func(cmd *cobra.Command, args []string) error { + return updateExperimentStatus(cmd, cli, args, &inputs.ID, "archived") + }, + } + + return cmd +} + +func updateExperimentStatus(cmd *cobra.Command, cli *cli, args []string, idDst *string, targetStatus string) error { + if len(args) > 0 { + *idDst = args[0] + } else { + if err := experimentID.Pick(cmd, idDst, cli.experimentPickerOptions); err != nil { + return err + } + } + + status := management.ExperimentTransitionStatusEnum(targetStatus) + var result *management.UpdateExperimentStatusResponseContent + if err := ansi.Waiting(func() (err error) { + result, err = cli.apiv2.Experiments.UpdateStatus(cmd.Context(), *idDst, &management.UpdateExperimentStatusRequestContent{ + Status: status, + }) + return err + }); err != nil { + return fmt.Errorf("failed to set experiment %q to %s: %w", *idDst, targetStatus, err) + } + + return cli.renderer.ExperimentStatusUpdate(result) +} + +// Picker helpers. + +func (c *cli) experimentPickerOptions(ctx context.Context) (pickerOptions, error) { + page, err := c.apiv2.Experiments.List(ctx, &management.ListExperimentsRequestParameters{}) + if err != nil { + return nil, err + } + + var opts pickerOptions + for _, e := range page.Results { + label := fmt.Sprintf("%s %s %s", e.GetName(), ansi.Faint("("+e.GetID()+")"), statusBadge(string(e.GetStatus()))) + opts = append(opts, pickerOption{value: e.GetID(), label: label}) + } + + if len(opts) == 0 { + return nil, errors.New("no experiments available. Create one by running: `auth0 experiments create`") + } + + return opts, nil +} + +func statusBadge(status string) string { + switch status { + case "active": + return ansi.Green("[active]") + case "paused": + return ansi.Yellow("[paused]") + case "draft": + return ansi.Yellow("[draft]") + case "completed", "archived": + return ansi.Faint("[" + status + "]") + default: + return "[" + status + "]" + } +} + +// buildAllocationsInteractively guides the user through picking variations +// and entering weights/segments for each one. +func (c *cli) buildAllocationsInteractively(cmd *cobra.Command, featureFlagID string, strategy string) ([]*management.AllocationRequestItem, error) { + ctx := cmd.Context() + variations, err := c.apiv2.Variations.List(ctx, featureFlagID) + if err != nil { + return nil, fmt.Errorf("failed to list variations: %w", err) + } + + if len(variations.GetVariations()) == 0 { + return nil, fmt.Errorf("no variations found for feature flag %q. Create some first with `auth0 feature-flags variations create %s`", featureFlagID, featureFlagID) + } + + c.renderer.Infof("Found %d variation(s). You will be prompted to configure each one.", len(variations.GetVariations())) + c.renderer.Newline() + + var allocations []*management.AllocationRequestItem + + for i, v := range variations.GetVariations() { + c.renderer.Infof("Variation %d/%d: %s %s", i+1, len(variations.GetVariations()), v.GetName(), ansi.Faint("("+v.GetID()+")")) + + isControl := i == 0 + isControlStr := "false" + if isControl { + isControlStr = "true (first variation is control by default)" + } + c.renderer.Detailf("Is control: %s", isControlStr) + + alloc := &management.AllocationRequestItem{ + VariationID: v.GetID(), + IsControl: isControl, + } + + switch strategy { + case "percentage": + defaultWeight := fmt.Sprintf("%.4f", 1.0/float64(len(variations.GetVariations()))) + var weightStr string + q := prompt.TextInput( + "weight", + fmt.Sprintf("Weight for %q (0.0–1.0)", v.GetName()), + "Proportion of traffic assigned to this variation. All weights must sum to 1.0.", + defaultWeight, + true, + ) + if err := prompt.AskOne(q, &weightStr); err != nil { + return nil, err + } + if weightStr == "" { + weightStr = defaultWeight + } + var weight float64 + if _, err := fmt.Sscanf(weightStr, "%f", &weight); err != nil { + return nil, fmt.Errorf("invalid weight %q: must be a decimal between 0.0 and 1.0", weightStr) + } + alloc.Weight = &weight + case "segment": + // Segment_id is optional — fetch available segments and offer a picker + // with a "No segment" escape hatch. If no segments exist at all, skip silently. + segOpts, err := c.segmentPickerOptions(ctx) + if err != nil { + // No segments exist — warn and continue without assigning one. + c.renderer.Warnf("No segments available for variation %q (segment_id left unset). Create segments with `auth0 segments create`.", v.GetName()) + } else { + // Prepend a skip option so the user can leave segment_id blank. + skipLabel := "No segment (unassigned)" + labels := append([]string{skipLabel}, segOpts.labels()...) + selectFlag := Flag{Name: "Segment", LongForm: "segment"} + var chosen string + if err := selectFlag.Select(cmd, &chosen, labels, &skipLabel); err != nil { + return nil, err + } + if chosen != skipLabel { + sid := segOpts.getValue(chosen) + alloc.SegmentID = &sid + } + } + } + + allocations = append(allocations, alloc) + } + + return allocations, nil +} diff --git a/internal/cli/experiments_test.go b/internal/cli/experiments_test.go new file mode 100644 index 000000000..dcf5eb5f8 --- /dev/null +++ b/internal/cli/experiments_test.go @@ -0,0 +1,645 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + + management "github.com/auth0/go-auth0/v2/management" + managementcore "github.com/auth0/go-auth0/v2/management/core" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/auth0/mock" + "github.com/auth0/auth0-cli/internal/display" +) + +func TestExperimentsListCmd(t *testing.T) { + tests := []struct { + name string + experiments []*management.ExperimentListItem + apiError error + expectedError string + assertOutput func(t testing.TB, out string) + }{ + { + name: "it successfully lists experiments", + experiments: []*management.ExperimentListItem{ + { + ID: "exp_001", + Name: "button-color", + Status: management.ExperimentStatusEnumDraft, + FeatureFlagID: "ff_001", + IsValid: false, + }, + { + ID: "exp_002", + Name: "checkout-flow", + Status: management.ExperimentStatusEnumActive, + FeatureFlagID: "ff_002", + IsValid: true, + }, + }, + assertOutput: func(t testing.TB, out string) { + assert.Contains(t, out, "button-color") + assert.Contains(t, out, "exp_001") + assert.Contains(t, out, "checkout-flow") + assert.Contains(t, out, "exp_002") + }, + }, + { + name: "it displays an empty state when there are no experiments", + experiments: []*management.ExperimentListItem{}, + assertOutput: func(t testing.TB, out string) { + assert.Empty(t, out) + }, + }, + { + name: "it returns an error if the API call fails", + apiError: errors.New("500 Internal Server Error"), + expectedError: "failed to list experiments: 500 Internal Server Error", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + experimentAPI.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return( + &managementcore.Page[*string, *management.ExperimentListItem, *management.ListExperimentsResponseContent]{ + Results: test.experiments, + NextPageFunc: noNextPage[*string, *management.ExperimentListItem, *management.ListExperimentsResponseContent](), + }, + test.apiError, + ) + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + } + + cmd := listExperimentsCmd(cli) + err := cmd.Execute() + + if test.expectedError != "" { + assert.EqualError(t, err, test.expectedError) + } else { + assert.NoError(t, err) + test.assertOutput(t, stdout.String()) + } + }) + } +} + +func TestExperimentsShowCmd(t *testing.T) { + const expID = "exp_abc123" + + t.Run("it successfully shows an experiment", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + experimentAPI.EXPECT(). + Get(gomock.Any(), expID). + Return(&management.GetExperimentResponseContent{ + ID: expID, + Name: "button-color", + Status: management.ExperimentStatusEnumDraft, + FeatureFlagID: "ff_001", + AuthenticationFlow: "login", + AllocationStrategy: management.AllocationStrategyEnumPercentage, + IsValid: false, + }, nil) + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + } + + cmd := showExperimentCmd(cli) + cmd.SetArgs([]string{expID}) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Contains(t, stdout.String(), "button-color") + assert.Contains(t, stdout.String(), expID) + }) + + t.Run("it returns an error if the API call fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + experimentAPI.EXPECT(). + Get(gomock.Any(), expID). + Return(nil, errors.New("404 Not Found")) + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + } + + cmd := showExperimentCmd(cli) + cmd.SetArgs([]string{expID}) + err := cmd.Execute() + + assert.EqualError(t, err, `failed to get experiment "exp_abc123": 404 Not Found`) + }) +} + +func TestExperimentsCreateCmd(t *testing.T) { + tests := []struct { + name string + args []string + apiResponse *management.CreateExperimentResponseContent + apiError error + expectedError string + assertOutput func(t testing.TB, out string) + }{ + { + name: "it successfully creates an experiment", + args: []string{ + "--name", "button-color", + "--feature-flag-id", "ff_001", + "--authentication-flow", "login", + "--allocation-strategy", "percentage", + "--assignment-config", `{"subject":"device"}`, + "--allocations", `[{"variation_id":"vid_001","weight":0.5,"is_control":true},{"variation_id":"vid_002","weight":0.5,"is_control":false}]`, + }, + apiResponse: &management.CreateExperimentResponseContent{ + ID: "exp_new", + Name: "button-color", + Status: management.ExperimentStatusEnumDraft, + FeatureFlagID: "ff_001", + AuthenticationFlow: "login", + AllocationStrategy: management.AllocationStrategyEnumPercentage, + }, + assertOutput: func(t testing.TB, out string) { + assert.Contains(t, out, "button-color") + assert.Contains(t, out, "exp_new") + }, + }, + { + name: "it returns an error when --assignment-config is missing", + args: []string{ + "--name", "button-color", + "--feature-flag-id", "ff_001", + "--authentication-flow", "login", + "--allocation-strategy", "percentage", + "--allocations", `[{"variation_id":"vid_001","weight":1.0,"is_control":true}]`, + }, + expectedError: "--assignment-config is required", + }, + { + name: "it returns an error when --assignment-config is invalid JSON", + args: []string{ + "--name", "button-color", + "--feature-flag-id", "ff_001", + "--authentication-flow", "login", + "--allocation-strategy", "percentage", + "--assignment-config", "not-json", + "--allocations", `[{"variation_id":"vid_001","weight":1.0,"is_control":true}]`, + }, + expectedError: "invalid JSON for --assignment-config", + }, + { + name: "it returns an error when --allocations is empty", + args: []string{ + "--name", "button-color", + "--feature-flag-id", "ff_001", + "--authentication-flow", "login", + "--allocation-strategy", "percentage", + "--assignment-config", `{"subject":"device"}`, + "--allocations", "", + }, + expectedError: "--allocations is required", + }, + { + name: "it returns an error when --allocations is invalid JSON", + args: []string{ + "--name", "button-color", + "--feature-flag-id", "ff_001", + "--authentication-flow", "login", + "--allocation-strategy", "percentage", + "--assignment-config", `{"subject":"device"}`, + "--allocations", "not-json", + }, + expectedError: "invalid JSON for --allocations", + }, + { + name: "it returns an error if the API call fails", + args: []string{ + "--name", "button-color", + "--feature-flag-id", "ff_001", + "--authentication-flow", "login", + "--allocation-strategy", "percentage", + "--assignment-config", `{"subject":"device"}`, + "--allocations", `[{"variation_id":"vid_001","weight":1.0,"is_control":true}]`, + }, + apiError: errors.New("400 Bad Request"), + expectedError: "failed to create experiment: 400 Bad Request", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + if test.apiResponse != nil || test.apiError != nil { + experimentAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + Return(test.apiResponse, test.apiError) + } + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + } + + cmd := createExperimentCmd(cli) + cmd.SetArgs(test.args) + err := cmd.Execute() + + if test.expectedError != "" { + assert.ErrorContains(t, err, test.expectedError) + } else { + assert.NoError(t, err) + test.assertOutput(t, stdout.String()) + } + }) + } +} + +func TestExperimentsUpdateCmd(t *testing.T) { + const expID = "exp_abc123" + + tests := []struct { + name string + args []string + apiResponse *management.UpdateExperimentResponseContent + apiError error + expectedError string + }{ + { + name: "it successfully updates the name", + args: []string{expID, "--name", "new-name"}, + apiResponse: &management.UpdateExperimentResponseContent{ID: expID, Name: "new-name"}, + }, + { + name: "it returns an error when no flags are provided", + args: []string{expID}, + expectedError: "nothing to update", + }, + { + name: "it returns an error when --allocations is invalid JSON", + args: []string{expID, "--allocations", "not-json"}, + expectedError: "invalid JSON for --allocations", + }, + { + name: "it returns an error if the API call fails", + args: []string{expID, "--name", "new-name"}, + apiError: errors.New("500 Internal Server Error"), + expectedError: `failed to update experiment "exp_abc123": 500 Internal Server Error`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + if test.apiResponse != nil || test.apiError != nil { + experimentAPI.EXPECT(). + Update(gomock.Any(), expID, gomock.Any()). + Return(test.apiResponse, test.apiError) + } + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + } + + cmd := updateExperimentCmd(cli) + cmd.SetArgs(test.args) + err := cmd.Execute() + + if test.expectedError != "" { + assert.ErrorContains(t, err, test.expectedError) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestExperimentsDeleteCmd(t *testing.T) { + const expID = "exp_abc123" + + t.Run("it successfully deletes an experiment with --force", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + experimentAPI.EXPECT(). + Delete(gomock.Any(), expID). + Return(nil) + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + force: true, + } + + cmd := deleteExperimentCmd(cli) + cmd.SetArgs([]string{expID}) + err := cmd.Execute() + + assert.NoError(t, err) + }) + + t.Run("it returns an error if the API call fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + experimentAPI.EXPECT(). + Delete(gomock.Any(), expID). + Return(errors.New("500 Internal Server Error")) + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + force: true, + } + + cmd := deleteExperimentCmd(cli) + cmd.SetArgs([]string{expID}) + err := cmd.Execute() + + assert.EqualError(t, err, `failed to delete experiment "exp_abc123": 500 Internal Server Error`) + }) +} + +func TestExperimentsValidateCmd(t *testing.T) { + const expID = "exp_abc123" + + t.Run("it shows valid when the experiment passes validation", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + experimentAPI.EXPECT(). + Validate(gomock.Any(), expID). + Return(&management.ValidateExperimentResponseContent{ + IsValid: true, + }, nil) + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + } + + cmd := validateExperimentCmd(cli) + cmd.SetArgs([]string{expID}) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Contains(t, stdout.String(), "✓") + }) + + t.Run("it shows invalid and errors when the experiment fails validation", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + experimentAPI.EXPECT(). + Validate(gomock.Any(), expID). + Return(&management.ValidateExperimentResponseContent{ + IsValid: false, + Errors: []*management.ExperimentValidationError{ + { + Code: "missing_control_variation", + Message: "Exactly one variation must be marked as control.", + }, + }, + }, nil) + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + } + + cmd := validateExperimentCmd(cli) + cmd.SetArgs([]string{expID}) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Contains(t, stdout.String(), "✗") + assert.Contains(t, stdout.String(), "missing_control_variation") + }) + + t.Run("it returns an error if the API call fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + experimentAPI.EXPECT(). + Validate(gomock.Any(), expID). + Return(nil, errors.New("500 Internal Server Error")) + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + } + + cmd := validateExperimentCmd(cli) + cmd.SetArgs([]string{expID}) + err := cmd.Execute() + + assert.EqualError(t, err, `failed to validate experiment "exp_abc123": 500 Internal Server Error`) + }) +} + +func TestExperimentsStartCmd(t *testing.T) { + const expID = "exp_abc123" + + t.Run("it successfully starts an experiment", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + experimentAPI.EXPECT(). + UpdateStatus(gomock.Any(), expID, &management.UpdateExperimentStatusRequestContent{ + Status: management.ExperimentTransitionStatusEnum("active"), + }). + Return(&management.UpdateExperimentStatusResponseContent{ + ID: expID, + Name: "button-color", + Status: management.ExperimentStatusEnumActive, + }, nil) + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + } + + cmd := startExperimentCmd(cli) + cmd.SetArgs([]string{expID}) + err := cmd.Execute() + + assert.NoError(t, err) + }) + + t.Run("it returns an error if the API call fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + experimentAPI.EXPECT(). + UpdateStatus(gomock.Any(), expID, gomock.Any()). + Return(nil, errors.New("400 Bad Request")) + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + } + + cmd := startExperimentCmd(cli) + cmd.SetArgs([]string{expID}) + err := cmd.Execute() + + assert.EqualError(t, err, `failed to set experiment "exp_abc123" to active: 400 Bad Request`) + }) +} + +func TestExperimentPickerOptions(t *testing.T) { + tests := []struct { + name string + experiments []*management.ExperimentListItem + apiError error + assertOutput func(t testing.TB, options pickerOptions) + assertError func(t testing.TB, err error) + }{ + { + name: "it returns picker options for each experiment", + experiments: []*management.ExperimentListItem{ + {ID: "exp_001", Name: "button-color", Status: management.ExperimentStatusEnumDraft}, + {ID: "exp_002", Name: "checkout-flow", Status: management.ExperimentStatusEnumActive}, + }, + assertOutput: func(t testing.TB, options pickerOptions) { + assert.Len(t, options, 2) + assert.Equal(t, "exp_001", options[0].value) + assert.Equal(t, "exp_002", options[1].value) + assert.Contains(t, options[0].label, "button-color") + assert.Contains(t, options[0].label, "exp_001") + assert.Contains(t, options[1].label, "checkout-flow") + assert.Contains(t, options[1].label, "exp_002") + }, + assertError: func(t testing.TB, err error) { + t.Fail() + }, + }, + { + name: "it returns an error when there are no experiments", + experiments: []*management.ExperimentListItem{}, + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.ErrorContains(t, err, "no experiments available") + }, + }, + { + name: "it returns an error if the API call fails", + apiError: errors.New("500 Internal Server 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() + + experimentAPI := mock.NewMockExperimentsAPI(ctrl) + experimentAPI.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return( + &managementcore.Page[*string, *management.ExperimentListItem, *management.ListExperimentsResponseContent]{ + Results: test.experiments, + NextPageFunc: noNextPage[*string, *management.ExperimentListItem, *management.ListExperimentsResponseContent](), + }, + test.apiError, + ) + + cli := &cli{ + apiv2: &auth0.APIV2{Experiments: experimentAPI}, + } + + options, err := cli.experimentPickerOptions(context.Background()) + + if err != nil { + test.assertError(t, err) + } else { + test.assertOutput(t, options) + } + }) + } +} diff --git a/internal/cli/feature_flags.go b/internal/cli/feature_flags.go new file mode 100644 index 000000000..39b0057de --- /dev/null +++ b/internal/cli/feature_flags.go @@ -0,0 +1,888 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + management "github.com/auth0/go-auth0/v2/management" + managementcore "github.com/auth0/go-auth0/v2/management/core" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/prompt" +) + +var ( + featureFlagID = Argument{ + Name: "Feature Flag ID", + Help: "ID of the feature flag.", + } + + featureFlagName = Flag{ + Name: "Name", + LongForm: "name", + ShortForm: "n", + Help: "Name of the feature flag.", + IsRequired: true, + } + + featureFlagDescription = Flag{ + Name: "Description", + LongForm: "description", + ShortForm: "d", + Help: "Description of the feature flag.", + } + + featureFlagParameters = Flag{ + Name: "Parameters", + LongForm: "parameters", + ShortForm: "p", + Help: `Parameters schema as JSON. Example: '{"color":{"type":"string","value":"blue"}}'`, + IsRequired: true, + } + + variationID = Argument{ + Name: "Variation ID", + Help: "ID of the variation.", + } + + variationName = Flag{ + Name: "Name", + LongForm: "name", + ShortForm: "n", + Help: "Name of the variation.", + IsRequired: true, + } + + variationDescription = Flag{ + Name: "Description", + LongForm: "description", + ShortForm: "d", + Help: "Description of the variation.", + } + + variationOverrides = Flag{ + Name: "Overrides", + LongForm: "overrides", + ShortForm: "o", + Help: `Parameter overrides as JSON. Example: '{"color":{"value":"red"}}'`, + IsRequired: true, + } +) + +func featureFlagsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "feature-flags", + Short: "Manage experimentation feature flags", + Long: "Feature flags define named parameters (string, boolean, number) that experiments vary across user groups.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listFeatureFlagsCmd(cli)) + cmd.AddCommand(createFeatureFlagCmd(cli)) + cmd.AddCommand(showFeatureFlagCmd(cli)) + cmd.AddCommand(updateFeatureFlagCmd(cli)) + cmd.AddCommand(deleteFeatureFlagCmd(cli)) + cmd.AddCommand(activateFeatureFlagCmd(cli)) + cmd.AddCommand(archiveFeatureFlagCmd(cli)) + cmd.AddCommand(variationsCmd(cli)) + + return cmd +} + +func listFeatureFlagsCmd(cli *cli) *cobra.Command { + var inputs struct { + Status string + Type string + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + Short: "List your feature flags", + Long: "List all feature flags. To create one, run: `auth0 feature-flags create`.", + Example: ` auth0 feature-flags list + auth0 feature-flags ls + auth0 feature-flags list --json + auth0 feature-flags list --status active`, + RunE: func(cmd *cobra.Command, args []string) error { + req := &management.ListFeatureFlagsRequestParameters{} + if inputs.Status != "" { + s := management.FeatureFlagStatusEnum(inputs.Status) + req.Status = &s + } + if inputs.Type != "" { + t := management.FeatureFlagTypeEnum(inputs.Type) + req.Type = &t + } + + var allFlags []*management.FeatureFlag + + if err := ansi.Waiting(func() error { + page, err := cli.apiv2.FeatureFlags.List(cmd.Context(), req) + if err != nil { + return err + } + allFlags = append(allFlags, page.Results...) + for { + next, err := page.GetNextPage(cmd.Context()) + if errors.Is(err, managementcore.ErrNoPages) { + break + } + if err != nil { + return err + } + allFlags = append(allFlags, next.Results...) + page = next + } + return nil + }); err != nil { + return fmt.Errorf("failed to list feature flags: %w", err) + } + + cli.renderer.FeatureFlagList(allFlags) + return nil + }, + } + + 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.Flags().StringVar(&inputs.Status, "status", "", "Filter by status (draft, active, archived).") + cmd.Flags().StringVar(&inputs.Type, "type", "", "Filter by type (auth0, self).") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + + return cmd +} + +func showFeatureFlagCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(1), + Short: "Show a feature flag", + Long: "Display details about a feature flag including its parameters.", + Example: ` auth0 feature-flags show + auth0 feature-flags show + auth0 feature-flags show --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := featureFlagID.Pick(cmd, &inputs.ID, cli.featureFlagPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + var ff *management.GetFeatureFlagResponseContent + if err := ansi.Waiting(func() (err error) { + ff, err = cli.apiv2.FeatureFlags.Get(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to get feature flag %q: %w", inputs.ID, err) + } + + cli.renderer.FeatureFlagShow(ff) + return nil + }, + } + + 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 createFeatureFlagCmd(cli *cli) *cobra.Command { + var inputs struct { + Name string + Description string + Parameters string + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.NoArgs, + Short: "Create a new feature flag", + Long: "Create a new feature flag.\n\n" + + "To create interactively, use `auth0 feature-flags create` with no flags.\n\n" + + "To create non-interactively, supply name and parameters through the flags.", + Example: ` auth0 feature-flags create + auth0 feature-flags create --name "dark-mode" --parameters '{"enabled":{"type":"boolean","value":false}}' + auth0 feature-flags create -n "checkout-flow" -p '{"variant":{"type":"string","value":"control"}}'`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := featureFlagName.Ask(cmd, &inputs.Name, nil); err != nil { + return err + } + + if err := featureFlagDescription.Ask(cmd, &inputs.Description, nil); err != nil { + return err + } + + if err := featureFlagParameters.OpenEditor( + cmd, + &inputs.Parameters, + `{"param_name":{"type":"string","value":"default_value"}}`, + "feature-flag-params.*.json", + cli.featureFlagParamsEditorHint, + ); err != nil { + return err + } + + if inputs.Parameters == "" { + return fmt.Errorf("--parameters is required (e.g. --parameters '{\"color\":{\"type\":\"string\",\"value\":\"blue\"}}')") + } + var params management.CreateFeatureFlagParameters + if err := json.Unmarshal([]byte(inputs.Parameters), ¶ms); err != nil { + return fmt.Errorf("invalid JSON for --parameters (ensure the value is quoted in your shell): %w", err) + } + + req := &management.CreateFeatureFlagRequestContent{ + Name: inputs.Name, + Parameters: params, + } + if inputs.Description != "" { + req.Description = &inputs.Description + } + + var result *management.CreateFeatureFlagResponseContent + if err := ansi.Waiting(func() (err error) { + result, err = cli.apiv2.FeatureFlags.Create(cmd.Context(), req) + return err + }); err != nil { + return fmt.Errorf("failed to create feature flag: %w", err) + } + + return cli.renderer.FeatureFlagCreate(result) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + featureFlagName.RegisterString(cmd, &inputs.Name, "") + featureFlagDescription.RegisterString(cmd, &inputs.Description, "") + featureFlagParameters.RegisterString(cmd, &inputs.Parameters, "") + + return cmd +} + +func updateFeatureFlagCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Name string + Description string + Parameters string + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(1), + Short: "Update a feature flag", + Long: "Update a feature flag.\n\n" + + "To update interactively, use `auth0 feature-flags update` with no arguments.\n\n" + + "To update non-interactively, supply the feature flag ID and fields to change through the flags.", + Example: ` auth0 feature-flags update + auth0 feature-flags update + auth0 feature-flags update --name "new-name" + auth0 feature-flags update --parameters '{"enabled":{"type":"boolean","value":true}}'`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.ID = args[0] + } else { + if err := featureFlagID.Pick(cmd, &inputs.ID, cli.featureFlagPickerOptions); err != nil { + return err + } + } + + if err := featureFlagName.AskU(cmd, &inputs.Name, nil); err != nil { + return err + } + if err := featureFlagDescription.AskU(cmd, &inputs.Description, nil); err != nil { + return err + } + if err := featureFlagParameters.AskU(cmd, &inputs.Parameters, nil); err != nil { + return err + } + + req := &management.UpdateFeatureFlagRequestContent{} + updated := false + + if inputs.Name != "" { + req.Name = &inputs.Name + updated = true + } + if inputs.Description != "" { + req.Description = &inputs.Description + updated = true + } + if inputs.Parameters != "" { + var params management.UpdateFeatureFlagParameters + if err := json.Unmarshal([]byte(inputs.Parameters), ¶ms); err != nil { + return fmt.Errorf("invalid JSON for --parameters (ensure the value is quoted in your shell): %w", err) + } + req.Parameters = ¶ms + updated = true + } + + if !updated { + return fmt.Errorf("nothing to update — provide at least one flag") + } + + var result *management.UpdateFeatureFlagResponseContent + if err := ansi.Waiting(func() (err error) { + result, err = cli.apiv2.FeatureFlags.Update(cmd.Context(), inputs.ID, req) + return err + }); err != nil { + return fmt.Errorf("failed to update feature flag %q: %w", inputs.ID, err) + } + + return cli.renderer.FeatureFlagUpdate(result) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + featureFlagName.RegisterStringU(cmd, &inputs.Name, "") + featureFlagDescription.RegisterStringU(cmd, &inputs.Description, "") + featureFlagParameters.RegisterStringU(cmd, &inputs.Parameters, "") + + return cmd +} + +func deleteFeatureFlagCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Short: "Delete a feature flag", + Long: "Delete a feature flag.\n\n" + + "To delete interactively, use `auth0 feature-flags delete` with no arguments.\n\n" + + "To delete non-interactively, supply the feature flag ID and use `--force` to skip confirmation.", + Example: ` auth0 feature-flags delete + auth0 feature-flags delete + auth0 feature-flags delete --force`, + RunE: func(cmd *cobra.Command, args []string) error { + var ids []string + if len(args) == 0 { + if err := featureFlagID.PickMany(cmd, &ids, cli.featureFlagPickerOptions); err != nil { + return err + } + } else { + ids = args + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Are you sure you want to proceed?"); !confirmed { + return nil + } + } + + return ansi.ProgressBar("Deleting feature flag(s)", ids, func(_ int, id string) error { + if id != "" { + if err := cli.apiv2.FeatureFlags.Delete(cmd.Context(), id); err != nil { + return fmt.Errorf("failed to delete feature flag %q: %w", id, err) + } + } + return nil + }) + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +func activateFeatureFlagCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "activate", + Args: cobra.MaximumNArgs(1), + Short: "Activate a feature flag", + Long: "Transition a feature flag from draft to active status.", + Example: ` auth0 feature-flags activate + auth0 feature-flags activate `, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.ID = args[0] + } else { + if err := featureFlagID.Pick(cmd, &inputs.ID, cli.featureFlagPickerOptions); err != nil { + return err + } + } + + status := management.FeatureFlagStatusEnumActive + if err := ansi.Waiting(func() error { + _, err := cli.apiv2.FeatureFlags.UpdateStatus(cmd.Context(), inputs.ID, &management.UpdateFeatureFlagStatusRequestContent{ + Status: status, + }) + return err + }); err != nil { + return fmt.Errorf("failed to activate feature flag %q: %w", inputs.ID, err) + } + + cli.renderer.Infof("Feature flag %s is now active.", ansi.Faint(inputs.ID)) + return nil + }, + } + + return cmd +} + +func archiveFeatureFlagCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "archive", + Args: cobra.MaximumNArgs(1), + Short: "Archive a feature flag", + Long: "Transition a feature flag to archived status.", + Example: ` auth0 feature-flags archive + auth0 feature-flags archive `, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.ID = args[0] + } else { + if err := featureFlagID.Pick(cmd, &inputs.ID, cli.featureFlagPickerOptions); err != nil { + return err + } + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Archiving is irreversible. Are you sure?"); !confirmed { + return nil + } + } + + status := management.FeatureFlagStatusEnumArchived + if err := ansi.Waiting(func() error { + _, err := cli.apiv2.FeatureFlags.UpdateStatus(cmd.Context(), inputs.ID, &management.UpdateFeatureFlagStatusRequestContent{ + Status: status, + }) + return err + }); err != nil { + return fmt.Errorf("failed to archive feature flag %q: %w", inputs.ID, err) + } + + cli.renderer.Infof("Feature flag %s has been archived.", ansi.Faint(inputs.ID)) + return nil + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +// variationsCmd groups variation sub-commands under feature-flags. +func variationsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "variations", + Short: "Manage variations of a feature flag", + Long: "Variations define the different parameter overrides for a feature flag (e.g. control vs treatment arms).", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listVariationsCmd(cli)) + cmd.AddCommand(createVariationCmd(cli)) + cmd.AddCommand(showVariationCmd(cli)) + cmd.AddCommand(updateVariationCmd(cli)) + cmd.AddCommand(deleteVariationCmd(cli)) + + return cmd +} + +func listVariationsCmd(cli *cli) *cobra.Command { + var inputs struct { + FeatureFlagID string + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.MaximumNArgs(1), + Short: "List variations of a feature flag", + Long: "List all variations for a given feature flag.", + Example: ` auth0 feature-flags variations list + auth0 feature-flags variations list + auth0 feature-flags variations list --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.FeatureFlagID = args[0] + } else { + if err := featureFlagID.Pick(cmd, &inputs.FeatureFlagID, cli.featureFlagPickerOptions); err != nil { + return err + } + } + + var result *management.ListVariationsResponseContent + if err := ansi.Waiting(func() (err error) { + result, err = cli.apiv2.Variations.List(cmd.Context(), inputs.FeatureFlagID) + return err + }); err != nil { + return fmt.Errorf("failed to list variations for feature flag %q: %w", inputs.FeatureFlagID, err) + } + + cli.renderer.VariationList(result.GetVariations()) + return nil + }, + } + + 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 showVariationCmd(cli *cli) *cobra.Command { + var inputs struct { + FeatureFlagID string + VariationID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(2), + Short: "Show a variation", + Long: "Display details about a specific variation.", + Example: ` auth0 feature-flags variations show + auth0 feature-flags variations show + auth0 feature-flags variations show --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) >= 1 { + inputs.FeatureFlagID = args[0] + } + if len(args) == 2 { + inputs.VariationID = args[1] + } + + if inputs.FeatureFlagID == "" { + if err := featureFlagID.Pick(cmd, &inputs.FeatureFlagID, cli.featureFlagPickerOptions); err != nil { + return err + } + } + + if inputs.VariationID == "" { + if err := variationID.Pick(cmd, &inputs.VariationID, cli.variationPickerOptions(inputs.FeatureFlagID)); err != nil { + return err + } + } + + var v *management.GetVariationResponseContent + if err := ansi.Waiting(func() (err error) { + v, err = cli.apiv2.Variations.Get(cmd.Context(), inputs.FeatureFlagID, inputs.VariationID) + return err + }); err != nil { + return fmt.Errorf("failed to get variation %q: %w", inputs.VariationID, err) + } + + cli.renderer.VariationShow(v) + return nil + }, + } + + 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 createVariationCmd(cli *cli) *cobra.Command { + var inputs struct { + FeatureFlagID string + Name string + Description string + Overrides string + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.MaximumNArgs(1), + Short: "Create a new variation", + Long: "Create a new variation for a feature flag.\n\n" + + "To create interactively, use `auth0 feature-flags variations create` with no flags.\n\n" + + "To create non-interactively, supply the feature flag ID, name, and overrides through the flags.", + Example: ` auth0 feature-flags variations create + auth0 feature-flags variations create + auth0 feature-flags variations create --name "treatment" --overrides '{"color":{"value":"red"}}'`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.FeatureFlagID = args[0] + } else { + if err := featureFlagID.Pick(cmd, &inputs.FeatureFlagID, cli.featureFlagPickerOptions); err != nil { + return err + } + } + + if err := variationName.Ask(cmd, &inputs.Name, nil); err != nil { + return err + } + + if err := variationDescription.Ask(cmd, &inputs.Description, nil); err != nil { + return err + } + + if err := variationOverrides.OpenEditor( + cmd, + &inputs.Overrides, + `{"param_name":{"value":"override_value"}}`, + "variation-overrides.*.json", + cli.variationOverridesEditorHint, + ); err != nil { + return err + } + + if inputs.Overrides == "" { + return fmt.Errorf("--overrides is required (e.g. --overrides '{\"color\":{\"value\":\"red\"}}')") + } + var overrides management.VariationOverridesMap + if err := json.Unmarshal([]byte(inputs.Overrides), &overrides); err != nil { + return fmt.Errorf("invalid JSON for --overrides (ensure the value is quoted in your shell): %w", err) + } + + req := &management.CreateVariationRequestContent{ + Name: inputs.Name, + Overrides: overrides, + } + if inputs.Description != "" { + req.Description = &inputs.Description + } + + var result *management.CreateVariationResponseContent + if err := ansi.Waiting(func() (err error) { + result, err = cli.apiv2.Variations.Create(cmd.Context(), inputs.FeatureFlagID, req) + return err + }); err != nil { + return fmt.Errorf("failed to create variation: %w", err) + } + + return cli.renderer.VariationCreate(result) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + variationName.RegisterString(cmd, &inputs.Name, "") + variationDescription.RegisterString(cmd, &inputs.Description, "") + variationOverrides.RegisterString(cmd, &inputs.Overrides, "") + + return cmd +} + +func updateVariationCmd(cli *cli) *cobra.Command { + var inputs struct { + FeatureFlagID string + VariationID string + Name string + Description string + Overrides string + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(2), + Short: "Update a variation", + Long: "Update a variation.\n\n" + + "To update interactively, use `auth0 feature-flags variations update` with no arguments.\n\n" + + "To update non-interactively, supply the IDs and fields to change through the flags.", + Example: ` auth0 feature-flags variations update + auth0 feature-flags variations update + auth0 feature-flags variations update --name "new-name"`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) >= 1 { + inputs.FeatureFlagID = args[0] + } + if len(args) == 2 { + inputs.VariationID = args[1] + } + + if inputs.FeatureFlagID == "" { + if err := featureFlagID.Pick(cmd, &inputs.FeatureFlagID, cli.featureFlagPickerOptions); err != nil { + return err + } + } + + if inputs.VariationID == "" { + if err := variationID.Pick(cmd, &inputs.VariationID, cli.variationPickerOptions(inputs.FeatureFlagID)); err != nil { + return err + } + } + + if err := variationName.AskU(cmd, &inputs.Name, nil); err != nil { + return err + } + if err := variationDescription.AskU(cmd, &inputs.Description, nil); err != nil { + return err + } + if err := variationOverrides.AskU(cmd, &inputs.Overrides, nil); err != nil { + return err + } + + req := &management.UpdateVariationRequestContent{} + updated := false + + if inputs.Name != "" { + req.Name = &inputs.Name + updated = true + } + if inputs.Description != "" { + req.Description = &inputs.Description + updated = true + } + if inputs.Overrides != "" { + var overrides management.UpdateVariationOverridesMap + if err := json.Unmarshal([]byte(inputs.Overrides), &overrides); err != nil { + return fmt.Errorf("invalid JSON for --overrides (ensure the value is quoted in your shell): %w", err) + } + req.Overrides = &overrides + updated = true + } + + if !updated { + return fmt.Errorf("nothing to update — provide at least one flag") + } + + var result *management.UpdateVariationResponseContent + if err := ansi.Waiting(func() (err error) { + result, err = cli.apiv2.Variations.Update(cmd.Context(), inputs.FeatureFlagID, inputs.VariationID, req) + return err + }); err != nil { + return fmt.Errorf("failed to update variation %q: %w", inputs.VariationID, err) + } + + return cli.renderer.VariationUpdate(result) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + variationName.RegisterStringU(cmd, &inputs.Name, "") + variationDescription.RegisterStringU(cmd, &inputs.Description, "") + variationOverrides.RegisterStringU(cmd, &inputs.Overrides, "") + + return cmd +} + +func deleteVariationCmd(cli *cli) *cobra.Command { + var inputs struct { + FeatureFlagID string + VariationID string + } + + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Short: "Delete a variation", + Long: "Delete a variation.\n\n" + + "To delete interactively, use `auth0 feature-flags variations delete` with no arguments.", + Example: ` auth0 feature-flags variations delete + auth0 feature-flags variations delete + auth0 feature-flags variations delete --force`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) >= 1 { + inputs.FeatureFlagID = args[0] + } + if len(args) == 2 { + inputs.VariationID = args[1] + } + + if inputs.FeatureFlagID == "" { + if err := featureFlagID.Pick(cmd, &inputs.FeatureFlagID, cli.featureFlagPickerOptions); err != nil { + return err + } + } + + if inputs.VariationID == "" { + if err := variationID.Pick(cmd, &inputs.VariationID, cli.variationPickerOptions(inputs.FeatureFlagID)); err != nil { + return err + } + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Are you sure you want to proceed?"); !confirmed { + return nil + } + } + + if err := ansi.Waiting(func() error { + return cli.apiv2.Variations.Delete(cmd.Context(), inputs.FeatureFlagID, inputs.VariationID) + }); err != nil { + return fmt.Errorf("failed to delete variation %q: %w", inputs.VariationID, err) + } + + cli.renderer.Infof("Variation %s deleted.", ansi.Faint(inputs.VariationID)) + return nil + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +// Picker helpers. + +func (c *cli) featureFlagPickerOptions(ctx context.Context) (pickerOptions, error) { + page, err := c.apiv2.FeatureFlags.List(ctx, &management.ListFeatureFlagsRequestParameters{}) + if err != nil { + return nil, err + } + + var opts pickerOptions + for _, ff := range page.Results { + label := fmt.Sprintf("%s %s", ff.GetName(), ansi.Faint("("+ff.GetID()+")")) + opts = append(opts, pickerOption{value: ff.GetID(), label: label}) + } + + if len(opts) == 0 { + return nil, errors.New("no feature flags available. Create one by running: `auth0 feature-flags create`") + } + + return opts, nil +} + +func (c *cli) variationPickerOptions(featureFlagID string) func(ctx context.Context) (pickerOptions, error) { + return func(ctx context.Context) (pickerOptions, error) { + result, err := c.apiv2.Variations.List(ctx, featureFlagID) + if err != nil { + return nil, err + } + + var opts pickerOptions + for _, v := range result.GetVariations() { + label := fmt.Sprintf("%s %s", v.GetName(), ansi.Faint("("+v.GetID()+")")) + opts = append(opts, pickerOption{value: v.GetID(), label: label}) + } + + if len(opts) == 0 { + return nil, fmt.Errorf("no variations for feature flag %q. Create one by running: `auth0 feature-flags variations create %s`", featureFlagID, featureFlagID) + } + + return opts, nil + } +} + +// Editor hints. + +func (c *cli) featureFlagParamsEditorHint() { + c.renderer.Infof("Define parameters as a JSON object. Each key is the parameter name.") + c.renderer.Infof(`Supported types: "string", "boolean", "number"`) + c.renderer.Infof(`Example: {"color":{"type":"string","value":"blue"},"enabled":{"type":"boolean","value":false}}`) +} + +func (c *cli) variationOverridesEditorHint() { + c.renderer.Infof("Define overrides as a JSON object. Keys must match the feature flag's parameter names.") + c.renderer.Infof(`Example: {"color":{"value":"red"},"enabled":{"value":true}}`) +} diff --git a/internal/cli/feature_flags_test.go b/internal/cli/feature_flags_test.go new file mode 100644 index 000000000..df8f35575 --- /dev/null +++ b/internal/cli/feature_flags_test.go @@ -0,0 +1,770 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + + management "github.com/auth0/go-auth0/v2/management" + managementcore "github.com/auth0/go-auth0/v2/management/core" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/auth0/mock" + "github.com/auth0/auth0-cli/internal/display" +) + +// noNextPage returns a NextPageFunc that always returns ErrNoPages. +func noNextPage[C comparable, T any, R any]() func(context.Context) (*managementcore.Page[C, T, R], error) { + return func(_ context.Context) (*managementcore.Page[C, T, R], error) { + return nil, managementcore.ErrNoPages + } +} + +func TestFeatureFlagsListCmd(t *testing.T) { + tests := []struct { + name string + flags []*management.FeatureFlag + apiError error + expectedError string + assertOutput func(t testing.TB, out string) + }{ + { + name: "it successfully lists feature flags", + flags: []*management.FeatureFlag{ + { + ID: "ff_001", + Name: "dark-mode", + Type: management.FeatureFlagTypeEnumSelf, + Status: management.FeatureFlagStatusEnumActive, + }, + { + ID: "ff_002", + Name: "checkout-flow", + Type: management.FeatureFlagTypeEnumAuth0, + Status: management.FeatureFlagStatusEnumDraft, + }, + }, + assertOutput: func(t testing.TB, out string) { + assert.Contains(t, out, "dark-mode") + assert.Contains(t, out, "ff_001") + assert.Contains(t, out, "checkout-flow") + assert.Contains(t, out, "ff_002") + }, + }, + { + name: "it displays an empty state when there are no feature flags", + flags: []*management.FeatureFlag{}, + assertOutput: func(t testing.TB, out string) { + assert.Empty(t, out) + }, + }, + { + name: "it returns an error if the API call fails", + apiError: errors.New("500 Internal Server Error"), + expectedError: "failed to list feature flags: 500 Internal Server Error", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + featureFlagAPI := mock.NewMockFeatureFlagsAPI(ctrl) + featureFlagAPI.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return( + &managementcore.Page[*string, *management.FeatureFlag, *management.ListFeatureFlagsResponseContent]{ + Results: test.flags, + NextPageFunc: noNextPage[*string, *management.FeatureFlag, *management.ListFeatureFlagsResponseContent](), + }, + test.apiError, + ) + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + apiv2: &auth0.APIV2{FeatureFlags: featureFlagAPI}, + } + + cmd := listFeatureFlagsCmd(cli) + err := cmd.Execute() + + if test.expectedError != "" { + assert.EqualError(t, err, test.expectedError) + } else { + assert.NoError(t, err) + test.assertOutput(t, stdout.String()) + } + }) + } +} + +func TestFeatureFlagsShowCmd(t *testing.T) { + const flagID = "ff_abc123" + + t.Run("it successfully shows a feature flag", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + featureFlagAPI := mock.NewMockFeatureFlagsAPI(ctrl) + featureFlagAPI.EXPECT(). + Get(gomock.Any(), flagID). + Return(&management.GetFeatureFlagResponseContent{ + ID: flagID, + Name: "dark-mode", + Type: management.FeatureFlagTypeEnumSelf, + Status: management.FeatureFlagStatusEnumActive, + }, nil) + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + apiv2: &auth0.APIV2{FeatureFlags: featureFlagAPI}, + } + + cmd := showFeatureFlagCmd(cli) + cmd.SetArgs([]string{flagID}) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Contains(t, stdout.String(), "dark-mode") + assert.Contains(t, stdout.String(), flagID) + }) + + t.Run("it returns an error if the API call fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + featureFlagAPI := mock.NewMockFeatureFlagsAPI(ctrl) + featureFlagAPI.EXPECT(). + Get(gomock.Any(), flagID). + Return(nil, errors.New("404 Not Found")) + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + apiv2: &auth0.APIV2{FeatureFlags: featureFlagAPI}, + } + + cmd := showFeatureFlagCmd(cli) + cmd.SetArgs([]string{flagID}) + err := cmd.Execute() + + assert.EqualError(t, err, `failed to get feature flag "ff_abc123": 404 Not Found`) + }) +} + +func TestFeatureFlagsCreateCmd(t *testing.T) { + tests := []struct { + name string + args []string + apiResponse *management.CreateFeatureFlagResponseContent + apiError error + expectedError string + assertOutput func(t testing.TB, out string) + }{ + { + name: "it successfully creates a feature flag", + args: []string{ + "--name", "dark-mode", + "--parameters", `{"enabled":{"type":"boolean","value":false}}`, + }, + apiResponse: &management.CreateFeatureFlagResponseContent{ + ID: "ff_new123", + Name: "dark-mode", + Status: management.FeatureFlagStatusEnumDraft, + }, + assertOutput: func(t testing.TB, out string) { + assert.Contains(t, out, "dark-mode") + assert.Contains(t, out, "ff_new123") + }, + }, + { + name: "it returns an error when --parameters is empty", + args: []string{ + "--name", "dark-mode", + "--parameters", "", + }, + expectedError: "--parameters is required", + }, + { + name: "it returns an error when --parameters is invalid JSON", + args: []string{ + "--name", "dark-mode", + "--parameters", "not-json", + }, + expectedError: "invalid JSON for --parameters", + }, + { + name: "it returns an error if the API call fails", + args: []string{ + "--name", "dark-mode", + "--parameters", `{"enabled":{"type":"boolean","value":false}}`, + }, + apiError: errors.New("400 Bad Request"), + expectedError: "failed to create feature flag: 400 Bad Request", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + featureFlagAPI := mock.NewMockFeatureFlagsAPI(ctrl) + if test.apiResponse != nil || test.apiError != nil { + featureFlagAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + Return(test.apiResponse, test.apiError) + } + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + apiv2: &auth0.APIV2{FeatureFlags: featureFlagAPI}, + } + + cmd := createFeatureFlagCmd(cli) + cmd.SetArgs(test.args) + err := cmd.Execute() + + if test.expectedError != "" { + assert.ErrorContains(t, err, test.expectedError) + } else { + assert.NoError(t, err) + test.assertOutput(t, stdout.String()) + } + }) + } +} + +func TestFeatureFlagsUpdateCmd(t *testing.T) { + const flagID = "ff_abc123" + + tests := []struct { + name string + args []string + apiResponse *management.UpdateFeatureFlagResponseContent + apiError error + expectedError string + }{ + { + name: "it successfully updates the name", + args: []string{flagID, "--name", "new-name"}, + apiResponse: &management.UpdateFeatureFlagResponseContent{ + ID: flagID, + Name: "new-name", + }, + }, + { + name: "it returns an error when no flags are provided", + args: []string{flagID}, + expectedError: "nothing to update", + }, + { + name: "it returns an error when --parameters is invalid JSON", + args: []string{flagID, "--parameters", "not-json"}, + expectedError: "invalid JSON for --parameters", + }, + { + name: "it returns an error if the API call fails", + args: []string{flagID, "--name", "new-name"}, + apiError: errors.New("500 Internal Server Error"), + expectedError: `failed to update feature flag "ff_abc123": 500 Internal Server Error`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + featureFlagAPI := mock.NewMockFeatureFlagsAPI(ctrl) + if test.apiResponse != nil || test.apiError != nil { + featureFlagAPI.EXPECT(). + Update(gomock.Any(), flagID, gomock.Any()). + Return(test.apiResponse, test.apiError) + } + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + apiv2: &auth0.APIV2{FeatureFlags: featureFlagAPI}, + } + + cmd := updateFeatureFlagCmd(cli) + cmd.SetArgs(test.args) + err := cmd.Execute() + + if test.expectedError != "" { + assert.ErrorContains(t, err, test.expectedError) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestFeatureFlagsDeleteCmd(t *testing.T) { + const flagID = "ff_abc123" + + t.Run("it successfully deletes a feature flag with --force", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + featureFlagAPI := mock.NewMockFeatureFlagsAPI(ctrl) + featureFlagAPI.EXPECT(). + Delete(gomock.Any(), flagID). + Return(nil) + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + apiv2: &auth0.APIV2{FeatureFlags: featureFlagAPI}, + force: true, + } + + cmd := deleteFeatureFlagCmd(cli) + cmd.SetArgs([]string{flagID}) + err := cmd.Execute() + + assert.NoError(t, err) + }) + + t.Run("it returns an error if the API call fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + featureFlagAPI := mock.NewMockFeatureFlagsAPI(ctrl) + featureFlagAPI.EXPECT(). + Delete(gomock.Any(), flagID). + Return(errors.New("500 Internal Server Error")) + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + apiv2: &auth0.APIV2{FeatureFlags: featureFlagAPI}, + force: true, + } + + cmd := deleteFeatureFlagCmd(cli) + cmd.SetArgs([]string{flagID}) + err := cmd.Execute() + + assert.EqualError(t, err, `failed to delete feature flag "ff_abc123": 500 Internal Server Error`) + }) +} + +func TestFeatureFlagPickerOptions(t *testing.T) { + tests := []struct { + name string + flags []*management.FeatureFlag + apiError error + assertOutput func(t testing.TB, options pickerOptions) + assertError func(t testing.TB, err error) + }{ + { + name: "it returns picker options for each feature flag", + flags: []*management.FeatureFlag{ + {ID: "ff_001", Name: "dark-mode"}, + {ID: "ff_002", Name: "checkout-flow"}, + }, + assertOutput: func(t testing.TB, options pickerOptions) { + assert.Len(t, options, 2) + assert.Equal(t, "ff_001", options[0].value) + assert.Equal(t, "ff_002", options[1].value) + assert.Contains(t, options[0].label, "dark-mode") + assert.Contains(t, options[0].label, "ff_001") + assert.Contains(t, options[1].label, "checkout-flow") + assert.Contains(t, options[1].label, "ff_002") + }, + assertError: func(t testing.TB, err error) { + t.Fail() + }, + }, + { + name: "it returns an error when there are no feature flags", + flags: []*management.FeatureFlag{}, + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.ErrorContains(t, err, "no feature flags available") + }, + }, + { + name: "it returns an error if the API call fails", + apiError: errors.New("500 Internal Server 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() + + featureFlagAPI := mock.NewMockFeatureFlagsAPI(ctrl) + featureFlagAPI.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return( + &managementcore.Page[*string, *management.FeatureFlag, *management.ListFeatureFlagsResponseContent]{ + Results: test.flags, + NextPageFunc: noNextPage[*string, *management.FeatureFlag, *management.ListFeatureFlagsResponseContent](), + }, + test.apiError, + ) + + cli := &cli{ + apiv2: &auth0.APIV2{FeatureFlags: featureFlagAPI}, + } + + options, err := cli.featureFlagPickerOptions(context.Background()) + + if err != nil { + test.assertError(t, err) + } else { + test.assertOutput(t, options) + } + }) + } +} + +func TestVariationPickerOptions(t *testing.T) { + const flagID = "ff_abc123" + + tests := []struct { + name string + variations []*management.Variation + apiError error + assertOutput func(t testing.TB, options pickerOptions) + assertError func(t testing.TB, err error) + }{ + { + name: "it returns picker options for each variation", + variations: []*management.Variation{ + {ID: "vid_001", Name: "control"}, + {ID: "vid_002", Name: "treatment"}, + }, + assertOutput: func(t testing.TB, options pickerOptions) { + assert.Len(t, options, 2) + assert.Equal(t, "vid_001", options[0].value) + assert.Equal(t, "vid_002", options[1].value) + assert.Contains(t, options[0].label, "control") + assert.Contains(t, options[0].label, "vid_001") + assert.Contains(t, options[1].label, "treatment") + assert.Contains(t, options[1].label, "vid_002") + }, + assertError: func(t testing.TB, err error) { + t.Fail() + }, + }, + { + name: "it returns an error when there are no variations", + variations: []*management.Variation{}, + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.ErrorContains(t, err, "no variations for feature flag") + assert.ErrorContains(t, err, flagID) + }, + }, + { + name: "it returns an error if the API call fails", + apiError: errors.New("500 Internal Server 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() + + variationsAPI := mock.NewMockVariationsAPI(ctrl) + variationsAPI.EXPECT(). + List(gomock.Any(), flagID). + Return(&management.ListVariationsResponseContent{ + Variations: test.variations, + }, test.apiError) + + cli := &cli{ + apiv2: &auth0.APIV2{Variations: variationsAPI}, + } + + pickerFn := cli.variationPickerOptions(flagID) + options, err := pickerFn(context.Background()) + + if err != nil { + test.assertError(t, err) + } else { + test.assertOutput(t, options) + } + }) + } +} + +func TestVariationsListCmd(t *testing.T) { + const flagID = "ff_abc123" + + tests := []struct { + name string + variations []*management.Variation + apiError error + expectedError string + assertOutput func(t testing.TB, out string) + }{ + { + name: "it successfully lists variations", + variations: []*management.Variation{ + {ID: "vid_001", Name: "control"}, + {ID: "vid_002", Name: "treatment"}, + }, + assertOutput: func(t testing.TB, out string) { + assert.Contains(t, out, "control") + assert.Contains(t, out, "vid_001") + assert.Contains(t, out, "treatment") + assert.Contains(t, out, "vid_002") + }, + }, + { + name: "it displays an empty state when there are no variations", + variations: []*management.Variation{}, + assertOutput: func(t testing.TB, out string) { + assert.Empty(t, out) + }, + }, + { + name: "it returns an error if the API call fails", + apiError: errors.New("500 Internal Server Error"), + expectedError: "failed to list variations", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + variationsAPI := mock.NewMockVariationsAPI(ctrl) + variationsAPI.EXPECT(). + List(gomock.Any(), flagID). + Return(&management.ListVariationsResponseContent{ + Variations: test.variations, + }, test.apiError) + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + apiv2: &auth0.APIV2{Variations: variationsAPI}, + } + + cmd := listVariationsCmd(cli) + cmd.SetArgs([]string{flagID}) + err := cmd.Execute() + + if test.expectedError != "" { + assert.ErrorContains(t, err, test.expectedError) + } else { + assert.NoError(t, err) + test.assertOutput(t, stdout.String()) + } + }) + } +} + +func TestVariationsCreateCmd(t *testing.T) { + const flagID = "ff_abc123" + + tests := []struct { + name string + args []string + apiResponse *management.CreateVariationResponseContent + apiError error + expectedError string + assertOutput func(t testing.TB, out string) + }{ + { + name: "it successfully creates a variation", + args: []string{ + flagID, + "--name", "treatment", + "--overrides", `{"color":{"value":"red"}}`, + }, + apiResponse: &management.CreateVariationResponseContent{ + ID: "vid_new", + FeatureFlagID: flagID, + Name: "treatment", + }, + assertOutput: func(t testing.TB, out string) { + assert.Contains(t, out, "treatment") + assert.Contains(t, out, "vid_new") + }, + }, + { + name: "it returns an error when --overrides is empty", + args: []string{ + flagID, + "--name", "treatment", + "--overrides", "", + }, + expectedError: "--overrides is required", + }, + { + name: "it returns an error when --overrides is invalid JSON", + args: []string{ + flagID, + "--name", "treatment", + "--overrides", "not-json", + }, + expectedError: "invalid JSON for --overrides", + }, + { + name: "it returns an error if the API call fails", + args: []string{ + flagID, + "--name", "treatment", + "--overrides", `{"color":{"value":"red"}}`, + }, + apiError: errors.New("400 Bad Request"), + expectedError: "failed to create variation: 400 Bad Request", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + variationsAPI := mock.NewMockVariationsAPI(ctrl) + if test.apiResponse != nil || test.apiError != nil { + variationsAPI.EXPECT(). + Create(gomock.Any(), flagID, gomock.Any()). + Return(test.apiResponse, test.apiError) + } + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + apiv2: &auth0.APIV2{Variations: variationsAPI}, + } + + cmd := createVariationCmd(cli) + cmd.SetArgs(test.args) + err := cmd.Execute() + + if test.expectedError != "" { + assert.ErrorContains(t, err, test.expectedError) + } else { + assert.NoError(t, err) + test.assertOutput(t, stdout.String()) + } + }) + } +} + +func TestVariationsUpdateCmd(t *testing.T) { + const flagID = "ff_abc123" + const varID = "vid_001" + + tests := []struct { + name string + args []string + apiResponse *management.UpdateVariationResponseContent + apiError error + expectedError string + }{ + { + name: "it successfully updates the name", + args: []string{flagID, varID, "--name", "new-treatment"}, + apiResponse: &management.UpdateVariationResponseContent{ID: varID, Name: "new-treatment"}, + }, + { + name: "it returns an error when no flags are provided", + args: []string{flagID, varID}, + expectedError: "nothing to update", + }, + { + name: "it returns an error when --overrides is invalid JSON", + args: []string{flagID, varID, "--overrides", "not-json"}, + expectedError: "invalid JSON for --overrides", + }, + { + name: "it returns an error if the API call fails", + args: []string{flagID, varID, "--name", "new-treatment"}, + apiError: errors.New("500 Internal Server Error"), + expectedError: `failed to update variation "vid_001": 500 Internal Server Error`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + variationsAPI := mock.NewMockVariationsAPI(ctrl) + if test.apiResponse != nil || test.apiError != nil { + variationsAPI.EXPECT(). + Update(gomock.Any(), flagID, varID, gomock.Any()). + Return(test.apiResponse, test.apiError) + } + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + apiv2: &auth0.APIV2{Variations: variationsAPI}, + } + + cmd := updateVariationCmd(cli) + cmd.SetArgs(test.args) + err := cmd.Execute() + + if test.expectedError != "" { + assert.ErrorContains(t, err, test.expectedError) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 75739f6f6..fa0fbe382 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -189,6 +189,9 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(apiCmd(cli)) rootCmd.AddCommand(terraformCmd(cli)) rootCmd.AddCommand(eventStreamsCmd(cli)) + rootCmd.AddCommand(featureFlagsCmd(cli)) + rootCmd.AddCommand(segmentsCmd(cli)) + rootCmd.AddCommand(experimentsCmd(cli)) rootCmd.AddCommand(networkACLCmd(cli)) rootCmd.AddCommand(tenantSettingsCmd(cli)) rootCmd.AddCommand(tokenExchangeCmd(cli)) diff --git a/internal/cli/segments.go b/internal/cli/segments.go new file mode 100644 index 000000000..a4e5154e3 --- /dev/null +++ b/internal/cli/segments.go @@ -0,0 +1,389 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + management "github.com/auth0/go-auth0/v2/management" + managementcore "github.com/auth0/go-auth0/v2/management/core" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/prompt" +) + +var ( + segmentID = Argument{ + Name: "Segment ID", + Help: "ID of the segment.", + } + + segmentName = Flag{ + Name: "Name", + LongForm: "name", + ShortForm: "n", + Help: "Name of the segment.", + IsRequired: true, + } + + segmentDescription = Flag{ + Name: "Description", + LongForm: "description", + ShortForm: "d", + Help: "Description of the segment.", + } + + segmentRules = Flag{ + Name: "Rules", + LongForm: "rules", + ShortForm: "r", + Help: `Rules for matching users. JSON array. Example: '[{"match":{"contains":["@example.com"]}}]'`, + IsRequired: true, + } +) + +func segmentsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "segments", + Short: "Manage experimentation segments", + Long: "Segments define groups of users matched by rules (email domain, attribute presence, etc.) for use in experiments.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listSegmentsCmd(cli)) + cmd.AddCommand(createSegmentCmd(cli)) + cmd.AddCommand(showSegmentCmd(cli)) + cmd.AddCommand(updateSegmentCmd(cli)) + cmd.AddCommand(deleteSegmentCmd(cli)) + + return cmd +} + +func listSegmentsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + Short: "List your segments", + Long: "List all segments. To create one, run: `auth0 segments create`.", + Example: ` auth0 segments list + auth0 segments ls + auth0 segments list --json + auth0 segments list --csv`, + RunE: func(cmd *cobra.Command, args []string) error { + var allSegments []*management.Segment + + if err := ansi.Waiting(func() error { + page, err := cli.apiv2.Segments.List(cmd.Context(), &management.ListSegmentsRequestParameters{}) + if err != nil { + return err + } + allSegments = append(allSegments, page.Results...) + for { + next, err := page.GetNextPage(cmd.Context()) + if errors.Is(err, managementcore.ErrNoPages) { + break + } + if err != nil { + return err + } + allSegments = append(allSegments, next.Results...) + page = next + } + return nil + }); err != nil { + return fmt.Errorf("failed to list segments: %w", err) + } + + cli.renderer.SegmentList(allSegments) + return nil + }, + } + + 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 showSegmentCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(1), + Short: "Show a segment", + Long: "Display details about a segment including its rules.", + Example: ` auth0 segments show + auth0 segments show + auth0 segments show --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := segmentID.Pick(cmd, &inputs.ID, cli.segmentPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + var segment *management.GetSegmentResponseContent + if err := ansi.Waiting(func() (err error) { + segment, err = cli.apiv2.Segments.Get(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to get segment %q: %w", inputs.ID, err) + } + + cli.renderer.SegmentShow(segment) + return nil + }, + } + + 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 createSegmentCmd(cli *cli) *cobra.Command { + var inputs struct { + Name string + Description string + Rules string + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.NoArgs, + Short: "Create a new segment", + Long: "Create a new segment.\n\n" + + "To create interactively, use `auth0 segments create` with no flags.\n\n" + + "To create non-interactively, supply name and rules through the flags.", + Example: ` auth0 segments create + auth0 segments create --name "Beta Users" --rules '[{"match":{"contains":["@beta.example.com"]}}]' + auth0 segments create -n "Internal" -r '[{"match":{"ends_with":["@mycompany.com"]}}]'`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := segmentName.Ask(cmd, &inputs.Name, nil); err != nil { + return err + } + + if err := segmentDescription.Ask(cmd, &inputs.Description, nil); err != nil { + return err + } + + if err := segmentRules.OpenEditor( + cmd, + &inputs.Rules, + `[{"match":{"contains":["@example.com"]}}]`, + "segment.*.json", + cli.segmentRulesEditorHint, + ); err != nil { + return err + } + + if inputs.Rules == "" { + return fmt.Errorf("--rules is required (e.g. --rules '[{\"match\":{\"contains\":[\"@example.com\"]}}]')") + } + var rules []*management.SegmentRule + if err := json.Unmarshal([]byte(inputs.Rules), &rules); err != nil { + return fmt.Errorf("invalid JSON for --rules (ensure the value is quoted in your shell): %w", err) + } + + req := &management.CreateSegmentRequestContent{ + Name: inputs.Name, + Rules: rules, + } + if inputs.Description != "" { + req.Description = &inputs.Description + } + + var result *management.CreateSegmentResponseContent + if err := ansi.Waiting(func() (err error) { + result, err = cli.apiv2.Segments.Create(cmd.Context(), req) + return err + }); err != nil { + return fmt.Errorf("failed to create segment: %w", err) + } + + return cli.renderer.SegmentCreate(result) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + segmentName.RegisterString(cmd, &inputs.Name, "") + segmentDescription.RegisterString(cmd, &inputs.Description, "") + segmentRules.RegisterString(cmd, &inputs.Rules, "") + + return cmd +} + +func updateSegmentCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Name string + Description string + Rules string + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(1), + Short: "Update a segment", + Long: "Update a segment.\n\n" + + "To update interactively, use `auth0 segments update` with no arguments.\n\n" + + "To update non-interactively, supply the segment ID and fields to change through the flags.", + Example: ` auth0 segments update + auth0 segments update + auth0 segments update --name "New Name" + auth0 segments update --rules '[{"match":{"contains":["@newdomain.com"]}}]'`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.ID = args[0] + } else { + if err := segmentID.Pick(cmd, &inputs.ID, cli.segmentPickerOptions); err != nil { + return err + } + } + + var existing *management.GetSegmentResponseContent + if err := ansi.Waiting(func() (err error) { + existing, err = cli.apiv2.Segments.Get(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to get segment %q: %w", inputs.ID, err) + } + + if err := segmentName.AskU(cmd, &inputs.Name, nil); err != nil { + return err + } + + if err := segmentDescription.AskU(cmd, &inputs.Description, nil); err != nil { + return err + } + + if err := segmentRules.AskU(cmd, &inputs.Rules, nil); err != nil { + return err + } + + req := &management.UpdateSegmentRequestContent{} + updated := false + + if inputs.Name != "" { + req.Name = &inputs.Name + updated = true + } + if inputs.Description != "" { + req.Description = &inputs.Description + updated = true + } + if inputs.Rules != "" { + var rules []*management.SegmentRule + if err := json.Unmarshal([]byte(inputs.Rules), &rules); err != nil { + return fmt.Errorf("invalid JSON for --rules (ensure the value is quoted in your shell): %w", err) + } + req.Rules = rules + updated = true + } + + if !updated { + return fmt.Errorf("nothing to update — provide at least one flag") + } + + _ = existing + + var result *management.UpdateSegmentResponseContent + if err := ansi.Waiting(func() (err error) { + result, err = cli.apiv2.Segments.Update(cmd.Context(), inputs.ID, req) + return err + }); err != nil { + return fmt.Errorf("failed to update segment %q: %w", inputs.ID, err) + } + + return cli.renderer.SegmentUpdate(result) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + segmentName.RegisterStringU(cmd, &inputs.Name, "") + segmentDescription.RegisterStringU(cmd, &inputs.Description, "") + segmentRules.RegisterStringU(cmd, &inputs.Rules, "") + + return cmd +} + +func deleteSegmentCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Short: "Delete a segment", + Long: "Delete a segment.\n\n" + + "To delete interactively, use `auth0 segments delete` with no arguments.\n\n" + + "To delete non-interactively, supply the segment ID and use `--force` to skip confirmation.", + Example: ` auth0 segments delete + auth0 segments rm + auth0 segments delete + auth0 segments delete --force + auth0 segments delete --force`, + RunE: func(cmd *cobra.Command, args []string) error { + var ids []string + if len(args) == 0 { + if err := segmentID.PickMany(cmd, &ids, cli.segmentPickerOptions); err != nil { + return err + } + } else { + ids = args + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Are you sure you want to proceed?"); !confirmed { + return nil + } + } + + return ansi.ProgressBar("Deleting segment(s)", ids, func(_ int, id string) error { + if id != "" { + if err := cli.apiv2.Segments.Delete(cmd.Context(), id); err != nil { + return fmt.Errorf("failed to delete segment %q: %w", id, err) + } + } + return nil + }) + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +func (c *cli) segmentPickerOptions(ctx context.Context) (pickerOptions, error) { + page, err := c.apiv2.Segments.List(ctx, &management.ListSegmentsRequestParameters{}) + if err != nil { + return nil, err + } + + var opts pickerOptions + for _, s := range page.Results { + label := fmt.Sprintf("%s %s", s.GetName(), ansi.Faint("("+s.GetID()+")")) + opts = append(opts, pickerOption{value: s.GetID(), label: label}) + } + + if len(opts) == 0 { + return nil, errors.New("no segments available. Create one by running: `auth0 segments create`") + } + + return opts, nil +} + +func (c *cli) segmentRulesEditorHint() { + c.renderer.Infof("Enter the segment rules as a JSON array. Each rule has a `match` and/or `not_match` block.") + c.renderer.Infof(`Example: [{"match":{"contains":["@example.com"]}}]`) +} diff --git a/internal/display/experiments.go b/internal/display/experiments.go new file mode 100644 index 000000000..b2177de99 --- /dev/null +++ b/internal/display/experiments.go @@ -0,0 +1,266 @@ +package display + +import ( + "fmt" + "strings" + "time" + + management "github.com/auth0/go-auth0/v2/management" + + "github.com/auth0/auth0-cli/internal/ansi" +) + +type experimentView struct { + ID string + Name string + Description string + Status string + Valid string + FeatureFlagID string + AuthFlow string + AllocationStrategy string + Allocations string + StartedAt string + CreatedAt string + UpdatedAt string + raw interface{} +} + +func (v *experimentView) AsTableHeader() []string { + return []string{"ID", "Name", "Status", "Valid", "Auth Flow", "Started"} +} + +func (v *experimentView) AsTableRow() []string { + return []string{ansi.Faint(v.ID), v.Name, v.Status, v.Valid, v.AuthFlow, v.StartedAt} +} + +func (v *experimentView) KeyValues() [][]string { + kvs := [][]string{ + {"ID", ansi.Faint(v.ID)}, + {"NAME", v.Name}, + {"STATUS", v.Status}, + {"VALID", v.Valid}, + {"FEATURE FLAG", ansi.Faint(v.FeatureFlagID)}, + {"AUTH FLOW", v.AuthFlow}, + {"ALLOCATION", v.AllocationStrategy}, + } + if v.Description != "" { + kvs = append(kvs, []string{"DESCRIPTION", v.Description}) + } + if v.Allocations != "" { + kvs = append(kvs, []string{"ALLOCATIONS", v.Allocations}) + } + if v.StartedAt != "" { + kvs = append(kvs, []string{"STARTED", v.StartedAt}) + } + kvs = append(kvs, + []string{"CREATED", v.CreatedAt}, + []string{"UPDATED", v.UpdatedAt}, + ) + return kvs +} + +func (v *experimentView) Object() interface{} { + return v.raw +} + +func experimentStatus(s string) string { + switch strings.ToLower(s) { + case "active": + return ansi.Green(s) + case "draft": + return ansi.Yellow(s) + case "paused": + return ansi.Yellow(s) + case "completed", "archived": + return ansi.Faint(s) + default: + return s + } +} + +func formatAllocations(allocations []*management.AllocationItem) string { + if len(allocations) == 0 { + return "" + } + parts := make([]string, 0, len(allocations)) + for _, a := range allocations { + var part string + role := "" + if a.GetIsControl() { + role = " (control)" + } else if a.GetIsFallback() { + role = " (fallback)" + } + + switch { + case a.GetWeight() > 0: + part = fmt.Sprintf("%s%s %.0f%%", a.GetVariationID(), role, a.GetWeight()*100) + case a.GetSegmentID() != "": + part = fmt.Sprintf("%s%s → segment:%s", a.GetVariationID(), role, a.GetSegmentID()) + default: + part = a.GetVariationID() + role + } + parts = append(parts, part) + } + return strings.Join(parts, " / ") +} + +func optionalTimeAgo(t *time.Time) string { + if t == nil { + return "" + } + return timeAgo(*t) +} + +func makeExperimentViewFromListItem(e *management.ExperimentListItem) *experimentView { + return &experimentView{ + ID: e.GetID(), + Name: e.GetName(), + Description: e.GetDescription(), + Status: experimentStatus(string(e.GetStatus())), + Valid: boolean(e.GetIsValid()), + FeatureFlagID: e.GetFeatureFlagID(), + AuthFlow: e.GetAuthenticationFlow(), + AllocationStrategy: string(e.GetAllocationStrategy()), + Allocations: formatAllocations(e.GetAllocations()), + StartedAt: optionalTimeAgo(e.StartedAt), + CreatedAt: timeAgo(e.GetCreatedAt()), + UpdatedAt: timeAgo(e.GetUpdatedAt()), + raw: e, + } +} + +func makeExperimentViewFromGet(e *management.GetExperimentResponseContent) *experimentView { + return &experimentView{ + ID: e.GetID(), + Name: e.GetName(), + Description: e.GetDescription(), + Status: experimentStatus(string(e.GetStatus())), + Valid: boolean(e.GetIsValid()), + FeatureFlagID: e.GetFeatureFlagID(), + AuthFlow: e.GetAuthenticationFlow(), + AllocationStrategy: string(e.GetAllocationStrategy()), + Allocations: formatAllocations(e.GetAllocations()), + StartedAt: optionalTimeAgo(e.StartedAt), + CreatedAt: timeAgo(e.GetCreatedAt()), + UpdatedAt: timeAgo(e.GetUpdatedAt()), + raw: e, + } +} + +func makeExperimentViewFromCreate(e *management.CreateExperimentResponseContent) *experimentView { + return &experimentView{ + ID: e.GetID(), + Name: e.GetName(), + Description: e.GetDescription(), + Status: experimentStatus(string(e.GetStatus())), + Valid: boolean(e.GetIsValid()), + FeatureFlagID: e.GetFeatureFlagID(), + AuthFlow: e.GetAuthenticationFlow(), + AllocationStrategy: string(e.GetAllocationStrategy()), + Allocations: formatAllocations(e.GetAllocations()), + CreatedAt: timeAgo(e.GetCreatedAt()), + UpdatedAt: timeAgo(e.GetUpdatedAt()), + raw: e, + } +} + +func makeExperimentViewFromStatusUpdate(e *management.UpdateExperimentStatusResponseContent) *experimentView { + return &experimentView{ + ID: e.GetID(), + Name: e.GetName(), + Status: experimentStatus(string(e.GetStatus())), + Valid: boolean(e.GetIsValid()), + UpdatedAt: timeAgo(e.GetUpdatedAt()), + raw: e, + } +} + +func (r *Renderer) ExperimentList(experiments []*management.ExperimentListItem) { + r.Heading("experiments") + if len(experiments) == 0 { + r.EmptyState("experiments", "Use 'auth0 experiments create' to add one") + return + } + var res []View + for _, e := range experiments { + res = append(res, makeExperimentViewFromListItem(e)) + } + r.Results(res) +} + +func (r *Renderer) ExperimentShow(e *management.GetExperimentResponseContent) { + r.Heading("experiment") + r.Result(makeExperimentViewFromGet(e)) +} + +func (r *Renderer) ExperimentCreate(e *management.CreateExperimentResponseContent) error { + r.Heading("experiment created") + r.Result(makeExperimentViewFromCreate(e)) + r.Newline() + r.Infof("To validate this experiment, run: auth0 experiments validate %s", e.GetID()) + return nil +} + +func (r *Renderer) ExperimentUpdate(e *management.UpdateExperimentResponseContent) error { + r.Heading("experiment updated") + view := &experimentView{ + ID: e.GetID(), + Name: e.GetName(), + Status: experimentStatus(string(e.GetStatus())), + Valid: boolean(e.GetIsValid()), + UpdatedAt: timeAgo(e.GetUpdatedAt()), + raw: e, + } + r.Result(view) + return nil +} + +func (r *Renderer) ExperimentStatusUpdate(e *management.UpdateExperimentStatusResponseContent) error { + r.Heading(fmt.Sprintf("experiment %s", strings.ToLower(string(e.GetStatus())))) + r.Result(makeExperimentViewFromStatusUpdate(e)) + r.Newline() + switch e.GetStatus() { + case "active": + r.Infof("Experiment is now running. To pause it, run: auth0 experiments pause %s", e.GetID()) + case "paused": + r.Infof("Experiment paused. To resume, run: auth0 experiments start %s", e.GetID()) + case "completed": + r.Infof("Experiment completed. To archive it, run: auth0 experiments archive %s", e.GetID()) + } + return nil +} + +func (r *Renderer) ExperimentValidate(id string, v *management.ValidateExperimentResponseContent) { + r.Heading("experiment validated") + + view := &experimentValidationView{id: id, result: v} + r.Result(view) + r.Newline() + if v.GetIsValid() { + r.Infof("Experiment is ready to start. Run: auth0 experiments start %s", id) + } else { + r.Warnf("Fix the validation errors above before starting the experiment.") + } +} + +// experimentValidationView renders the validate result. +type experimentValidationView struct { + id string + result *management.ValidateExperimentResponseContent +} + +func (v *experimentValidationView) AsTableHeader() []string { return nil } +func (v *experimentValidationView) AsTableRow() []string { return nil } +func (v *experimentValidationView) Object() interface{} { return v.result } + +func (v *experimentValidationView) KeyValues() [][]string { + kvs := [][]string{ + {"VALID", boolean(v.result.GetIsValid())}, + } + for _, e := range v.result.GetErrors() { + kvs = append(kvs, []string{ansi.Red("ERROR"), fmt.Sprintf("%s: %s", e.GetCode(), e.GetMessage())}) + } + return kvs +} diff --git a/internal/display/feature_flags.go b/internal/display/feature_flags.go new file mode 100644 index 000000000..218d93cd9 --- /dev/null +++ b/internal/display/feature_flags.go @@ -0,0 +1,294 @@ +package display + +import ( + "encoding/json" + "fmt" + "strings" + + management "github.com/auth0/go-auth0/v2/management" + + "github.com/auth0/auth0-cli/internal/ansi" +) + +type featureFlagView struct { + ID string + Name string + Description string + Type string + Status string + Parameters string + CreatedAt string + UpdatedAt string + raw interface{} +} + +func (v *featureFlagView) AsTableHeader() []string { + return []string{"ID", "Name", "Type", "Status", "Updated"} +} + +func (v *featureFlagView) AsTableRow() []string { + return []string{ansi.Faint(v.ID), v.Name, v.Type, v.Status, v.UpdatedAt} +} + +func (v *featureFlagView) KeyValues() [][]string { + kvs := [][]string{ + {"ID", ansi.Faint(v.ID)}, + {"NAME", v.Name}, + {"TYPE", v.Type}, + {"STATUS", v.Status}, + } + if v.Description != "" { + kvs = append(kvs, []string{"DESCRIPTION", v.Description}) + } + if v.Parameters != "" { + kvs = append(kvs, []string{"PARAMETERS", v.Parameters}) + } + kvs = append(kvs, + []string{"CREATED", v.CreatedAt}, + []string{"UPDATED", v.UpdatedAt}, + ) + return kvs +} + +func (v *featureFlagView) Object() interface{} { + return v.raw +} + +func featureFlagStatus(s string) string { + switch strings.ToLower(s) { + case "active": + return ansi.Green(s) + case "archived": + return ansi.Faint(s) + default: + return ansi.Yellow(s) + } +} + +func makeFeatureFlagView(ff *management.FeatureFlag) *featureFlagView { + params := "" + if ff.Parameters != nil { + if b, err := json.Marshal(ff.Parameters); err == nil { + params = string(b) + } + } + return &featureFlagView{ + ID: ff.GetID(), + Name: ff.GetName(), + Description: ff.GetDescription(), + Type: string(ff.GetType()), + Status: featureFlagStatus(string(ff.GetStatus())), + Parameters: params, + CreatedAt: timeAgo(ff.GetCreatedAt()), + UpdatedAt: timeAgo(ff.GetUpdatedAt()), + raw: ff, + } +} + +func makeFeatureFlagViewFromGet(ff *management.GetFeatureFlagResponseContent) *featureFlagView { + params := "" + if ff.Parameters != nil { + if b, err := json.Marshal(ff.Parameters); err == nil { + params = string(b) + } + } + return &featureFlagView{ + ID: ff.GetID(), + Name: ff.GetName(), + Description: ff.GetDescription(), + Type: string(ff.GetType()), + Status: featureFlagStatus(string(ff.GetStatus())), + Parameters: params, + CreatedAt: timeAgo(ff.GetCreatedAt()), + UpdatedAt: timeAgo(ff.GetUpdatedAt()), + raw: ff, + } +} + +func (r *Renderer) FeatureFlagList(flags []*management.FeatureFlag) { + r.Heading("feature flags") + if len(flags) == 0 { + r.EmptyState("feature flags", "Use 'auth0 feature-flags create' to add one") + return + } + var res []View + for _, ff := range flags { + res = append(res, makeFeatureFlagView(ff)) + } + r.Results(res) +} + +func (r *Renderer) FeatureFlagShow(ff *management.GetFeatureFlagResponseContent) { + r.Heading("feature flag") + r.Result(makeFeatureFlagViewFromGet(ff)) +} + +func (r *Renderer) FeatureFlagCreate(ff *management.CreateFeatureFlagResponseContent) error { + r.Heading("feature flag created") + // CreateFeatureFlagResponseContent has the same fields — project through a FeatureFlag for display. + view := &featureFlagView{ + ID: ff.GetID(), + Name: ff.GetName(), + Description: ff.GetDescription(), + Type: string(ff.GetType()), + Status: featureFlagStatus(string(ff.GetStatus())), + CreatedAt: timeAgo(ff.GetCreatedAt()), + UpdatedAt: timeAgo(ff.GetUpdatedAt()), + raw: ff, + } + if ff.Parameters != nil { + if b, err := json.Marshal(ff.Parameters); err == nil { + view.Parameters = string(b) + } + } + r.Result(view) + r.Newline() + r.Infof("To manage variations, run: auth0 feature-flags variations list %s", ff.GetID()) + return nil +} + +func (r *Renderer) FeatureFlagUpdate(ff *management.UpdateFeatureFlagResponseContent) error { + r.Heading("feature flag updated") + view := &featureFlagView{ + ID: ff.GetID(), + Name: ff.GetName(), + Description: ff.GetDescription(), + Type: string(ff.GetType()), + Status: featureFlagStatus(string(ff.GetStatus())), + UpdatedAt: timeAgo(ff.GetUpdatedAt()), + raw: ff, + } + r.Result(view) + return nil +} + +// VariationView. + +type variationView struct { + ID string + FeatureFlagID string + Name string + Description string + Overrides string + CreatedAt string + UpdatedAt string + raw interface{} +} + +func (v *variationView) AsTableHeader() []string { + return []string{"ID", "Name", "Overrides", "Updated"} +} + +func (v *variationView) AsTableRow() []string { + overrides := v.Overrides + if len(overrides) > 60 { + overrides = overrides[:57] + "..." + } + return []string{ansi.Faint(v.ID), v.Name, overrides, v.UpdatedAt} +} + +func (v *variationView) KeyValues() [][]string { + kvs := [][]string{ + {"ID", ansi.Faint(v.ID)}, + {"FEATURE FLAG", ansi.Faint(v.FeatureFlagID)}, + {"NAME", v.Name}, + } + if v.Description != "" { + kvs = append(kvs, []string{"DESCRIPTION", v.Description}) + } + kvs = append(kvs, + []string{"OVERRIDES", v.Overrides}, + []string{"CREATED", v.CreatedAt}, + []string{"UPDATED", v.UpdatedAt}, + ) + return kvs +} + +func (v *variationView) Object() interface{} { + return v.raw +} + +func formatOverrides(overrides management.VariationOverridesMap) string { + if len(overrides) == 0 { + return "{}" + } + parts := make([]string, 0, len(overrides)) + for k, v := range overrides { + parts = append(parts, fmt.Sprintf("%s=%v", k, v.GetValue())) + } + return strings.Join(parts, ", ") +} + +func makeVariationView(v *management.Variation) *variationView { + return &variationView{ + ID: v.GetID(), + FeatureFlagID: v.GetFeatureFlagID(), + Name: v.GetName(), + Description: v.GetDescription(), + Overrides: formatOverrides(v.GetOverrides()), + CreatedAt: timeAgo(v.GetCreatedAt()), + UpdatedAt: timeAgo(v.GetUpdatedAt()), + raw: v, + } +} + +func makeVariationViewFromGet(v *management.GetVariationResponseContent) *variationView { + return &variationView{ + ID: v.GetID(), + FeatureFlagID: v.GetFeatureFlagID(), + Name: v.GetName(), + Description: v.GetDescription(), + Overrides: formatOverrides(v.GetOverrides()), + CreatedAt: timeAgo(v.GetCreatedAt()), + UpdatedAt: timeAgo(v.GetUpdatedAt()), + raw: v, + } +} + +func (r *Renderer) VariationList(variations []*management.Variation) { + r.Heading("variations") + if len(variations) == 0 { + r.EmptyState("variations", "Use 'auth0 feature-flags variations create ' to add one") + return + } + var res []View + for _, v := range variations { + res = append(res, makeVariationView(v)) + } + r.Results(res) +} + +func (r *Renderer) VariationShow(v *management.GetVariationResponseContent) { + r.Heading("variation") + r.Result(makeVariationViewFromGet(v)) +} + +func (r *Renderer) VariationCreate(v *management.CreateVariationResponseContent) error { + r.Heading("variation created") + view := &variationView{ + ID: v.GetID(), + FeatureFlagID: v.GetFeatureFlagID(), + Name: v.GetName(), + Description: v.GetDescription(), + Overrides: formatOverrides(v.GetOverrides()), + CreatedAt: timeAgo(v.GetCreatedAt()), + UpdatedAt: timeAgo(v.GetUpdatedAt()), + raw: v, + } + r.Result(view) + return nil +} + +func (r *Renderer) VariationUpdate(v *management.UpdateVariationResponseContent) error { + r.Heading("variation updated") + view := &variationView{ + ID: v.GetID(), + Name: v.GetName(), + Description: v.GetDescription(), + Overrides: formatOverrides(v.GetOverrides()), + UpdatedAt: timeAgo(v.GetUpdatedAt()), + raw: v, + } + r.Result(view) + return nil +} diff --git a/internal/display/segments.go b/internal/display/segments.go new file mode 100644 index 000000000..5c72cf983 --- /dev/null +++ b/internal/display/segments.go @@ -0,0 +1,141 @@ +package display + +import ( + "encoding/json" + "strings" + + management "github.com/auth0/go-auth0/v2/management" + + "github.com/auth0/auth0-cli/internal/ansi" +) + +type segmentView struct { + ID string + Name string + Description string + Type string + Rules string + CreatedAt string + UpdatedAt string + raw interface{} +} + +func (v *segmentView) AsTableHeader() []string { + return []string{"ID", "Name", "Type", "Rules", "Updated"} +} + +func (v *segmentView) AsTableRow() []string { + rules := v.Rules + if len(rules) > 50 { + rules = rules[:47] + "..." + } + return []string{ansi.Faint(v.ID), v.Name, v.Type, rules, v.UpdatedAt} +} + +func (v *segmentView) KeyValues() [][]string { + kvs := [][]string{ + {"ID", ansi.Faint(v.ID)}, + {"NAME", v.Name}, + {"TYPE", v.Type}, + } + if v.Description != "" { + kvs = append(kvs, []string{"DESCRIPTION", v.Description}) + } + kvs = append(kvs, + []string{"RULES", v.Rules}, + []string{"CREATED", v.CreatedAt}, + []string{"UPDATED", v.UpdatedAt}, + ) + return kvs +} + +func (v *segmentView) Object() interface{} { + return v.raw +} + +func formatRules(rules []*management.SegmentRule) string { + if len(rules) == 0 { + return "[]" + } + b, err := json.Marshal(rules) + if err != nil { + return "[]" + } + return string(b) +} + +func makeSegmentView(s *management.Segment) *segmentView { + return &segmentView{ + ID: s.GetID(), + Name: s.GetName(), + Description: s.GetDescription(), + Type: strings.ToLower(string(s.GetType())), + Rules: formatRules(s.GetRules()), + CreatedAt: timeAgo(s.GetCreatedAt()), + UpdatedAt: timeAgo(s.GetUpdatedAt()), + raw: s, + } +} + +func makeSegmentViewFromGet(s *management.GetSegmentResponseContent) *segmentView { + return &segmentView{ + ID: s.GetID(), + Name: s.GetName(), + Description: s.GetDescription(), + Type: strings.ToLower(string(s.GetType())), + Rules: formatRules(s.GetRules()), + CreatedAt: timeAgo(s.GetCreatedAt()), + UpdatedAt: timeAgo(s.GetUpdatedAt()), + raw: s, + } +} + +func (r *Renderer) SegmentList(segments []*management.Segment) { + r.Heading("segments") + if len(segments) == 0 { + r.EmptyState("segments", "Use 'auth0 segments create' to add one") + return + } + var res []View + for _, s := range segments { + res = append(res, makeSegmentView(s)) + } + r.Results(res) +} + +func (r *Renderer) SegmentShow(s *management.GetSegmentResponseContent) { + r.Heading("segment") + r.Result(makeSegmentViewFromGet(s)) +} + +func (r *Renderer) SegmentCreate(s *management.CreateSegmentResponseContent) error { + r.Heading("segment created") + view := &segmentView{ + ID: s.GetID(), + Name: s.GetName(), + Description: s.GetDescription(), + Type: strings.ToLower(string(s.GetType())), + Rules: formatRules(s.GetRules()), + CreatedAt: timeAgo(s.GetCreatedAt()), + UpdatedAt: timeAgo(s.GetUpdatedAt()), + raw: s, + } + r.Result(view) + r.Newline() + r.Infof("To use this segment in an experiment, run: auth0 experiments create") + return nil +} + +func (r *Renderer) SegmentUpdate(s *management.UpdateSegmentResponseContent) error { + r.Heading("segment updated") + view := &segmentView{ + ID: s.GetID(), + Name: s.GetName(), + Type: strings.ToLower(string(s.GetType())), + Rules: formatRules(s.GetRules()), + UpdatedAt: timeAgo(s.GetUpdatedAt()), + raw: s, + } + r.Result(view) + return nil +} diff --git a/test/integration/experiments-test-cases.yaml b/test/integration/experiments-test-cases.yaml new file mode 100644 index 000000000..d3cdfef35 --- /dev/null +++ b/test/integration/experiments-test-cases.yaml @@ -0,0 +1,236 @@ +config: + inherit-env: true + retries: 1 + +tests: + # ─── Segments ───────────────────────────────────────────────────────────────── + + 001 - it successfully lists all segments with no data: + command: auth0 segments list + exit-code: 0 + stderr: + contains: + - "Use 'auth0 segments create' to add one" + + 002 - it successfully lists all segments with no data (json): + command: auth0 segments list --json + exit-code: 0 + stdout: + exactly: "[]" + + 003 - it successfully creates a segment: + command: auth0 segments create -n "integration-test-segment1" -r '[{"match":{"domain":["example.com"]}}]' --no-input + exit-code: 0 + stdout: + contains: + - "NAME integration-test" + - "TYPE self" + + 004 - it successfully lists all segments with data: + command: auth0 segments list + exit-code: 0 + stdout: + contains: + - NAME + - RULES + - UPDATED + + 005 - it successfully creates a segment and outputs in json: + command: auth0 segments create -n "integration-test-segment2" -r '[{"match":{"browser":["chrome"]}}]' --json --no-input + exit-code: 0 + stdout: + json: + name: "integration-test-segment2" + + 006 - given a test segment, it successfully shows the segment details: + command: auth0 segments show $(./test/integration/scripts/get-segment-id.sh) + exit-code: 0 + stdout: + contains: + - "NAME integration-test-segment" + - "TYPE self" + + 007 - given a test segment, it successfully shows the segment details in json: + command: auth0 segments show $(./test/integration/scripts/get-segment-id.sh) --json + exit-code: 0 + stdout: + json: + name: "integration-test-segment" + + 008 - given a test segment, it successfully updates the name: + command: auth0 segments update $(./test/integration/scripts/get-segment-id.sh) --name "integration-test-segment-updated" --no-input + exit-code: 0 + stdout: + contains: + - "NAME integration-test-segment-updated" + - "TYPE self" + + 009 - given a test segment, it successfully updates in json: + command: auth0 segments update $(./test/integration/scripts/get-segment-id.sh) --name "integration-test-segment-updated-again" --json --no-input + exit-code: 0 + stdout: + json: + name: "integration-test-segment-updated-again" + + 010 - it returns an error when --rules is invalid json: + command: auth0 segments create -n "integration-test-bad" -r 'not-json' --no-input + exit-code: 1 + stderr: + contains: + - "Invalid JSON for --rules" + + # ─── Experiments ────────────────────────────────────────────────────────────── + + 020 - it successfully lists all experiments with no data: + command: auth0 experiments list + exit-code: 0 + stderr: + contains: + - "Use 'auth0 experiments create' to add one" + + 021 - it successfully lists all experiments with no data (json): + command: auth0 experiments list --json + exit-code: 0 + stdout: + exactly: "[]" + + 022 - given prerequisites, it successfully creates an experiment: + command: auth0 experiments create -n "integration-test-experiment1" -f $(./test/integration/scripts/get-feature-flag-id.sh) -a "login" -s "percentage" --assignment-config "{\"subject\":\"device\"}" -A "[{\"variation_id\":\"$(./test/integration/scripts/get-variation-id.sh)\",\"weight\":1.0,\"is_control\":true}]" --no-input + exit-code: 0 + stdout: + contains: + - "NAME integration-test-experiment1" + - "STATUS draft" + + 023 - it successfully lists all experiments with data: + command: auth0 experiments list + exit-code: 0 + stdout: + contains: + - NAME + - STATUS + - AUTH FLOW + + 024 - it successfully creates an experiment and outputs in json: + command: auth0 experiments create -n "integration-test-experiment" -f $(./test/integration/scripts/get-feature-flag-id.sh) -a "login" -s "percentage" --assignment-config "{\"subject\":\"device\"}" -A "[{\"variation_id\":\"$(./test/integration/scripts/get-variation-id.sh)\",\"weight\":1.0,\"is_control\":true}]" --json --no-input + exit-code: 0 + stdout: + json: + name: "integration-test-experiment2" + status: "draft" + authentication_flow: "login" + allocation_strategy: "percentage" + + 025 - given a test experiment, it successfully shows the experiment details: + command: auth0 experiments show $(./test/integration/scripts/get-experiment-id.sh) + exit-code: 0 + stdout: + contains: + - "NAME integration-test-experiment" + - "STATUS draft" + + 026 - given a test experiment, it successfully shows the experiment details in json: + command: auth0 experiments show $(./test/integration/scripts/get-experiment-id.sh) --json + exit-code: 0 + stdout: + json: + name: "integration-test-experiment" + status: "draft" + authentication_flow: "login" + allocation_strategy: "percentage" + + 027 - given a test experiment, it successfully updates the name: + command: auth0 experiments update $(./test/integration/scripts/get-experiment-id.sh) --name "integration-test-experiment-updated" --no-input + exit-code: 0 + stdout: + contains: + - "NAME integration-test-experiment-updated" + - "STATUS draft" + + 028 - given a test experiment, it successfully updates in json: + command: auth0 experiments update $(./test/integration/scripts/get-experiment-id.sh) --name "integration-test-experiment-updated-again" --json --no-input + exit-code: 0 + stdout: + json: + name: "integration-test-experiment-updated-again" + + 029 - given a test experiment, it successfully validates the experiment: + command: auth0 experiments validate $(./test/integration/scripts/get-experiment-id.sh) + exit-code: 0 + stdout: + contains: + - VALID + + 030 - it successfully filters experiments by status: + command: auth0 experiments list --status draft + exit-code: 0 + stdout: + contains: + - NAME + - STATUS + + 031 - it returns an error when --allocations is invalid json: + command: auth0 experiments create -n "bad-experiment" -f $(./test/integration/scripts/get-feature-flag-id.sh) -a "login" -s "percentage" --assignment-config "{\"subject\":\"device\"}" -A 'not-json' --no-input + exit-code: 1 + stderr: + contains: + - "invalid JSON for --allocations" + + 031b - it returns an error when --assignment-config is missing: + command: auth0 experiments create -n "bad-experiment" -f $(./test/integration/scripts/get-feature-flag-id.sh) -a "login" -s "percentage" -A "[{\"variation_id\":\"vid\",\"weight\":1.0,\"is_control\":true}]" --no-input + exit-code: 1 + stderr: + contains: + - "--assignment-config is required" + + 031c - it returns an error when --assignment-config is invalid json: + command: auth0 experiments create -n "bad-experiment" -f $(./test/integration/scripts/get-feature-flag-id.sh) -a "login" -s "percentage" --assignment-config 'not-json' -A "[{\"variation_id\":\"vid\",\"weight\":1.0,\"is_control\":true}]" --no-input + exit-code: 1 + stderr: + contains: + - "invalid JSON for --assignment-config" + + 032 - given a test experiment, it successfully starts the experiment: + command: auth0 experiments start $(./test/integration/scripts/get-experiment-id.sh) + exit-code: 0 + stdout: + contains: + - "STATUS active" + + 033 - given a running experiment, it successfully pauses it: + command: auth0 experiments pause $(./test/integration/scripts/get-experiment-id.sh) + exit-code: 0 + stdout: + contains: + - "STATUS paused" + + 034 - given a paused experiment, it successfully resumes it: + command: auth0 experiments start $(./test/integration/scripts/get-experiment-id.sh) + exit-code: 0 + stdout: + contains: + - "STATUS active" + + 035 - given a running experiment, it successfully completes it: + command: auth0 experiments complete $(./test/integration/scripts/get-experiment-id.sh) + exit-code: 0 + stdout: + contains: + - "STATUS completed" + + 036 - given a completed experiment, it successfully archives it: + command: auth0 experiments archive $(./test/integration/scripts/get-experiment-id.sh) + exit-code: 0 + stdout: + contains: + - "STATUS archived" + + # ─── Cleanup ────────────────────────────────────────────────────────────────── + + 040 - it successfully deletes the test experiment: + command: auth0 experiments delete $(./test/integration/scripts/get-experiment-id.sh) --force + exit-code: 0 + + 041 - it successfully deletes the test segment: + command: auth0 segments delete $(./test/integration/scripts/get-segment-id.sh) --force + exit-code: 0 diff --git a/test/integration/feature-flags-test-cases.yaml b/test/integration/feature-flags-test-cases.yaml new file mode 100644 index 000000000..60bfc8756 --- /dev/null +++ b/test/integration/feature-flags-test-cases.yaml @@ -0,0 +1,189 @@ +config: + inherit-env: true + retries: 1 + +tests: + # ─── Feature Flags ──────────────────────────────────────────────────────────── + + 001 - it successfully lists all feature flags with no data: + command: auth0 feature-flags list + exit-code: 0 + stderr: + contains: + - "Use 'auth0 feature-flags create' to add one" + + 002 - it successfully lists all feature flags with no data (json): + command: auth0 feature-flags list --json + exit-code: 0 + stdout: + exactly: "[]" + + 003 - it successfully creates a feature flag and outputs in json: + command: auth0 feature-flags create -n "integration-test-flag1" -p '{"color":{"type":"string","value":"blue"}}' --json --no-input + exit-code: 0 + stdout: + json: + name: "integration-test-flag1" + status: "draft" + + 004 - it successfully creates a second feature flag: + command: auth0 feature-flags create -n "integration-test-flag2" -p '{"enabled":{"type":"boolean","value":false}}' --no-input + exit-code: 0 + stdout: + contains: + - "NAME integration-test-flag2" + - "STATUS draft" + + 005 - it successfully lists all feature flags with data: + command: auth0 feature-flags list + exit-code: 0 + stdout: + contains: + - NAME + - TYPE + - STATUS + - UPDATED + + 006 - given a test feature flag, it successfully shows the feature flag details in json: + command: auth0 feature-flags show $(./test/integration/scripts/get-feature-flag-id.sh) --json + exit-code: 0 + stdout: + json: + name: "integration-test-flag" + status: "draft" + + 007 - given a test feature flag, it successfully shows the feature flag details: + command: auth0 feature-flags show $(./test/integration/scripts/get-feature-flag-id.sh) + exit-code: 0 + stdout: + contains: + - "NAME integration-test-flag" + - "STATUS draft" + + 008 - given a test feature flag, it successfully updates the name in json: + command: auth0 feature-flags update $(./test/integration/scripts/get-feature-flag-id.sh) --name "integration-test-flag-updated" --json --no-input + exit-code: 0 + stdout: + json: + name: "integration-test-flag-updated" + + 009 - given a test feature flag, it successfully updates the name: + command: auth0 feature-flags update $(./test/integration/scripts/get-feature-flag-id.sh) --name "integration-test-flag-updated-again" --no-input + exit-code: 0 + stdout: + contains: + - "NAME integration-test-flag-updated-again" + - "STATUS draft" + + 010 - given a test feature flag, it successfully updates the description: + command: auth0 feature-flags update $(./test/integration/scripts/get-feature-flag-id.sh) --description "updated description" --json --no-input + exit-code: 0 + stdout: + json: + description: "updated description" + + 011 - it returns an error when --parameters is invalid json: + command: auth0 feature-flags create -n "integration-test-bad" -p 'not-json' --no-input + exit-code: 1 + stderr: + contains: + - "invalid JSON for --parameters" + + 012 - it returns an error when nothing to update is provided: + command: auth0 feature-flags update $(./test/integration/scripts/get-feature-flag-id.sh) --no-input + exit-code: 1 + stderr: + contains: + - "nothing to update" + + 013 - given a test feature flag, it successfully activates it: + command: auth0 feature-flags activate $(./test/integration/scripts/get-feature-flag-id.sh) + exit-code: 0 + stderr: + contains: + - "is now active" + + # ─── Variations ─────────────────────────────────────────────────────────────── + + 020 - it successfully lists variations with no data: + command: auth0 feature-flags variations list $(./test/integration/scripts/get-feature-flag-id.sh) + exit-code: 0 + stderr: + contains: + - "Use 'auth0 feature-flags variations create" + + 021 - it successfully creates a control variation in json: + command: auth0 feature-flags variations create $(./test/integration/scripts/get-feature-flag-id.sh) -n "integration-test-control" -o '{"color":{"value":"blue"}}' --json --no-input + exit-code: 0 + stdout: + json: + name: "integration-test-control" + + 022 - it successfully creates a treatment variation: + command: auth0 feature-flags variations create $(./test/integration/scripts/get-feature-flag-id.sh) -n "integration-test-treatment" -o '{"color":{"value":"red"}}' --no-input + exit-code: 0 + stdout: + contains: + - "NAME integration-test-treatment" + + 023 - it successfully lists variations with data: + command: auth0 feature-flags variations list $(./test/integration/scripts/get-feature-flag-id.sh) + exit-code: 0 + stdout: + contains: + - NAME + - OVERRIDES + - UPDATED + + 024 - given a test variation, it successfully shows the variation details in json: + command: auth0 feature-flags variations show $(./test/integration/scripts/get-feature-flag-id.sh) $(./test/integration/scripts/get-variation-id.sh) --json + exit-code: 0 + stdout: + json: + name: "integration-test-control" + + 025 - given a test variation, it successfully shows the variation details: + command: auth0 feature-flags variations show $(./test/integration/scripts/get-feature-flag-id.sh) $(./test/integration/scripts/get-variation-id.sh) + exit-code: 0 + stdout: + contains: + - "NAME integration-test-control" + - "OVERRIDES color=blue" + + 026 - given a test variation, it successfully updates the name in json: + command: auth0 feature-flags variations update $(./test/integration/scripts/get-feature-flag-id.sh) $(./test/integration/scripts/get-variation-id.sh) --name "integration-test-control-updated" --json --no-input + exit-code: 0 + stdout: + json: + name: "integration-test-control-updated" + + 027 - given a test variation, it successfully updates the overrides in json: + command: auth0 feature-flags variations update $(./test/integration/scripts/get-feature-flag-id.sh) $(./test/integration/scripts/get-variation-id.sh) --overrides '{"color":{"value":"green"}}' --json --no-input + exit-code: 0 + stdout: + json: + name: "integration-test-control-updated" + + 028 - it returns an error when --overrides is invalid json: + command: auth0 feature-flags variations create $(./test/integration/scripts/get-feature-flag-id.sh) -n "bad-variation" -o 'not-json' --no-input + exit-code: 1 + stderr: + contains: + - "invalid JSON for --overrides" + + 029 - it returns an error when nothing to update is provided for a variation: + command: auth0 feature-flags variations update $(./test/integration/scripts/get-feature-flag-id.sh) $(./test/integration/scripts/get-variation-id.sh) --no-input + exit-code: 1 + stderr: + contains: + - "nothing to update" + + 030 - given a test variation, it successfully deletes the variation: + command: auth0 feature-flags variations delete $(./test/integration/scripts/get-feature-flag-id.sh) $(./test/integration/scripts/get-variation-id.sh) --force + exit-code: 0 + + # ─── Feature flag cleanup ───────────────────────────────────────────────────── + + # 031 - given a test feature flag, it successfully deletes the feature flag: + # command: auth0 feature-flags delete $(./test/integration/scripts/get-feature-flag-id.sh) --force + # exit-code: 0 diff --git a/test/integration/scripts/get-experiment-id.sh b/test/integration/scripts/get-experiment-id.sh new file mode 100755 index 000000000..7ae8206ca --- /dev/null +++ b/test/integration/scripts/get-experiment-id.sh @@ -0,0 +1,30 @@ +#! /bin/bash + +FILE=./test/integration/identifiers/experiment-id +if [ -f "$FILE" ]; then + cat $FILE + exit 0 +fi + +# Ensure the feature flag and a control variation exist first. +FF_ID=$(./test/integration/scripts/get-feature-flag-id.sh) +CONTROL_ID=$(./test/integration/scripts/get-variation-id.sh) + +# Create a second (treatment) variation for the allocation pair. +treatment=$( auth0 feature-flags variations create "$FF_ID" \ + -n "integration-test-treatment" \ + -o '{"color":{"value":"red"}}' \ + --json --no-input ) +TREATMENT_ID=$(echo "$treatment" | jq -r '.["id"]') + +experiment=$( auth0 experiments create \ + -n "integration-test-experiment" \ + -f "$FF_ID" \ + -a "login" \ + -s "percentage" \ + -A "[{\"variation_id\":\"$CONTROL_ID\",\"weight\":0.5,\"is_control\":true},{\"variation_id\":\"$TREATMENT_ID\",\"weight\":0.5,\"is_control\":false}]" \ + --json --no-input ) + +mkdir -p ./test/integration/identifiers +echo "$experiment" | jq -r '.["id"]' > $FILE +cat $FILE diff --git a/test/integration/scripts/get-feature-flag-id.sh b/test/integration/scripts/get-feature-flag-id.sh new file mode 100755 index 000000000..f9b3314f6 --- /dev/null +++ b/test/integration/scripts/get-feature-flag-id.sh @@ -0,0 +1,16 @@ +#! /bin/bash + +FILE=./test/integration/identifiers/feature-flag-id +if [ -f "$FILE" ]; then + cat $FILE + exit 0 +fi + +ff=$( auth0 feature-flags create \ + -n "integration-test-ff-flag" \ + -p '{"color":{"type":"string","value":"blue"}}' \ + --json --no-input ) + +mkdir -p ./test/integration/identifiers +echo "$ff" | jq -r '.["id"]' > $FILE +cat $FILE diff --git a/test/integration/scripts/get-segment-id.sh b/test/integration/scripts/get-segment-id.sh new file mode 100755 index 000000000..b6299ad00 --- /dev/null +++ b/test/integration/scripts/get-segment-id.sh @@ -0,0 +1,16 @@ +#! /bin/bash + +FILE=./test/integration/identifiers/segment-id +if [ -f "$FILE" ]; then + cat $FILE + exit 0 +fi + +segment=$( auth0 segments create \ + -n "integration-test-segment" \ + -r '[{"match":{"connection":["MyConn"]}}]' \ + --json --no-input ) + +mkdir -p ./test/integration/identifiers +echo "$segment" | jq -r '.["id"]' > $FILE +cat $FILE diff --git a/test/integration/scripts/get-variation-id.sh b/test/integration/scripts/get-variation-id.sh new file mode 100755 index 000000000..5fc66b72e --- /dev/null +++ b/test/integration/scripts/get-variation-id.sh @@ -0,0 +1,19 @@ +#! /bin/bash + +FILE=./test/integration/identifiers/variation-id +if [ -f "$FILE" ]; then + cat $FILE + exit 0 +fi + +# Ensure the feature flag exists first. +FF_ID=$(./test/integration/scripts/get-feature-flag-id.sh) + +variation=$( auth0 feature-flags variations create "$FF_ID" \ + -n "integration-test-control" \ + -o '{"color":{"value":"blue"}}' \ + --json --no-input ) + +mkdir -p ./test/integration/identifiers +echo "$variation" | jq -r '.["id"]' > $FILE +cat $FILE diff --git a/test/integration/scripts/test-cleanup.sh b/test/integration/scripts/test-cleanup.sh index 292e50aa7..0d677a33e 100755 --- a/test/integration/scripts/test-cleanup.sh +++ b/test/integration/scripts/test-cleanup.sh @@ -32,6 +32,9 @@ delete_resources "actions" "integration-test-" "id" delete_resources "token-exchange" "integration-test-" "id" delete_resources "event-streams" "integration-test-" "id" delete_resources "logs streams" "integration-test-" "id" +delete_resources "experiments" "integration-test-" "id" +delete_resources "segments" "integration-test-" "id" +delete_resources "feature-flags" "integration-test-" "id" auth0 domains delete $(./test/integration/scripts/get-custom-domain-id.sh) --no-input