From 4b3f947756fdcd4664a81151b4006c31ce5c6022 Mon Sep 17 00:00:00 2001 From: James Xian Date: Thu, 22 Oct 2026 15:38:45 -0700 Subject: [PATCH] Improve guided setup defaults and explain EPP Accept displayed defaults on Enter, including new application creation, the EPP Application name, FC1 hosting, and replacement of registered encryption credentials. Keep provider selection explicit and final deployment approval defaulting to No. Explain External Phone Provider (EPP) before setup prompts and update documentation and regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f1df639-4c59-44b0-b761-40fabc6ae26d --- README.md | 3 +- docs/GETTING-STARTED.md | 27 ++++-- docs/ONBOARDING.md | 6 +- setup/Setup-Epp.ps1 | 10 ++- setup/docs/README.md | 64 +++++++++----- setup/docs/Troubleshooting.md | 4 +- setup/support/Epp.Setup.psm1 | 93 +++++++++++++++------ setup/tests/Accounts.Tests.ps1 | 37 ++++++-- setup/tests/Applications.Tests.ps1 | 34 +++++--- setup/tests/Certificates.Tests.ps1 | 20 ++++- setup/tests/ProviderConfiguration.Tests.ps1 | 13 +-- setup/tests/Providers.Tests.ps1 | 2 +- setup/tests/Selections.Tests.ps1 | 54 ++++++++++-- setup/tests/ServicePlans.Tests.ps1 | 4 + 14 files changed, 274 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index e35c478..ffa4dbb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # External Phone Provider Azure Function Sample -Connect Microsoft Entra ID to a phone provider for SMS and voice one-time passwords. +This **External Phone Provider (EPP)** sample connects Microsoft Entra ID to a phone provider +for SMS and voice one-time passwords. Guided setup deploys **one regional endpoint** using JavaScript by default, with C# and Python options. > [!IMPORTANT] diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index b7277d8..19682a6 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -4,7 +4,8 @@ [Documentation](README.md) | [Setup reference](../setup/docs/README.md) | [Troubleshooting](../setup/docs/Troubleshooting.md) -Deploy one regional endpoint for Microsoft Entra ID SMS and voice requests. +**External Phone Provider (EPP)** connects Microsoft Entra ID to your phone provider for SMS +and voice one-time passwords. This guide deploys one regional endpoint for that integration. | Step | Task | | --- | --- | @@ -53,8 +54,10 @@ Assign an **onboarding owner** and review [production readiness](../TECHNICAL.md You can create the dedicated application during guided setup or register it manually. -**Create during setup:** in Step 3, choose **Create a new application**, enter its display name, -and choose a provider by name from the alphabetized list (**Soprano, Telesign**). +**Create during setup:** in Step 3, press Enter to choose **Create a new application**, then Enter to use +**EPP Application** or enter your own display name, and choose a provider by name from the +alphabetized list (**Soprano, Telesign**). Blank provider-name input prompts again instead of +canceling or selecting a default. Setup creates the registration only after you approve the final deployment plan. Canceling before approval creates no application. The signed-in user must be allowed to register applications in the customer tenant. @@ -114,18 +117,22 @@ JavaScript is used by default, without a language prompt. To deploy another lang `.\Setup-Epp.ps1 -Language csharp` or `.\Setup-Epp.ps1 -Language python`. Select the tenant and subscription, then select an existing application, enter its **client ID**, -or choose **Create a new application** and enter its display name. Setup detects the tenant's -replication scope and selects the corresponding provider route; it does not ask you to choose EU or -Global. Select the Azure region, then complete the remaining settings: +or choose **Create a new application** and accept **EPP Application** or enter your own display name. +Enter accepts the displayed menu default rather than canceling: the current CLI tenant/subscription +when eligible (otherwise the first eligible option), and **Create a new application** for the app. +Use **0** or **Cancel** where offered to stop. +Setup detects the tenant's replication scope and selects the corresponding provider route; +it does not ask you to choose EU or Global. Select the Azure region, then complete the remaining settings: - **Azure region:** EU tenants receive recommendations within the EU Data Boundary (EU/EFTA). + Enter selects the first displayed region. Other tenants retain the existing Azure-recommended shortlist. All-region and manual selection, including `-Location`, remain available; EU tenants receive a warning for an out-of-boundary or unverifiable choice. Recommendations are not a data-residency or GDPR compliance guarantee. - **Provider:** enter or confirm the provider by name. New applications show all guided providers in alphabetical order, with no default selection. Setup downloads the language's matching stable release asset, verifies `SHA256SUMS.txt`, and builds when required. -- **Hosting:** FC1 can scale to zero and cold-start. EP1 has paid warm capacity. Review +- **Hosting:** Enter defaults to FC1, which can scale to zero and cold-start. EP1 has paid warm capacity. Review [plan details](../setup/docs/README.md#service-plan-selection) and the [cost estimate](COST-CALCULATOR.md). - **Resource prefix:** press Enter to use `epp`, or enter 2-8 lowercase letters/digits, starting with a letter. When rerunning an existing deployment, use its original prefix. @@ -133,8 +140,10 @@ Global. Select the Azure region, then complete the remaining settings: Complete required sign-in, MFA, or administrator consent. Resolve preflight errors before approval. For existing encryption credentials, follow -[certificate selection](../setup/docs/README.md#encryption-certificate-selection). Coordinate -changes across every endpoint sharing the application. +[certificate selection](../setup/docs/README.md#encryption-certificate-selection). +**Enter defaults to replacing all registered encryption credentials with one new certificate.** +Select **Reuse** explicitly to keep a certificate. Coordinate changes across every endpoint +sharing the application; no cleanup or creation occurs before final deployment approval. **Review the deployment plan before typing `Yes` or `Y`.** Check accounts, region, provider URLs, runtime, plan, resource names, permissions, certificate key IDs, and vault access. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 08aa85f..96d3866 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -46,13 +46,13 @@ renewal. | --- | --- | --- | | Customer tenant ID | Microsoft Entra admin center > Overview | `-TenantId` | | Azure subscription ID | Azure portal > Subscriptions | `-SubscriptionId` | -| Endpoint application | Existing dedicated app's client ID from Microsoft Entra admin center, or a new display name | `-ApplicationId`, or choose **Create a new application** interactively | +| Endpoint application | Existing dedicated app's client ID, or Enter to create a new app (default name `EPP Application`) | `-ApplicationId`, or the interactive **Create a new application** default | | Azure region | Available Linux FC1/EP1 region that meets quota and organization requirements | `-Location` | | Provider | Approved Security Store integration | `-Provider telesign` or `soprano` | | Provider route | Detected from the tenant's replication scope; EU for EU tenants, Global otherwise | Optional `-EndpointRegion eu` or `global` must match the detected route | -| Language and plan | JavaScript by default; C# or Python by parameter, with FC1 or EP1 | `-Language csharp` or `python`, `-ServicePlan` | +| Language and plan | JavaScript by default; C# or Python by parameter; Enter defaults to FC1 hosting | `-Language csharp` or `python`, `-ServicePlan` | | Resource prefix | Interactive default `epp`, or a unique 2-8 character lowercase label that starts with a letter | `-ResourcePrefix` (required unattended) | -| Encryption certificate | Existing registered key or approved replacement | `-CertificateAction` and certificate options | +| Encryption certificate | Enter defaults to replacing registered encryption credentials; explicitly choose Reuse to keep one | `-CertificateAction` and certificate options; final approval is still required | | Output and source versions | Private output folder and reviewed source/release versions | Optional output and version options | Use the application **client ID**, not its Object ID. The customer tenant ID and provider tenant ID diff --git a/setup/Setup-Epp.ps1 b/setup/Setup-Epp.ps1 index 7e6943f..3283770 100644 --- a/setup/Setup-Epp.ps1 +++ b/setup/Setup-Epp.ps1 @@ -8,11 +8,15 @@ Microsoft Graph operations reuse the Azure CLI user session through az ad and az rest. Interactive setup can select cached tenants, subscriptions, applications, and Azure regions, or create a dedicated app registration after final deployment approval. + Press Enter to accept a displayed default; use 0 or Cancel where offered to cancel. + Provider selection has no default. Deployment approval still defaults to No. The endpoint supports SMS and voice, selected by each request. Policy activation remains manual. No Azure resources or app registrations are changed until you approve the complete plan. .PARAMETER ApplicationId Existing application client ID. Omit interactively to select, enter, or plan a new application. + Enter at the application picker or manual client-ID prompt defaults to creating a new application. + Press Enter at the new application name prompt to use EPP Application. New applications use an alphabetized provider list and are created only after deployment approval. Required for unattended setup. .PARAMETER SourceRepository @@ -21,7 +25,7 @@ Optional stable epp-packages release tag. By default, setup uses the latest stable CI package release. .PARAMETER ServicePlan FC1 for Flex Consumption with a free usage grant, or EP1 for Premium with a warm instance. - Prompts when omitted; required for unattended setup. + Prompts with FC1 as the interactive default when omitted; required for unattended setup. .PARAMETER Language JavaScript by default, without a prompt. Use csharp or python to select another language. The legacy dotnet value remains supported for C#. @@ -39,7 +43,9 @@ saved whether or not this switch is supplied. .PARAMETER CertificateAction Create when no encryption certificate is registered, Reuse a selected certificate, or Replace - all registered encryption certificates. Existing certificates require an explicit choice. + all registered encryption certificates. Interactive selection defaults to Replace when + encryption certificates exist; changes still require final deployment approval. + An explicit choice is required for existing certificates in unattended mode. .PARAMETER EncryptionKeyId App-registration key ID to keep when reusing a certificate. Required for unattended reuse. .PARAMETER KeyVaultCertificateId diff --git a/setup/docs/README.md b/setup/docs/README.md index 320f03b..7fbaa1b 100644 --- a/setup/docs/README.md +++ b/setup/docs/README.md @@ -2,8 +2,8 @@ [Documentation](../../docs/README.md) | [First deployment](../../docs/GETTING-STARTED.md) | [Troubleshooting](Troubleshooting.md) -This reference covers setup choices, reruns, and maintenance. For a first deployment, follow the -linked [five-step deployment guide](../../docs/GETTING-STARTED.md); it keeps preparation, +**EPP** stands for **External Phone Provider**. This reference covers setup choices, reruns, and +maintenance. For a first deployment, follow the linked [five-step deployment guide](../../docs/GETTING-STARTED.md); it keeps preparation, deployment, provider validation, policy activation, and handoff in order. | Task | Section | @@ -89,23 +89,26 @@ Both plans use the same [request-time credential acquisition](../../TECHNICAL.md API-key secrets are read on each live request, and Azure Identity manages its own token cache. Setup does not configure custom credential caches or refreshers. -Use `-ServicePlan FC1` or `-ServicePlan EP1` to skip the prompt. Unattended setup requires it. +Press Enter to accept **FC1**, or use `-ServicePlan FC1` or `-ServicePlan EP1` to skip the prompt. +Unattended setup still requires an explicit service plan. **Use a different resource prefix to change plans.** In-place FC1/EP1 migration is not supported. Preflight rejects a mismatch with the existing resource-group tag or actual hosting-plan SKU, including older EP1 deployments without the new tag. Existing EP1 deployments can be rerun with `-ServicePlan EP1`. For an active endpoint, use a new Step 1 app or coordinate key continuity. Reusing an app still -requires an explicit certificate choice; replacement affects every endpoint sharing the app. +requires reviewing the certificate action; the interactive default is replacement, which affects +every endpoint sharing the app. Select **Reuse** explicitly when keeping the current certificate. ## Step 1 - choose an application registration For the first deployment, use a nonproduction tenant and subscription. -**Create during guided setup:** choose **Create a new application** in the application picker, -enter a display name (1-256 characters), and choose a provider by name from the alphabetized -list (**Soprano, Telesign**). An explicit `-Provider` skips that provider prompt. +**Create during guided setup:** press Enter to accept **Create a new application** in the application picker, +press Enter to accept **EPP Application** or enter a display name (1-256 characters), and choose +a provider by name from the alphabetized list (**Soprano, Telesign**). Blank provider-name input +prompts again; there is no default provider. An explicit `-Provider` skips that provider prompt. The new registration is only planned at this point: setup creates it after final deployment approval, not when you choose the menu option. Canceling before approval creates nothing. An existing app with the same display name is never adopted automatically. @@ -243,34 +246,47 @@ Force explicit account selection when testing on a shared or multi-account compu .\Setup-Epp.ps1 -ForceAuthentication ``` +Selection menus mark the default and show its number in brackets. **Enter accepts that default**; +choose **0** or **Cancel** where offered to stop instead. Required text inputs without a known +default, such as a versioned certificate URL, prompt again on blank input. Provider selection +never has a default. Installation and final deployment confirmations still default to **No**. + The flow is: 1. **Choose the customer Azure context.** Setup lists cached enabled Azure CLI user subscriptions, then asks for the tenant and subscription. Refresh or start device-code sign-in when the expected account is missing. Explicit `-TenantId` and `-SubscriptionId` values are preserved. + Enter prefers the current CLI tenant/subscription when eligible, otherwise the first listed + tenant/subscription within the chosen context. With no eligible entries, the displayed default + is sign-in; a missing explicit subscription instead defaults to refreshing the list. 2. **Choose the dedicated app registration.** The application picker shows organizational app names and client IDs, supports bounded name-prefix search and manual client-ID - entry, and offers **Create a new application**. That option asks for a display name and stages - creation for final deployment approval. The picker never substitutes an object ID. + entry, and defaults to **Create a new application** on Enter, including at the manual client-ID + prompt. That option asks for a display name, defaulting + to **EPP Application** on Enter, and stages creation for final deployment approval. + The picker never substitutes an object ID. A supplied client ID skips this selector. 3. **Detect tenant scope and choose the Azure region.** Setup selects EU/Global provider routing from the tenant metadata, then uses Azure's current Recommended regions for a shortlist. Only EU tenants receive the [EU Data Boundary filter](#tenant-scope-and-azure-region-recommendations). All-region and manual-entry options remain available. `-Location` skips the picker, but EU tenants still receive a boundary check and a warning if appropriate. + Enter selects the first displayed region, respecting the EU shortlist. An empty shortlist + defaults to showing all regions, not to selecting a region outside the shortlist automatically. 4. **Choose a provider by name.** JavaScript is used unless `-Language csharp` or `-Language python` was supplied; there is no language prompt. When `-Provider` is omitted, setup reads the application's allowed-tenants configuration: no supported provider prompts for a name, one asks for confirmation, and several require a name. For a new application, it shows all supported provider names in alphabetical order, without reading a nonexistent registration or selecting a default. + Blank provider-name input prompts again until a provider is selected. Existing allowed provider tenants are preserved; provider options are never numbered. The selected [profile](#availability) supplies both channel URLs and auth/timing values. The launcher has no endpoint/credential overrides or `PackageUrl`/`PackageSha256` inputs. It uses `packages/catalog.json` and the latest stable CI release, or your `-PackageReleaseTag epp-packages--`. Malformed/disabled profiles fail before resource creation. 5. **Service plan selection:** choose **Flex Consumption FC1** (free usage grant, zero always-ready - instances) or **Premium EP1** (warm instance). + instances, the Enter default) or **Premium EP1** (warm instance). Supply `-ServicePlan FC1` or `-ServicePlan EP1` to reuse a known selection. 6. **Accept the default resource prefix `epp`, or enter your own**, such as `contoso`. All resources created by the script start with @@ -283,9 +299,10 @@ The flow is: separate confirmation. Graph operations reuse the selected Azure CLI user session; no Graph SDK installation or separate sign-in occurs. 8. **Choose encryption certificate handling.** With no registered encryption certificate, use the - normal creation flow. Otherwise choose reuse of one certificate and supply its versioned Key - Vault location, or replace all encryption credentials with one new certificate. Reuse of one - among several also removes the others after approval. Enter cancels the certificate decision. + normal creation flow. Otherwise Enter defaults to **Replace**: remove the registered encryption + credentials and create one new certificate after final approval. To keep a certificate instead, + select **Reuse** and supply its versioned Key Vault location. Reuse of one among several also + removes the others after approval. Use **0** or **Cancel** to stop the certificate decision. 9. **Review the complete plan**, including resource names, tenant/subscription, language, service plan, automatic package verification/build, provider settings, scoped roles, the exact encryption key IDs to keep/remove, certificate location or @@ -316,12 +333,19 @@ certificates are not encryption candidates, and unsupported encryption credentia | Existing encryption credentials | Choice | Result after successful deployment | |---|---|---| | None | Creation is automatic; optional `-CertificateAction Create`. | Register one certificate from the setup vault. | -| One | `Reuse` and provide its Key Vault certificate version, or `Replace`. | Keep the existing key ID, or replace it with one new credential. | -| Several | `Reuse`, select the key ID to keep, and provide its certificate version; or `Replace`. | Remove only the other encryption entries, or replace all encryption entries with one new credential. | - -Interactive selection never defaults to the newest certificate or to cleanup. Enter/Cancel -stops before deployment; invalid locations can be corrected or followed by another choice. -Selecting an action does not apply it: the complete deployment approval lists the exact removals. +| One | Enter accepts `Replace`; select `Reuse` explicitly to provide its Key Vault certificate version. | Replace the existing credential with one new credential, or keep the existing key ID when reusing. | +| Several | Enter accepts `Replace`; select `Reuse` explicitly to choose a key ID and provide its certificate version. | Replace all encryption entries with one new credential, or remove only the other encryption entries when reusing. | + +**Interactive Enter defaults to cleanup and replacement, not reuse.** This affects every endpoint +sharing the registration; coordinate maintenance for active applications. If you explicitly choose +Reuse and several credentials exist, the key picker defaults to the first displayed key, not the +newest certificate. You must still provide its exact versioned Key Vault location. +Use **0** or **Cancel** at a menu, or type **Cancel** at a text prompt, to stop. +Invalid locations can be corrected or followed by another choice; the action menu again defaults +to Replace. Selecting an action does not apply it: the complete deployment approval lists the exact +removals, and **Enter at final approval still means No**. Cleanup does not delete Key Vault objects +or signing credentials. Unattended runs with existing encryption credentials still require an +explicit `-CertificateAction`. For reuse, open **Azure portal > Key Vault > Certificates > certificate > version** and copy **Certificate Identifier**, of the form @@ -587,7 +611,7 @@ Reuse leaves an external vault's issuance policy unchanged; automatic issuance t requires manual endpoint synchronization (`certificateRenewal: manual`). Account for every endpoint sharing the key and cached/in-flight requests; decryption uses one in-process private key. -**Legacy deployments require an explicit decision.** A compatible matching Key Vault certificate +**Review the replacement default for legacy deployments.** A compatible matching Key Vault certificate can be reused even if it has an older/custom subject. A standalone PEM secret without a Key Vault certificate is not a reuse input: arrange an approved secure certificate import with a PEM backing secret, or choose replacement. Setup never exports the private key locally. Do not manually delete diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md index ab22109..d28e1f5 100644 --- a/setup/docs/Troubleshooting.md +++ b/setup/docs/Troubleshooting.md @@ -60,7 +60,9 @@ Keep nonce-match **booleans**, statuses, latency, safe IDs, and delivery outcome **Check:** compare the application key ID and exact vault version using [certificate selection](README.md#encryption-certificate-selection). -Existing credentials require explicit **Reuse** or **Replace**, not an automatic new certificate. +For existing credentials, the interactive default is **Replace** (clean up registered encryption +credentials and create a new certificate after final approval). Select **Reuse** explicitly to keep +one, or **0/Cancel** to stop. Unattended runs still require an explicit **Reuse** or **Replace**. | Failure | Safe recovery | |---|---| diff --git a/setup/support/Epp.Setup.psm1 b/setup/support/Epp.Setup.psm1 index ac6afd7..85473d3 100644 --- a/setup/support/Epp.Setup.psm1 +++ b/setup/support/Epp.Setup.psm1 @@ -56,7 +56,7 @@ function Assert-EppHttpsUrl { function Select-EppOption { param( [object[]] $Entries, [string] $Name, [string] $Value, - [string] $PromptName, [switch] $NonInteractive, [switch] $AllowCancel + [string] $PromptName, [string] $DefaultValue, [switch] $NonInteractive, [switch] $AllowCancel ) $ids = @($Entries | ForEach-Object { $_['id'] }) @@ -67,12 +67,24 @@ function Select-EppOption { return $selected } if ($NonInteractive) { throw "-$Name is required. Choose: $($ids -join ', ')." } + $defaultIndex = -1 + if ($DefaultValue) { + $default = $Entries | Where-Object { $_['id'] -ieq $DefaultValue -or $_['displayName'] -ieq $DefaultValue } | Select-Object -First 1 + if (-not $default) { throw "Unknown default $Name '$DefaultValue'. Choose: $($ids -join ', ')." } + $defaultIndex = [Array]::IndexOf($ids, $default['id']) + } Write-Host "`nChoose your $($displayName.ToLowerInvariant()):" -ForegroundColor Cyan - for ($index = 0; $index -lt $Entries.Count; $index++) { Write-Host " [$($index + 1)] $($Entries[$index]['displayName'])" } - if ($AllowCancel) { Write-Host ' [0] Cancel (also Enter)' } + for ($index = 0; $index -lt $Entries.Count; $index++) { + $label = if ($index -eq $defaultIndex) { ' (default)' } else { '' } + Write-Host " [$($index + 1)] $($Entries[$index]['displayName'])$label" + } + if ($AllowCancel) { Write-Host ' [0] Cancel' } + $prompt = "$displayName number or name" + if ($defaultIndex -ge 0) { $prompt += " [$($defaultIndex + 1)]" } while ($true) { - $answer = ([string](Read-Host "$displayName number or name")).Trim() - if ($AllowCancel -and (-not $answer -or $answer -in @('0', 'Cancel'))) { return $null } + $answer = ([string](Read-Host $prompt)).Trim() + if ($AllowCancel -and $answer -in @('0', 'Cancel')) { return $null } + if (-not $answer -and $defaultIndex -ge 0) { return $Entries[$defaultIndex] } $number = 0 if ([int]::TryParse($answer, [ref] $number) -and $number -ge 1 -and $number -le $Entries.Count) { return $Entries[$number - 1] } $selected = $Entries | Where-Object { $_['id'] -ieq $answer -or $_['displayName'] -ieq $answer } | Select-Object -First 1 @@ -91,7 +103,7 @@ function Get-EppServicePlan { return Select-EppOption -Entries @( @{ id = 'FC1'; displayName = 'Flex Consumption FC1 (free grant, cold starts)' } @{ id = 'EP1'; displayName = 'Premium EP1 (warm instance)' } - ) -Name ServicePlan -PromptName 'Service plan' -Value $ServicePlan -NonInteractive:$NonInteractive + ) -Name ServicePlan -PromptName 'Service plan' -Value $ServicePlan -DefaultValue FC1 -NonInteractive:$NonInteractive } function Read-EppInput { @@ -115,7 +127,7 @@ function Read-EppInput { $Value = [string](Read-Host $prompt) } $Value = $Value.Trim() - if (-not $supplied -and $AllowCancel -and (-not $Value -or $Value -ieq 'Cancel')) { return $null } + if (-not $supplied -and $AllowCancel -and $Value -ieq 'Cancel') { return $null } if (-not $supplied -and -not $Value -and $DefaultValue) { $Value = $DefaultValue } try { if (-not $Value -or $Value -match '[\x00-\x1f<>]') { throw 'A nonempty value without placeholders is required.' } @@ -180,10 +192,13 @@ function Read-EppProviderName { while ($true) { if (-not $supplied) { Write-Host "Provider names: $(($Entries | ForEach-Object { $_['displayName'] } | Sort-Object) -join ', ')" - $Provider = [string](Read-Host 'Provider name (Enter cancels)') + $Provider = [string](Read-Host 'Provider name') } $Provider = $Provider.Trim() - if (-not $Provider -and -not $supplied) { return $null } + if (-not $Provider) { + Write-Warning 'A provider name is required. Choose one of the listed providers.' + continue + } $selected = @($Entries | Where-Object { $_['id'] -ieq $Provider -or $_['displayName'] -ieq $Provider }) if ($selected.Count -eq 1) { return [string]$selected[0]['id'] } $message = "Unknown provider '$Provider'. Supply a provider name, not a selection number." @@ -581,6 +596,7 @@ function Select-EppAzureContext { $refresh = $false while ($true) { $subscriptions = @(Get-EppAzureSubscriptions -Refresh:$refresh) + $currentSubscription = $subscriptions | Where-Object IsDefault | Select-Object -First 1 $refresh = $false $tenant = $TenantId $selection = $null @@ -599,7 +615,7 @@ function Select-EppAzureContext { return @{ TenantId = $subscription.TenantId; SubscriptionId = $SubscriptionId } } Write-Warning "The supplied subscription '$SubscriptionId' is not in the eligible cached accounts. It will not be replaced with another subscription." - $selection = Select-EppOption -Entries $actions -Name AzureAccount -PromptName 'Azure account action' -AllowCancel + $selection = Select-EppOption -Entries $actions -Name AzureAccount -PromptName 'Azure account action' -DefaultValue refresh -AllowCancel } else { if (-not $tenant) { @@ -608,7 +624,10 @@ function Select-EppAzureContext { $label = if ($name) { "$name [$($_.Name)]" } else { "$($_.Name) (tenant name unavailable)" } @{ id = $_.Name; displayName = $label } } | Sort-Object { $_.displayName }) - $selection = Select-EppOption -Entries ($tenants + $actions) -Name TenantId -PromptName 'Azure tenant' -AllowCancel + $defaultTenant = if ($currentSubscription) { $currentSubscription.TenantId } + elseif ($tenants.Count) { $tenants[0].id } else { 'signin' } + $selection = Select-EppOption -Entries ($tenants + $actions) -Name TenantId -PromptName 'Azure tenant' ` + -DefaultValue $defaultTenant -AllowCancel if (-not $selection) { return $null } if ($selection.id -notin @('refresh', 'signin')) { $tenant = $selection.id } } @@ -621,7 +640,10 @@ function Select-EppAzureContext { if (-not $options.Count) { Write-Warning "No eligible subscriptions are cached for tenant '$tenant'. Refresh or sign in to this tenant." } - $selection = Select-EppOption -Entries ($options + $actions) -Name SubscriptionId -PromptName 'Azure subscription' -AllowCancel + $defaultSubscription = if ($currentSubscription -and $currentSubscription.TenantId -eq $tenant) { $currentSubscription.Id } + elseif ($options.Count) { $options[0].id } else { 'signin' } + $selection = Select-EppOption -Entries ($options + $actions) -Name SubscriptionId -PromptName 'Azure subscription' ` + -DefaultValue $defaultSubscription -AllowCancel if (-not $selection) { return $null } if ($selection.id -notin @('refresh', 'signin')) { return @{ TenantId = $tenant; SubscriptionId = $selection.id } @@ -786,17 +808,24 @@ function Select-EppApplication { @{ id = 'manual'; displayName = 'Enter an application (client) ID manually' } @{ id = 'create'; displayName = 'Create a new application' } ) - $selection = Select-EppOption -Entries $entries -Name ApplicationId -PromptName 'Application' -AllowCancel + $selection = Select-EppOption -Entries $entries -Name ApplicationId -PromptName 'Application' -DefaultValue create -AllowCancel if (-not $selection) { return $null } switch ($selection.id) { 'search' { - $search = Read-EppInput -Name ApplicationSearch -Kind Text -Hint 'Beginning of the display name; Enter cancels setup' -AllowCancel + $search = Read-EppInput -Name ApplicationSearch -Kind Text -DefaultValue $search ` + -Hint 'Beginning of the display name; type Cancel to cancel' -AllowCancel if ($null -eq $search) { return $null } } 'refresh' { continue } 'manual' { - return Read-EppInput -Name ApplicationId -Kind Guid ` - -Hint 'Application (client) ID from App registrations, not Object ID; Enter cancels' -AllowCancel + while ($true) { + $clientId = Read-EppInput -Name ApplicationId -DefaultValue 'Create a new application' ` + -Hint 'Application (client) ID, not Object ID; type Cancel to cancel' -AllowCancel + if ($null -eq $clientId) { return $null } + if ($clientId -ieq 'Create a new application') { return 'create' } + try { return ConvertTo-EppGuid $clientId } + catch { Write-Warning "ApplicationId : $($_.Exception.Message)" } + } } default { return $selection.id } } @@ -919,6 +948,7 @@ function Select-EppLocation { $showAll = $false while (-not $Location) { $entries = @(if ($showAll) { $regions | Sort-Object { $_.displayName } } else { $shortlist }) + $defaultRegion = if ($entries.Count) { [string]$entries[0].id } else { '' } if (-not $entries.Count) { $message = if ($euTenant -and -not $showAll) { 'Azure returned no recommended regions with verified EU Data Boundary geography. Show all regions or enter a region name manually; verify the selection before deployment.' @@ -933,14 +963,16 @@ function Select-EppLocation { @{ id = 'all'; displayName = 'Show all physical regions returned for this subscription' } } $entries += @{ id = 'manual'; displayName = 'Enter an Azure region name manually' } - $selection = Select-EppOption -Entries $entries -Name Location -PromptName 'Azure region' -AllowCancel + $defaultSelection = if ($defaultRegion) { $defaultRegion } elseif ($showAll) { 'shortlist' } else { 'all' } + $selection = Select-EppOption -Entries $entries -Name Location -PromptName 'Azure region' -DefaultValue $defaultSelection -AllowCancel if (-not $selection) { return $null } switch ($selection.id) { 'all' { $showAll = $true } 'shortlist' { $showAll = $false } 'manual' { $example = if ($euTenant) { 'westeurope' } else { 'westus2' } - $Location = Read-EppInput -Name Location -Kind Location -Hint "Programmatic region name, for example $example; Enter cancels" -AllowCancel + $Location = Read-EppInput -Name Location -Kind Location -DefaultValue $defaultRegion ` + -Hint "Programmatic region name, for example $example; type Cancel to cancel" -AllowCancel if (-not $Location) { return $null } } default { $Location = $selection.id } @@ -1900,7 +1932,8 @@ function Get-EppCertificatePlan { $option = Select-EppOption -Entries @( @{ id = 'Reuse'; displayName = 'Keep one existing certificate and remove any other encryption credentials' } @{ id = 'Replace'; displayName = 'Create a new certificate and remove all existing encryption credentials' } - ) -Name CertificateAction -PromptName 'Encryption certificate action' -Value $action -AllowCancel -NonInteractive:$NonInteractive + ) -Name CertificateAction -PromptName 'Encryption certificate action' -Value $action -DefaultValue Replace ` + -AllowCancel -NonInteractive:$NonInteractive if (-not $option) { return $null } $action = $option.id } @@ -1933,11 +1966,11 @@ function Get-EppCertificatePlan { $selectedId = $EncryptionKeyId if (-not $selectedId -and -not $NonInteractive -and $encryptionKeys.Count -eq 1) { $selectedId = [string]$encryptionKeys[0].KeyId } $selected = Select-EppOption -Entries $choices -Name EncryptionKeyId -PromptName 'Certificate to keep' ` - -Value $selectedId -AllowCancel -NonInteractive:$NonInteractive + -Value $selectedId -DefaultValue $choices[0].id -AllowCancel -NonInteractive:$NonInteractive if (-not $selected) { return $null } $selectedKey = $encryptionKeys | Where-Object { [string]$_.KeyId -eq $selected.id } $location = Read-EppInput -Name KeyVaultCertificateId -Value $KeyVaultCertificateId ` - -Hint 'Versioned certificate URL; Enter cancels' -AllowCancel -NonInteractive:$NonInteractive + -Hint 'Versioned certificate URL; type Cancel to cancel' -AllowCancel -NonInteractive:$NonInteractive if (-not $location) { return $null } $issued = $null try { @@ -2601,24 +2634,32 @@ function Invoke-EppSetup { No Microsoft Graph PowerShell SDK or separate Graph sign-in is required. Interactive setup discovers cached Azure CLI accounts before asking for other settings. Choose a tenant and subscription, or explicitly refresh/sign in when the list is incomplete. + Enter accepts the displayed default; 0 or Cancel cancels where offered. + Provider selection has no default, and final deployment approval defaults to No. .PARAMETER TenantId Customer tenant ID. When omitted interactively, select from cached Azure subscriptions, or resolve it from an explicitly supplied subscription. Required in noninteractive mode. + The interactive default is the current CLI tenant, or the first eligible tenant. .PARAMETER SubscriptionId Azure subscription ID. When omitted interactively, select an enabled user subscription in the chosen tenant. Required in noninteractive mode. + Defaults to the current CLI subscription in that tenant, or its first eligible subscription. .PARAMETER ApplicationId Existing application client ID. When omitted interactively, use the current CLI tenant to choose/search an app registration, enter its client ID, or plan a new application. + Enter at the picker or manual client-ID prompt defaults to creating a new application. + The new application display name defaults to EPP Application when Enter is pressed. A new application is created only after final deployment approval. Required in noninteractive mode. .PARAMETER Provider Provider name, never a numbered selection. When omitted interactively, read the app's allowed-tenants configuration: enter a name if none match, confirm one match, or choose by name among multiple matches. New applications show supported providers alphabetically. + Blank provider-name input prompts again; there is no default provider. Explicit input confirms the choice; required unattended. .PARAMETER Location Azure region name. When omitted interactively, choose an Azure-recommended shortlist, show all physical regions returned for the subscription, or enter the name manually. + Enter accepts the first region in the displayed list. EU tenants receive EU Data Boundary recommendations; explicit out-of-boundary choices warn. Recommendations do not guarantee plan availability or quota. Required noninteractively. .PARAMETER EndpointRegion @@ -2637,6 +2678,8 @@ function Invoke-EppSetup { .PARAMETER CertificateAction Create when no encryption certificate is registered, Reuse a selected certificate, or Replace all registered encryption certificates after explicit approval. + Interactive selection defaults to Replace when encryption certificates exist. + Unattended runs with existing certificates still require an explicit action. .PARAMETER EncryptionKeyId App-registration key ID to keep when reusing a certificate. Required for unattended reuse. .PARAMETER KeyVaultCertificateId @@ -2705,6 +2748,8 @@ function Invoke-EppSetup { $workingDirectory = Join-Path ([IO.Path]::GetTempPath()) "epp-setup-$([Guid]::NewGuid().ToString('N'))" try { New-Item -ItemType Directory -Path $workingDirectory | Out-Null + Write-Host "`nExternal Phone Provider (EPP) setup" -ForegroundColor Cyan + Write-Host 'Connect Microsoft Entra ID to your phone provider for SMS and voice one-time passwords.' $azureContext = Select-EppAzureContext -TenantId $TenantId -SubscriptionId $SubscriptionId -NonInteractive:$NonInteractive if (-not $azureContext) { Write-Host 'Cancelled. No Azure resources or application credentials were changed.' -ForegroundColor Yellow @@ -2726,11 +2771,7 @@ function Invoke-EppSetup { if ($inputs.ApplicationId -ceq 'create') { $inputs.ApplicationId = $null $inputs.NewApplicationDisplayName = Read-EppInput -Name ApplicationDisplayName -Kind ApplicationName ` - -PromptName 'New application display name' -Hint 'Enter cancels' -AllowCancel - if (-not $inputs.NewApplicationDisplayName) { - Write-Host 'Cancelled. No Azure resources or app registrations were changed.' -ForegroundColor Yellow - return - } + -PromptName 'New application display name' -DefaultValue 'EPP Application' } if (-not [string]::IsNullOrWhiteSpace($Location) -or $NonInteractive) { $Location = Read-EppInput -Name Location -Value $Location -Kind Location -NonInteractive diff --git a/setup/tests/Accounts.Tests.ps1 b/setup/tests/Accounts.Tests.ps1 index c7d816e..f266066 100644 --- a/setup/tests/Accounts.Tests.ps1 +++ b/setup/tests/Accounts.Tests.ps1 @@ -198,7 +198,7 @@ exit $ExitCode Reset-TestState @($a, $b, $c) @('1', '2') $selected = Select-EppAzureContext Assert ($selected.TenantId -ceq $tenantA -and $selected.SubscriptionId -ceq $subB) 'Select a tenant, then its nondefault subscription.' - Assert (($script:Prompts -join ',') -ceq 'Azure tenant number or name,Azure subscription number or name') 'Ask for tenant and subscription before other settings.' + Assert (($script:Prompts -join ',') -ceq 'Azure tenant number or name [1],Azure subscription number or name [1]') 'Show the defaults while asking for tenant and subscription before other settings.' $messages = $script:Messages -join "`n" foreach ($text in @("Contoso [$tenantA]", $subA, $subB, 'current CLI default', 'operator@contoso.test')) { Assert ($messages.Contains($text)) "Selection must show '$text'." @@ -206,6 +206,29 @@ exit $ExitCode Assert (-not $messages.Contains($subC)) 'Do not offer subscriptions from another tenant.' Assert ($script:Calls.Count -eq 1) 'Selecting a cached account must not refresh, sign in, or change the CLI default.' + foreach ($blank in @('', ' ')) { + $current = @{} + $c; $current.isDefault = $true + $notCurrent = @{} + $a; $notCurrent.isDefault = $false + Reset-TestState @($notCurrent, $b, $current) @($blank, $blank) + $selected = Select-EppAzureContext + Assert ($selected.TenantId -ceq $tenantB -and $selected.SubscriptionId -ceq $subC) 'Enter must prefer the current CLI context, not the first alphabetic tenant.' + Assert ($script:Prompts[0] -ceq 'Azure tenant number or name [2]' -and $script:Calls.Count -eq 1) 'Display the actual default option without changing the CLI account.' + Reset-TestState @($notCurrent, $b, $current) @($blank) + $selected = Select-EppAzureContext -TenantId $tenantA + Assert ($selected.TenantId -ceq $tenantA -and $selected.SubscriptionId -ceq $subA) 'Within an explicit tenant, default to its first eligible subscription rather than another tenant.' + Reset-TestState @($notCurrent, $b, $c) @($blank, $blank) + $selected = Select-EppAzureContext + Assert ($selected.TenantId -ceq $tenantA -and $selected.SubscriptionId -ceq $subA) 'With no current CLI default, use the first listed eligible tenant and subscription.' + } + Reset-TestState @($a, $b) @('', '') + $selected = Select-EppAzureContext + Assert ($selected.SubscriptionId -ceq $subA) 'Enter must accept the current default subscription.' + Assert (-not ($script:Messages -join "`n").Contains('also Enter')) 'Cancel must be an explicit choice, not the blank-input default.' + $current = @{} + $b; $current.isDefault = $true + Reset-TestState @($notCurrent, $current) @('', '') + $selected = Select-EppAzureContext + Assert ($selected.SubscriptionId -ceq $subB -and $script:Prompts[1] -ceq 'Azure subscription number or name [2]') 'Prefer a nonfirst current subscription and display its correct default number.' + Reset-TestState @($a) @('1', '1') $selected = Select-EppAzureContext Assert ($selected.SubscriptionId -ceq $subA -and $script:Prompts.Count -eq 2) 'Even a single cached subscription requires an explicit interactive choice.' @@ -255,10 +278,10 @@ exit $ExitCode $selected = Select-EppAzureContext -TenantId $tenantA Assert ($selected.SubscriptionId -ceq $subB -and $script:Prompts.Count -eq 2) 'Refresh the subscription menu while preserving an explicit tenant.' - Reset-TestState @() @('signin', $tenantA, $subA) + Reset-TestState @() @('', '', '') $script:LoginJson = ConvertTo-Json -InputObject @($a) -Depth 8 $selected = Select-EppAzureContext - Assert ($selected.SubscriptionId -ceq $subA -and $script:Calls.Count -eq 3) 'An empty cache can be populated through an explicit sign-in choice.' + Assert ($selected.SubscriptionId -ceq $subA -and $script:Calls.Count -eq 3) 'With no eligible accounts, Enter accepts the displayed sign-in action before selecting the populated defaults.' Assert ($script:Calls[1] -ceq 'login --use-device-code --output none') 'Do not guess a tenant before the user can select one.' Reset-TestState @($a) @('signin', $subC) $script:LoginJson = ConvertTo-Json -InputObject @($a, $c) -Depth 8 @@ -273,7 +296,7 @@ exit $ExitCode Assert-Throws { Select-EppAzureContext } 'sign-in rejected|refresh denied' Assert ($script:Answers.Count -eq 0) 'A failed account operation must surface its error, not silently choose another context.' } - foreach ($cancel in @('', '0', 'Cancel')) { + foreach ($cancel in @('0', 'Cancel')) { Reset-TestState @($a) @($cancel) Assert ($null -eq (Select-EppAzureContext)) 'Tenant cancellation must return no selection.' Reset-TestState @($a) @($tenantA, $cancel) @@ -317,10 +340,12 @@ exit $ExitCode $setup.ApplicationId = $applicationId Reset-TestState @($a, $b) @($tenantA, $subB) Assert-Throws { Invoke-EppSetup @setup -ForceAuthentication } '^preflight-boundary$' - Assert (($script:Prompts -join ',') -ceq 'Azure tenant number or name,Azure subscription number or name') 'Account selection must run while explicit application and region inputs skip their pickers.' + Assert ($script:Messages[0].Trim() -ceq 'External Phone Provider (EPP) setup') 'Spell out EPP before account selection introduces the acronym.' + Assert ($script:Messages[1] -ceq 'Connect Microsoft Entra ID to your phone provider for SMS and voice one-time passwords.') 'Explain the purpose of setup in customer-facing language.' + Assert (($script:Prompts -join ',') -ceq 'Azure tenant number or name [1],Azure subscription number or name [1]') 'Account selection must run while explicit application and region inputs skip their pickers.' Assert ($script:ContextInputs.TenantId -ceq $tenantA -and $script:ContextInputs.SubscriptionId -ceq $subB -and $script:ContextInputs.ApplicationId -ceq $applicationId -and $script:ForceAuthentication) 'Forward the selected IDs and authentication choice to existing preflight.' - Reset-TestState @($a) @('') + Reset-TestState @($a) @('0') $result = Invoke-EppSetup @setup Assert ($null -eq $result -and $script:ContextCalls -eq 0 -and $script:Prompts.Count -eq 1) 'Cancellation must stop before remaining inputs, Graph sign-in, or deployment.' Reset-TestState @($a) diff --git a/setup/tests/Applications.Tests.ps1 b/setup/tests/Applications.Tests.ps1 index 27fcc0e..28ffbeb 100644 --- a/setup/tests/Applications.Tests.ps1 +++ b/setup/tests/Applications.Tests.ps1 @@ -32,10 +32,11 @@ try { } $script:Answers = [Collections.Generic.Queue[string]]::new() $script:Calls = [Collections.Generic.List[string]]::new() + $script:Prompts = [Collections.Generic.List[string]]::new() $script:Messages = [Collections.Generic.List[string]]::new() $script:Warnings = [Collections.Generic.List[string]]::new() function script:Reset-TestState([string[]] $Answers = @()) { - $script:Answers.Clear(); $script:Calls.Clear(); $script:Messages.Clear(); $script:Warnings.Clear() + $script:Answers.Clear(); $script:Calls.Clear(); $script:Prompts.Clear(); $script:Messages.Clear(); $script:Warnings.Clear() foreach ($answer in $Answers) { $script:Answers.Enqueue($answer) } $script:Approved = $false; $script:EmptyApplications = $false; $script:ChangedOperator = $false $script:CreationFailure = ''; $script:PackageFailure = ''; $script:GroupExists = $false @@ -56,6 +57,7 @@ try { } function script:Read-Host { param($Prompt) + $script:Prompts.Add($Prompt) Assert ($script:Answers.Count -gt 0) "Unexpected prompt: $Prompt" $answer = $script:Answers.Dequeue() if ($Prompt -like 'Proceed with deployment?*') { @@ -76,9 +78,9 @@ try { return @{ Id = $id; Account = 'operator@contoso.test' } } function script:Get-EppTenantRegionScope { param($TenantId); return 'NA' } - function script:New-TestApplication([string] $ClientId) { + function script:New-TestApplication([string] $ClientId, [string] $DisplayName = $script:ApplicationName) { return @{ - id = $script:ObjectId; appId = $ClientId; displayName = $script:ApplicationName + id = $script:ObjectId; appId = $ClientId; displayName = $DisplayName signInAudience = 'AzureADMyOrg'; api = @{ requestedAccessTokenVersion = $null } appRoles = @(); identifierUris = @(); keyCredentials = @(); tokenEncryptionKeyId = $null } @@ -91,10 +93,10 @@ try { return @{ value = @(if (-not $script:EmptyApplications) { New-TestApplication $script:ExistingAppId }) } } Assert ($Method -ceq 'POST' -and $script:Approved) 'Application creation requires final deployment approval.' - Assert ($Body.displayName -ceq $script:ApplicationName -and $Body.signInAudience -ceq 'AzureADMyOrg') 'Create the exact approved application.' + Assert ($Body.displayName -ceq $script:PlanInputs.NewApplicationDisplayName -and $Body.signInAudience -ceq 'AzureADMyOrg') 'Create the exact approved application.' $script:Calls.Add('create'); $script:Creates++ if ($script:CreationFailure) { throw $script:CreationFailure } - return New-TestApplication $script:AppId + return New-TestApplication -ClientId $script:AppId -DisplayName $Body.displayName } function script:Get-EppApplication { param($Inputs, $ClientId, $ObjectId) @@ -255,10 +257,18 @@ try { Assert (($script:Messages -join "`n").Contains("Create '$script:ApplicationName' after approval")) 'Show the exact application creation action in the final plan.' Assert ($script:Answers.Count -eq 0 -and -not (Test-Path -LiteralPath $Directory)) 'Do not write deployment output before approval.' } - foreach ($answers in @(@(''), @('create', ''), @('create', $script:ApplicationName, ''))) { - Reset-TestState $answers - Assert ($null -eq (Invoke-EppSetup @setup)) 'Allow cancellation at application, name, and provider selection.' - Assert ($null -eq $script:PlanInputs -and $script:Creates -eq 0) 'Early cancellation must stop before plan approval or creation.' + Reset-TestState @('0') + Assert ($null -eq (Invoke-EppSetup @setup)) 'Allow cancellation in the application picker.' + Assert ($null -eq $script:PlanInputs -and $script:Creates -eq 0) 'Early cancellation must stop before plan approval or creation.' + foreach ($blank in @('', ' ')) { + Reset-TestState @($blank, $blank, '', ' ', 'soprano', 'No') + $null = Invoke-EppSetup @setup + Assert ($script:Prompts -contains 'Application number or name [5]') 'Enter at the application picker must choose the displayed create-new default.' + Assert ($script:PlanInputs.NewApplicationDisplayName -ceq 'EPP Application') 'Blank application names must accept the displayed default instead of canceling.' + Assert ($script:Prompts -contains 'New application display name [EPP Application]') 'Display the application-name default without a cancellation hint.' + Assert (@($script:Prompts | Where-Object { $_ -ceq 'Provider name' }).Count -eq 3 -and + $script:Warnings.Count -eq 2) 'Blank provider input must prompt again until a provider is selected.' + Assert ($script:Answers.Count -eq 0 -and $script:Creates -eq 0) 'Continue to the final approval without creating an app early.' } Reset-TestState @('create', ('x' * 257), "bad`nname", $script:ApplicationName, '1', 'TELESIGN', 'No') $null = Invoke-EppSetup @setup @@ -338,7 +348,10 @@ try { Assert (Test-Path -LiteralPath (Join-Path $setup.OutputDirectory "application-$script:AppId.json")) 'Keep the new client ID available if resolved resource names conflict.' foreach ($provider in @('soprano', 'telesign')) { - Reset-TestState @('create', $script:ApplicationName, $provider, 'Yes') + $nameAnswer = if ($provider -eq 'soprano') { '' } else { $script:ApplicationName } + $expectedName = if ($provider -eq 'soprano') { 'EPP Application' } else { $script:ApplicationName } + $applicationAnswer = if ($provider -eq 'soprano') { '' } else { 'create' } + Reset-TestState @($applicationAnswer, $nameAnswer, $provider, 'Yes') $setup.OutputDirectory = Join-Path $Directory "complete-$provider" $script:StopAtInitialization = $false $result = Invoke-EppSetup @setup -PassThru @@ -348,6 +361,7 @@ try { Assert ($summaries.Count -eq 1) 'Persist a separate deployment success summary.' $summary = Read-EppJson $summaries[0].FullName $record = Read-EppJson (Join-Path $setup.OutputDirectory "application-$script:AppId.json") + Assert ($record.displayName -ceq $expectedName) 'Create and persist either the accepted default name or the supplied custom name.' Assert ($summary.applicationId -ceq $record.applicationId -and $summary.provider -ceq $provider -and $summary.packageSha256 -ceq ('b' * 64)) 'Record the deployed client ID, provider, and final package hash.' foreach ($key in $record.resources.Keys) { diff --git a/setup/tests/Certificates.Tests.ps1 b/setup/tests/Certificates.Tests.ps1 index 8592035..adeb160 100644 --- a/setup/tests/Certificates.Tests.ps1 +++ b/setup/tests/Certificates.Tests.ps1 @@ -318,7 +318,14 @@ try { Assert-Throws { Get-EppCertificatePlan @planArgs -CertificateAction Reuse -EncryptionKeyId $keyId -NonInteractive } '\-KeyVaultCertificateId is required' $script:Answers.Enqueue('') $script:Calls.Clear() - Assert ($null -eq (Get-EppCertificatePlan @planArgs)) 'Enter must cancel without choosing a certificate.' + $beforeWrites = $script:GraphWrites + $defaultPlan = Get-EppCertificatePlan @planArgs + Assert ($defaultPlan.Action -ceq 'Replace' -and $defaultPlan.RemoveKeyIds.Count -eq 1 -and + $defaultPlan.RemoveKeyIds[0] -ceq $keyId) 'Enter must plan cleanup and a new encryption certificate, preserving non-encryption credentials.' + Assert ($script:Prompts[-1] -ceq 'Encryption certificate action number or name [2]') 'Display replacement as the certificate default.' + Assert ($script:Calls.Count -eq 0 -and $script:GraphWrites -eq $beforeWrites) 'Accepting the replacement default must not change Azure or Graph before final approval.' + $script:Answers.Enqueue('0') + Assert ($null -eq (Get-EppCertificatePlan @planArgs)) 'An explicit cancellation must stop certificate selection.' Assert ($script:Calls.Count -eq 0) 'Cancelling the decision must not contact Key Vault.' $script:Answers.Enqueue('Reuse'); $script:Answers.Enqueue($certificateId) $singlePlan = Get-EppCertificatePlan @planArgs @@ -326,7 +333,11 @@ try { Assert ($script:Answers.Count -eq 0) 'Single-certificate reuse should only ask for the action and location.' $script:Application.KeyCredentials = @($first, (New-TestCredential $other $secondId), (New-TestCredential $certificate $thirdId), $signing) - $script:Answers.Enqueue('Reuse'); $script:Answers.Enqueue($keyId); $script:Answers.Enqueue($certificateId) + $script:Answers.Enqueue(' ') + $defaultPlan = Get-EppCertificatePlan @planArgs + Assert ($defaultPlan.Action -ceq 'Replace' -and $defaultPlan.RemoveKeyIds.Count -eq 3 -and + $defaultPlan.RemoveKeyIds -notcontains $signing.KeyId) 'The replacement default must plan removal of all encryption credentials and no signing credentials.' + $script:Answers.Enqueue('Reuse'); $script:Answers.Enqueue(''); $script:Answers.Enqueue($certificateId) $multiplePlan = Get-EppCertificatePlan @planArgs Assert ($multiplePlan.RemoveKeyIds.Count -eq 2 -and $multiplePlan.RemoveKeyIds -contains $secondId -and $multiplePlan.RemoveKeyIds -contains $thirdId) 'Keep the selected key ID even when another entry has the same thumbprint.' @@ -595,9 +606,12 @@ try { } Set-TestBundle $certificate $script:Application.KeyCredentials = @($first, $signing) - $script:Answers.Enqueue(''); $script:Calls.Clear() + $script:Answers.Enqueue('0'); $script:Calls.Clear() Invoke-EppSetup @setupInputs Assert ($script:Calls.Count -eq 0 -and $null -eq $context.CertificatePlan) 'Cancelling certificate onboarding must stop before package preparation or deployment.' + $script:Answers.Enqueue('') + Assert-Throws { Invoke-EppSetup @setupInputs } '^package-boundary$' + Assert ($context.CertificatePlan.Action -ceq 'Replace' -and $script:Calls.Count -eq 0) 'The setup entry point must stage the replacement default without making changes.' Assert-Throws { Invoke-EppSetup @setupInputs -NonInteractive -ApproveDeployment } '\-CertificateAction is required' Assert-Throws { Invoke-EppSetup @setupInputs @reuseArgs -NonInteractive } '^package-boundary$' Assert ($context.CertificatePlan.Action -ceq 'Reuse' -and $context.CertificatePlan.KeyId -ceq $keyId) 'The entry point must forward the explicit reuse inputs to preflight.' diff --git a/setup/tests/ProviderConfiguration.Tests.ps1 b/setup/tests/ProviderConfiguration.Tests.ps1 index cb43a5a..a3144bb 100644 --- a/setup/tests/ProviderConfiguration.Tests.ps1 +++ b/setup/tests/ProviderConfiguration.Tests.ps1 @@ -115,7 +115,7 @@ try { $script:Restriction = $restriction $script:Application.SignInAudience = $audience $selected = Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory - Assert ($selected -ceq 'telesign' -and $script:Prompts[0] -ceq 'Provider name (Enter cancels)') 'No configuration must prompt for a provider name.' + Assert ($selected -ceq 'telesign' -and $script:Prompts[0] -ceq 'Provider name') 'No configuration must prompt for a provider name.' Assert (($script:Messages -join "`n").Contains('configuration is included in the deployment approval')) 'Do not configure an existing app before deployment approval.' Assert ($script:Messages -contains 'Provider names: Soprano, Telesign') 'List provider names alphabetically, independently of catalog order.' Assert ($script:Patches.Count -eq 0) 'Provider resolution is read-only.' @@ -124,8 +124,9 @@ try { Reset-TestState @('1', 'soprano') Assert ((Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory) -ceq 'soprano') 'Reject a provider number, then accept a name.' Assert ($script:Warnings.Count -eq 1 -and -not (($script:Messages -join "`n") -match '\[\d+\]')) 'Do not number or rank provider choices.' - Reset-TestState @('') - Assert ($null -eq (Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory)) 'Empty provider input cancels.' + Reset-TestState @('', ' ', 'telesign') + Assert ((Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory) -ceq 'telesign') 'Blank provider input must prompt again, not cancel or select a default.' + Assert ($script:Warnings.Count -eq 2 -and $script:Prompts.Count -eq 3 -and $script:Patches.Count -eq 0) 'Require a provider choice without changing application configuration.' foreach ($answer in @('Yes', 'y', ' YES ', 'No', 'n', '')) { Reset-TestState @($answer) @@ -136,12 +137,12 @@ try { else { Assert ($null -eq $selected) 'Do not silently accept the single configured provider.' } Assert ($script:Prompts[0] -match 'configured for Telesign.*Yes or Y.*No or N') 'Show the configured provider and accepted responses in the confirmation.' } - Reset-TestState @('Soprano') + Reset-TestState @('', 'Soprano') $script:Application.SignInAudience = 'AzureADMultipleOrgs' $script:Restriction = New-TestRestriction @($telesign.TenantId, $soprano.TenantId) $selected = Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory - Assert ($selected -ceq 'soprano' -and $script:Prompts.Count -eq 1 -and - $script:Prompts[0] -ceq 'Provider name (Enter cancels)') 'Choose among multiple configured providers by name, not number.' + Assert ($selected -ceq 'soprano' -and $script:Prompts.Count -eq 2 -and $script:Warnings.Count -eq 1 -and + $script:Prompts[0] -ceq 'Provider name') 'Require a name when choosing among multiple configured providers.' Assert (-not (($script:Messages -join "`n") -match '\[\d+\]')) 'Configured providers must not be numbered.' Reset-TestState $selected = Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory -Provider ' TELESIGN ' -NonInteractive diff --git a/setup/tests/Providers.Tests.ps1 b/setup/tests/Providers.Tests.ps1 index 6c8541b..4a0b217 100644 --- a/setup/tests/Providers.Tests.ps1 +++ b/setup/tests/Providers.Tests.ps1 @@ -229,7 +229,7 @@ try { Assert-Throws { Invoke-EppSetup @inputs -OutputDirectory $Directory } '^preflight-reached$' - Assert (($script:Prompts -join ',') -ceq 'Provider name (Enter cancels)') 'Detect tenant scope without a picker; provider selection must use a name.' + Assert (($script:Prompts -join ',') -ceq 'Provider name') 'Detect tenant scope without a picker; provider selection must use a name.' Assert ($script:Answers.Count -eq 0) 'All expected prompt answers must be consumed.' $defaults = @{} + $inputs diff --git a/setup/tests/Selections.Tests.ps1 b/setup/tests/Selections.Tests.ps1 index 6ed8ae6..82f843b 100644 --- a/setup/tests/Selections.Tests.ps1 +++ b/setup/tests/Selections.Tests.ps1 @@ -137,6 +137,26 @@ try { return ,$script:DiscoveryMetadata } + $options = @(@{ id = 'first'; displayName = 'Alpha' }, @{ id = 'second'; displayName = 'Beta' }) + foreach ($blank in @('', ' ')) { + Reset-TestState @($blank) + Assert ((Select-EppOption -Entries $options -Name Choice -DefaultValue second -AllowCancel).id -ceq 'second') 'Enter must select the specified default, not always the first entry.' + Assert ($script:Prompts[0] -ceq 'Choice number or name [2]' -and + $script:Messages -contains ' [2] Beta (default)' -and $script:Messages -contains ' [0] Cancel') 'Mark the default and keep cancellation explicit.' + Reset-TestState @($blank) + Assert ((Read-EppInput -Name Prefix -Kind Prefix -DefaultValue epp -AllowCancel) -ceq 'epp') 'A text-input default must also take precedence over blank-input cancellation.' + } + Reset-TestState @('', 'first') + Assert ((Select-EppOption -Entries $options -Name Choice -AllowCancel).id -ceq 'first' -and + $script:Warnings.Count -eq 1) 'When no default is supplied, require a choice rather than canceling.' + Reset-TestState @('', 'prefix') + Assert ((Read-EppInput -Name Search -AllowCancel) -ceq 'prefix' -and $script:Warnings.Count -eq 1) 'Required text without a known default must reprompt on Enter.' + Reset-TestState + Assert-Throws { Select-EppOption -Entries $options -Name Choice -DefaultValue invalid } 'Unknown default' + Assert-Throws { Select-EppOption -Entries $options -Name Choice -DefaultValue first -NonInteractive } 'Choice is required' + Assert ((Select-EppOption -Entries $options -Name Choice -Value second -DefaultValue first -NonInteractive).id -ceq 'second') 'Explicit and unattended inputs must not be replaced by interactive defaults.' + Assert ($script:Prompts.Count -eq 0) 'Invalid defaults and unattended inputs must not prompt.' + foreach ($scope in @('EU', 'NA', 'AS', 'OC', 'AF', 'SA', 'AN', 'JPR', 'WW')) { Reset-TestState $script:DiscoveryMetadata.tenant_region_scope = $scope.ToLowerInvariant() @@ -233,14 +253,23 @@ try { Reset-TestState $script:GraphError = 'application lookup denied' Assert-Throws { Select-EppApplication -Inputs (New-TestInputs) } 'application lookup denied' - foreach ($answers in @(@(''), @('manual', ''), @('search', ''))) { + foreach ($answers in @(@(''), @(' '), @('manual', ''))) { + Reset-TestState $answers + Assert ((Select-EppApplication -Inputs (New-TestInputs)) -ceq 'create') 'Blank application selection or manual client-ID input must default to creating a new app.' + } + Reset-TestState @('') + $script:Applications = @() + Assert ((Select-EppApplication -Inputs (New-TestInputs)) -ceq 'create' -and + $script:Prompts[0] -ceq 'Application number or name [4]') 'The create default must follow the menu entry even when no existing apps are listed.' + foreach ($answers in @(@('0'), @('Cancel'), @('manual', 'Cancel'), @('search', 'Cancel'))) { Reset-TestState $answers Assert ($null -eq (Select-EppApplication -Inputs (New-TestInputs))) 'Application picker/manual/search cancellation must stop setup.' } - Reset-TestState @('westus2') + Reset-TestState @('') $inputs = New-TestInputs - Assert ((Select-EppLocation -Inputs $inputs -ForceAuthentication) -ceq 'westus2') 'Choose a canonical recommended region.' + Assert ((Select-EppLocation -Inputs $inputs -ForceAuthentication) -ceq 'westus2') 'Enter chooses the first available recommended region.' + Assert ($script:Prompts[0] -ceq 'Azure region number or name [1]') 'Show the region default in the prompt.' $text = $script:Messages -join "`n" Assert ($text.Contains('WESTUS2 (westus2) - Azure recommended') -and -not $text.Contains('EASTUS2 (eastus2)')) 'The shortlist must use live Recommended classification, not hardcoded claims.' Assert ($text.Contains('not a latency/cost ranking') -and $text.Contains('quota')) 'Do not imply a recommendation guarantees capacity or price.' @@ -248,6 +277,8 @@ try { Reset-TestState @('all', 'eastus2') Assert ((Select-EppLocation -Inputs (New-TestInputs)) -ceq 'eastus2') 'Allow other physical subscription regions through the all-regions view.' Assert (-not ($script:Messages -join "`n").Contains('GLOBAL (global)')) 'Logical locations must not be offered.' + Reset-TestState @('all', '') + Assert ((Select-EppLocation -Inputs (New-TestInputs)) -ceq 'centralus') 'The all-regions view must default to its own first displayed region, not the shortlist default.' Reset-TestState @('all', 'shortlist', 'westus2') Assert ((Select-EppLocation -Inputs (New-TestInputs)) -ceq 'westus2') 'Return from all regions to the recommended shortlist.' $euRegions = @( @@ -262,10 +293,10 @@ try { (New-TestRegion unclassified -Geography '' -GeographyGroup Europe), (New-TestRegion franceother Other -Geography France -GeographyGroup Europe) ) - Reset-TestState @('westeurope') + Reset-TestState @('') $inputs = New-TestInputs; $inputs.TenantRegionScope = 'EU' $script:RegionsJson = @{ value = $euRegions } | ConvertTo-Json -Depth 8 - Assert ((Select-EppLocation -Inputs $inputs) -ceq 'westeurope') 'EU tenants can select the dynamic EU Data Boundary shortlist.' + Assert ((Select-EppLocation -Inputs $inputs) -ceq 'westeurope') 'The EU default must come from the filtered EU Data Boundary shortlist.' $text = $script:Messages -join "`n" foreach ($region in @('WESTEUROPE', 'NORTHEUROPE', 'FRANCECENTRAL', 'NORWAYEAST', 'SWITZERLANDNORTH', 'BELGIUMCENTRAL')) { Assert ($text.Contains($region)) 'Use live EU/EFTA geography metadata, including new region names.' @@ -297,7 +328,10 @@ try { Reset-TestState @('manual', 'eastus') $null = Select-EppLocation -Inputs $inputs Assert ($script:Warnings[0].Contains('no recommended regions with verified EU Data Boundary geography')) 'An empty EU shortlist must not silently fall back to global recommendations.' - Assert (@($script:Messages | Where-Object { $_ -match ' - Azure recommended$' }).Count -eq 0) 'Do not present global regions as EU recommendations.' + Assert (@($script:Messages | Where-Object { $_ -match ' - Azure recommended(?: \(default\))?$' }).Count -eq 0) 'Do not present global regions as EU recommendations.' + Reset-TestState @('', 'Cancel') + $null = Select-EppLocation -Inputs $inputs + Assert ($script:Prompts.Count -eq 2 -and $script:Warnings[0].Contains('no recommended regions')) 'An empty EU shortlist defaults to showing all regions, not silently selecting a global region.' foreach ($scope in @('NA', 'AS', 'OC', 'AF', 'SA', 'AN', 'JPR', 'WW')) { Reset-TestState @('westus2') $inputs = New-TestInputs; $inputs.TenantRegionScope = $scope @@ -308,6 +342,8 @@ try { } Reset-TestState @('manual', 'customregion5') Assert ((Select-EppLocation -Inputs (New-TestInputs)) -ceq 'customregion5') 'Allow a typed canonical region for later existing availability validation.' + Reset-TestState @('manual', '') + Assert ((Select-EppLocation -Inputs (New-TestInputs)) -ceq 'westus2') 'Manual region entry must show and accept the same available region default.' Reset-TestState @('manual', 'West US 2', 'westus2') Assert ((Select-EppLocation -Inputs (New-TestInputs)) -ceq 'westus2' -and $script:Warnings.Count -eq 1) 'Validate and reprompt malformed manual locations.' foreach ($regions in @(@(), @((New-TestRegion eastus2 Other)), @((New-TestRegion westus2)))) { @@ -320,7 +356,7 @@ try { ForEach-Object { New-TestRegion $_ } $script:RegionsJson = @{ value = @($many) } | ConvertTo-Json -Depth 8 $null = Select-EppLocation -Inputs (New-TestInputs) - Assert (@($script:Messages | Where-Object { $_ -match ' - Azure recommended$' }).Count -eq 8) 'Bound the common recommendation shortlist to eight regions.' + Assert (@($script:Messages | Where-Object { $_ -match ' - Azure recommended(?: \(default\))?$' }).Count -eq 8) 'Bound the common recommendation shortlist to eight regions.' foreach ($response in @('[]', '{"value":null}', '{"value":[{}]}')) { Reset-TestState $script:RegionsJson = $response @@ -343,7 +379,7 @@ try { $script:CliAvailable = $false Assert-Throws { Select-EppLocation -Inputs (New-TestInputs) } 'Azure CLI is not installed' Assert ($script:Calls.Count -eq 0) 'Missing Azure CLI must fail before attempting authentication.' - foreach ($answers in @(@(''), @('manual', ''))) { + foreach ($answers in @(@('0'), @('Cancel'), @('manual', 'Cancel'))) { Reset-TestState $answers Assert ($null -eq (Select-EppLocation -Inputs (New-TestInputs))) 'Region picker/manual cancellation must stop setup.' } @@ -381,7 +417,7 @@ try { } Reset-TestState @($script:AppId, 'westus2') Assert-Throws { Invoke-EppSetup @setup -ForceAuthentication } '^selection-preflight-boundary$' - Assert (($script:Prompts -join ',') -ceq 'Application number or name,Azure region number or name') 'Use application and region selection instead of raw-ID/location prompts.' + Assert (($script:Prompts -join ',') -ceq 'Application number or name [5],Azure region number or name [1]') 'Use application and region selection with displayed defaults.' Assert ($script:PreflightInputs.ApplicationId -ceq $script:AppId -and $script:PreflightInputs.Location -ceq 'westus2') 'Forward selected values to the existing preflight and deployment inputs.' Assert ($script:PreflightInputs.TenantRegionScope -ceq 'NA' -and $script:DiscoveryCalls.Count -eq 1) 'Discover and preserve tenant scope before selecting a location.' Assert ($script:PreflightInputs.AzureSelectionAuthenticated) 'Pass the shared CLI authentication state.' diff --git a/setup/tests/ServicePlans.Tests.ps1 b/setup/tests/ServicePlans.Tests.ps1 index 66653f1..db1870d 100644 --- a/setup/tests/ServicePlans.Tests.ps1 +++ b/setup/tests/ServicePlans.Tests.ps1 @@ -78,6 +78,10 @@ try { Assert-Throws { Get-EppServicePlan -ServicePlan Y1 -NonInteractive } 'Unknown ServicePlan' $script:Answers.Enqueue('1') Assert ((Get-EppServicePlan).id -ceq 'FC1') 'Option 1 must select Flex Consumption.' + foreach ($blank in @('', ' ')) { + $script:Answers.Enqueue($blank) + Assert ((Get-EppServicePlan).id -ceq 'FC1') 'Enter must accept the Flex Consumption default.' + } $script:Answers.Enqueue('2') Assert ((Get-EppServicePlan).id -ceq 'EP1') 'Option 2 must select Premium.' Assert (($script:Messages -join "`n") -match 'Service plan selection') 'The selection section must have its requested heading.'