From 4c9769cbf2ec2822a46887446901d7c1a6c71277 Mon Sep 17 00:00:00 2001 From: James Xian Date: Thu, 22 Oct 2026 10:09:21 -0700 Subject: [PATCH] Add approval-gated application creation to guided setup Offer new app registration with alphabetized provider choices while preserving existing-app setup. Create only after final deployment approval and save the new client ID for recovery. Update setup guidance and offline regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f1df639-4c59-44b0-b761-40fabc6ae26d --- .github/workflows/ci.yml | 3 + README.md | 6 +- docs/GETTING-STARTED.md | 30 +- docs/ONBOARDING.md | 7 +- setup/Setup-Epp.ps1 | 11 +- setup/docs/README.md | 69 +++- setup/docs/Troubleshooting.md | 10 +- setup/support/Epp.Graph.ps1 | 18 + setup/support/Epp.Setup.psm1 | 174 ++++++++-- setup/tests/Applications.Tests.ps1 | 367 ++++++++++++++++++++ setup/tests/Graph.Tests.ps1 | 49 ++- setup/tests/ProviderConfiguration.Tests.ps1 | 32 ++ 12 files changed, 712 insertions(+), 64 deletions(-) create mode 100644 setup/tests/Applications.Tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b827c5..387cbce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Test application and region selection without Azure access shell: pwsh run: ./setup/tests/Selections.Tests.ps1 + - name: Test new application creation without Azure access + shell: pwsh + run: ./setup/tests/Applications.Tests.ps1 - name: Test deployment progress without Azure access shell: pwsh run: ./setup/tests/Progress.Tests.ps1 diff --git a/README.md b/README.md index 43f4c9c..e35c478 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ Guided setup deploys **one regional endpoint** using JavaScript by default, with ## Requirements -- Use a separate test environment with a Microsoft Entra ID tenant, Azure subscription, app - registration, and unique resource name prefix. +- Use a separate test environment with a Microsoft Entra ID tenant, Azure subscription, and + unique resource name prefix. Supply a dedicated app registration or create one during guided setup. - Check [roles, permissions, tools, regional availability, and quota](setup/docs/README.md#prerequisites-for-step-2). - Plan for Azure, telemetry, and provider charges. The [cost estimate](docs/COST-CALCULATOR.md) is not a bill. - Use Easy Auth to allow only the approved Microsoft caller. @@ -49,7 +49,7 @@ See [architecture details](TECHNICAL.md#single-region-architecture). Follow the **[five-step setup guide](docs/GETTING-STARTED.md)** in order: 1. [Get ready](docs/GETTING-STARTED.md#1-get-ready): confirm access, approvals, permissions, and budget. -2. [Register the app](docs/GETTING-STARTED.md#2-register-your-application): save the tenant and client IDs. +2. [Choose the app](docs/GETTING-STARTED.md#2-register-your-application): use an existing registration or create one during setup. 3. [Deploy](docs/GETTING-STARTED.md#3-run-guided-setup): choose the provider and hosting plan. 4. [Connect and test](docs/GETTING-STARTED.md#4-connect-your-provider-and-test): configure provider access and run approved checks. 5. [Activate and hand over](docs/GETTING-STARTED.md#5-activate-and-hand-over): activate after validation and assign owners. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 7ad1cb8..b7277d8 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -9,7 +9,7 @@ Deploy one regional endpoint for Microsoft Entra ID SMS and voice requests. | Step | Task | | --- | --- | | 1 | [Prepare access, tools, region, and budget](#1-get-ready) | -| 2 | [Register the endpoint application](#2-register-your-application) | +| 2 | [Choose the endpoint application](#2-register-your-application) | | 3 | [Run guided setup](#3-run-guided-setup) | | 4 | [Connect the provider and test](#4-connect-your-provider-and-test) | | 5 | [Activate and hand over](#5-activate-and-hand-over) | @@ -51,7 +51,17 @@ Assign an **onboarding owner** and review [production readiness](../TECHNICAL.md ## 2. Register your application -In the customer tenant's **Microsoft Entra admin center > App registrations > New registration**: +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**). +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. + +**Register manually or use an existing dedicated app:** keep its tenant and application client +IDs handy. To register manually, open the customer tenant's +**Microsoft Entra admin center > App registrations > New registration**: 1. Give the application a recognizable name. 2. Select **Accounts in this organizational directory only**. Leave the redirect URI blank, @@ -79,7 +89,8 @@ See [application registration details](../setup/docs/README.md#step-1---manually Keep the Azure subscription ID handy. The [setup values worksheet](ONBOARDING.md#inputs-you-supply-to-setup) lists every input. Use a new app and prefix for an independent endpoint. After the app exists, -complete provider onboarding steps that require its application client ID. +complete provider onboarding steps that require its application client ID; for setup-created +applications, that ID becomes available after deployment approval. ## 3. Run guided setup @@ -102,7 +113,8 @@ On a shared workstation, use `.\Setup-Epp.ps1 -ForceAuthentication` for a fresh JavaScript is used by default, without a language prompt. To deploy another language, run `.\Setup-Epp.ps1 -Language csharp` or `.\Setup-Epp.ps1 -Language python`. -Select the tenant, subscription, and existing application **client ID**. Setup detects the tenant's +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: @@ -110,7 +122,8 @@ Global. Select the Azure region, then complete the remaining settings: 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. Setup downloads the language's matching +- **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 [plan details](../setup/docs/README.md#service-plan-selection) and the [cost estimate](COST-CALCULATOR.md). @@ -128,6 +141,13 @@ runtime, plan, resource names, permissions, certificate key IDs, and vault acces The plan includes Microsoft Graph `Application.Read.All` for the Microsoft phone-provider service principal. `No`, `N`, or Enter cancels. Responses are case-insensitive. +For a new application, the plan shows its display name and resource-name patterns containing +``. After approval, setup creates the registration, uses its real client ID to +calculate the final names, and checks resource ownership before Azure deployment. It prints the +new client ID and saves `application-.json` in `epp-output` for recovery. If deployment +then fails, reuse that client ID with `-ApplicationId` and the same prefix; do not create another app. +The registration record is not a successful deployment summary. + **Plan review:** run interactively and choose **No** at the final prompt. See [plan review](../setup/docs/README.md#reviewing-the-plan-without-deploying). diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 582643d..08aa85f 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -46,7 +46,7 @@ renewal. | --- | --- | --- | | Customer tenant ID | Microsoft Entra admin center > Overview | `-TenantId` | | Azure subscription ID | Azure portal > Subscriptions | `-SubscriptionId` | -| Endpoint application client ID | Microsoft Entra admin center > App registrations > application > Overview | `-ApplicationId` | +| 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 | | 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 | @@ -60,6 +60,11 @@ are different values. Global or EU identifies provider routing, not Azure locati EU tenants receive an EU Data Boundary region shortlist; other tenants retain the existing recommendations. Explicit region overrides remain available with a warning for EU tenants. +New registrations are created only after final deployment approval. Choose their provider from +the alphabetized guided-provider list. Unattended runs still require an existing `-ApplicationId`. +If setup creates an app and deployment later fails, use the client ID saved in +`application-.json` and the original prefix for a rerun, rather than creating another app. + One guided deployment uses one provider and supports both SMS and voice. Each request selects its channel. Reusing the same subscription, application, and prefix reconfigures that deployment. diff --git a/setup/Setup-Epp.ps1 b/setup/Setup-Epp.ps1 index 78df066..7e6943f 100644 --- a/setup/Setup-Epp.ps1 +++ b/setup/Setup-Epp.ps1 @@ -6,10 +6,15 @@ Download only this file. Supporting PowerShell, Bicep, and provider JSON files come from the selected public GitHub repository (Azure-Samples by default). 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. + Interactive setup can select cached tenants, subscriptions, applications, and Azure regions, + or create a dedicated app registration after final deployment approval. The endpoint supports SMS and voice, selected by each request. - Application registration and policy activation are manual steps. - No Azure resources are changed until you approve the complete plan. + 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. + New applications use an alphabetized provider list and are created only after deployment approval. + Required for unattended setup. .PARAMETER SourceRepository Public GitHub owner/repository containing the setup files. Use with SourceRef to test a fork. .PARAMETER PackageReleaseTag diff --git a/setup/docs/README.md b/setup/docs/README.md index 7d9aac3..320f03b 100644 --- a/setup/docs/README.md +++ b/setup/docs/README.md @@ -9,7 +9,7 @@ deployment, provider validation, policy activation, and handoff in order. | Task | Section | | --- | --- | | Prepare access, tools, and hosting | [Prerequisites](#prerequisites-for-step-2), [permissions](#permissions-are-separate), [plans](#service-plan-selection) | -| Register the dedicated app | [Step 1](#step-1---manually-create-the-application) | +| Choose the dedicated app | [Step 1](#step-1---manually-create-the-application) | | Run or review setup | [Step 2](#step-2---download-and-run-one-script), [certificate choice](#encryption-certificate-selection), [cancel at approval](#reviewing-the-plan-without-deploying) | | Use known inputs and versions | [Noninteractive example](#noninteractive-example), [source versioning](#source-versioning) | | Find deployment results | [Summary fields](#read-the-deployment-summary) | @@ -23,7 +23,7 @@ Setup creates **one regional endpoint**. See [Front Door](../../docs/FRONTDOOR.m ## Availability -Choose **Telesign or Soprano** and **FC1/EP1** hosting. Setup detects the tenant's replication scope +Choose **Soprano or Telesign** and **FC1/EP1** hosting. Setup detects the tenant's replication scope and selects the **EU** provider route for EU tenants or **Global** otherwise, without a scope picker. **JavaScript** is the default language, without a prompt. To use another language, run `.\Setup-Epp.ps1 -Language csharp` or `.\Setup-Epp.ps1 -Language python`. @@ -98,16 +98,28 @@ including older EP1 deployments without the new tag. Existing EP1 deployments ca 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. -## Step 1 - manually create the application + +## 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. +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. + +**Use an existing dedicated registration:** select/search for it or supply its client ID. +To create it manually before setup: + 1. In the customer tenant's **Microsoft Entra admin center > App registrations**, register a new application for this endpoint with **Accounts in this organizational directory only** as its initial supported account type; setup changes it to organizational multi-tenant after approval. No redirect URI, client secret, API permission, or app role is required manually. -2. Record the **Directory (tenant) ID** and **Application (client) ID**. The script requires the - client ID, not the application's object ID, and will not create a replacement registration. +2. Record the **Directory (tenant) ID** and **Application (client) ID**. For an existing app, + supply its client ID, not the application's object ID. Setup never silently creates a + replacement if an existing ID is invalid. 3. Complete provider purchase, sender registration, and API onboarding. Telesign uses `telesign-api-key` and `telesign-customer-id` in Key Vault. Soprano uses OAuth client-assertion exchange with the selected provider tenant/scope/application ID. Setup does not @@ -115,6 +127,10 @@ For the first deployment, use a nonproduction tenant and subscription. [provider authentication handoff](../../docs/ONBOARDING.md#complete-provider-authentication) after setup creates the vault and identity identifiers needed for those steps. +New registrations created by setup also start single-tenant, without a client secret or redirect +URI; the remaining approved configuration follows the same path as an existing app. +Creation is an interactive choice. Unattended runs still require an existing `-ApplicationId`. + Setup configures: - Organizational multitenancy and the Microsoft Entra ID allowed-tenants preview: home tenant, @@ -141,6 +157,11 @@ Setup configures: principal Graph `Application.Read.All`. Microsoft Graph operations use the current Azure CLI user session through `az ad` and `az rest`; Azure subscription roles alone do not grant these directory permissions. +- **New app registration:** the signed-in user must also be allowed to register applications in + the selected tenant, and its Graph access must permit creation. If default user app creation is + restricted, an appropriate role such as **Application Developer** can provide that permission. + This does not replace the other setup permissions. + See [application creation permissions](https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0#permissions). - Microsoft Graph **beta** access for `signInAudienceRestrictions`. The provider tenant is added; Microsoft Entra ID always allows the home tenant. See the @@ -229,7 +250,9 @@ The flow is: expected account is missing. Explicit `-TenantId` and `-SubscriptionId` values are preserved. 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 never substitutes an object ID. A supplied client ID skips this selector. + 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. + 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). @@ -239,7 +262,9 @@ The flow is: `-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. Existing allowed provider tenants are preserved; provider options are never numbered. + name. For a new application, it shows all supported provider names in alphabetical order, + without reading a nonexistent registration or selecting a default. + 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 @@ -265,10 +290,13 @@ The flow is: automatic package verification/build, provider settings, scoped roles, the exact encryption key IDs to keep/remove, certificate location or staging name, and application configuration. Bicep receives these - exact names; it does not independently calculate a different naming scheme. + exact names for existing apps; it does not independently calculate a different naming scheme. + For a new app, the plan instead shows its requested display name and name patterns containing + ``. After approval assigns the client ID, setup calculates and displays the final + eight-character suffix and checks resource ownership before deploying Azure resources. The plan also lists the six required **Azure resource providers** and their registration states. These Azure service registrations are separate from your phone-provider selection. -10. **Enter `Yes` or `Y` to deploy.** `No`, `N`, or Enter cancels without Azure changes. Responses +10. **Enter `Yes` or `Y` to deploy.** `No`, `N`, or Enter cancels without Azure or app-registration changes. Responses are case-insensitive. Invalid answers prompt again; individual resources do not request additional approvals. @@ -342,8 +370,9 @@ at `Proceed with deployment? Enter Yes or Y to deploy, or No or N to cancel [No] Do not supply `-ApproveDeployment`: that switch authorizes the deployment phase without asking again. -This is a cancellation at the approval gate, not an offline simulation. The existing application -registration is still required. Before the plan appears, setup can download tools and packages, request required installation, +This is a cancellation at the approval gate, not an offline simulation. You can select an existing +registration or plan a new one; neither is created or changed before approval. +Before the plan appears, setup can download tools and packages, request required installation, and sign in through Azure CLI. It also checks permissions and regions, verifies package checksums, builds the C# package when selected, and compiles Bicep. Installation, sign-in, and consent prompts are separate from deployment approval. @@ -351,6 +380,16 @@ A preflight error is not a successful preview. ### After approving the plan +If you chose a new application, setup creates its registration first and saves +`application-.json` in the output directory. This record contains its tenant, subscription, +client/object IDs, display name, provider, prefix, and resolved resource names; it is not a deployment +success summary. No secret is created with the registration. Its client ID determines the same +resource names used by later reruns with `-ApplicationId` and the original prefix. +The resolved resource-group ownership check must pass before further app configuration or Azure +deployment. If a later step fails, the app and its registration record remain; do not create another +app for the retry. An uncertain creation response is not retried automatically: inspect the selected +tenant before choosing creation again. + Setup rechecks the subscription and registers only missing `Microsoft.Web`, `Microsoft.Storage`, `Microsoft.KeyVault`, `Microsoft.OperationalInsights`, `Microsoft.Insights`, and `Microsoft.ManagedIdentity` providers before certificates or deployment. Preflight registers nothing. @@ -421,7 +460,7 @@ there is no separate `phone-provider-decryption-key` secret. ### Noninteractive example -Supply the required inputs, including `-ServicePlan` and `-ResourcePrefix`, and +Supply the required inputs, including an existing `-ApplicationId`, `-ServicePlan`, and `-ResourcePrefix`, and **both `-NonInteractive -ApproveDeployment`** to authorize the complete reviewed plan. `-NonInteractive` alone approves nothing; there is no `-Stage`, `-Resume`, `-ConfigPath`, or policy-approval switch. @@ -591,6 +630,7 @@ $tests = @( 'Graph.Tests.ps1', 'ProviderConfiguration.Tests.ps1', 'Selections.Tests.ps1', + 'Applications.Tests.ps1', 'Progress.Tests.ps1', 'Certificates.Tests.ps1', 'Providers.Tests.ps1' @@ -607,12 +647,13 @@ Remove-Item -LiteralPath $templatePath ``` The suites cover launcher downloads and cleanup; account, subscription, application, and region -selection; Azure CLI Graph calls; provider discovery; allowlist preservation; and stale-plan +selection; approval-gated application creation and recovery; alphabetical provider choices; +Azure CLI Graph calls; provider discovery; allowlist preservation; and stale-plan checks. They also cover tenant-scope discovery, EU-only recommendations, language and prefix defaults, confirmation aliases, certificate choices, provider and package selection, and FC1/EP1 deployment. Progress checks use a local CLI stand-in to verify animation, output handling, and cancellation. GitHub, Azure, Microsoft Graph, certificate, and publication calls are mocked. -CI runs the same nine suites on Windows and Linux. +CI runs the same ten suites on Windows and Linux. They do not certify live deployments, RBAC propagation, Key Vault issuance, Entra behavior, or provider delivery. diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md index 277ae0d..4376ee9 100644 --- a/setup/docs/Troubleshooting.md +++ b/setup/docs/Troubleshooting.md @@ -47,7 +47,9 @@ Keep nonce-match **booleans**, statuses, latency, safe IDs, and delivery outcome the error and **resource group > Deployments**. 2. **Fix, then rerun with the same inputs:** tenant, subscription, app, runtime, plan, prefix, provider/route, and source/package versions. Active endpoints need a change window; setup has - no checkpoint resume or automatic undo. + no checkpoint resume or automatic undo. If setup created the app, find its client ID in the + printed output or `application-.json` and rerun with `-ApplicationId` and the same + prefix. Do not choose **Create a new application** again for that deployment. 3. **Verify the failed stage and complete acceptance checks.** Keep resource-group deletion, vault purge/recreation, encryption-credential removal, and shared-grant revocation out of retry steps. Use the separate [rollback procedure](README.md#rollback-and-decommissioning) @@ -337,6 +339,12 @@ app to another runtime with the same prefix. delete, purge/recover vaults, activate policy, or roll back. Follow [safe recovery](#recover-without-destructive-cleanup) before a same-input rerun. +For **Create a new application**, a successful registration can remain even if later steps fail. +Its `application-.json` record is not proof of deployment success. If the creation request +itself fails or times out, inspect the selected tenant before retrying; setup does not automatically +repeat that non-idempotent request. A permission error requires app-registration rights in addition +to the [other setup permissions](README.md#prerequisites-for-step-2). + Recognized storage/Key Vault RBAC propagation errors are retried for at most twelve attempts. Transient Function startup errors also have bounded retries. This includes the specific ARM `BadRequest` response `Encountered an error (InternalServerError) from host runtime`, which Azure can diff --git a/setup/support/Epp.Graph.ps1 b/setup/support/Epp.Graph.ps1 index 3827a1a..a4e623f 100644 --- a/setup/support/Epp.Graph.ps1 +++ b/setup/support/Epp.Graph.ps1 @@ -162,6 +162,24 @@ function Get-EppApplication { return $application } +function New-EppApplication { + param([hashtable] $Inputs, [string] $DisplayName) + + $DisplayName = Read-EppInput -Name ApplicationDisplayName -Value $DisplayName -Kind ApplicationName -NonInteractive + $raw = Invoke-EppGraphRequest -Inputs $Inputs -Method POST -Path '/v1.0/applications' -Body @{ + displayName = $DisplayName + signInAudience = 'AzureADMyOrg' + } + $application = ConvertFrom-EppGraphApplication -Application $raw + if ($application.DisplayName -cne $DisplayName -or $application.SignInAudience -cne 'AzureADMyOrg' -or + $application.KeyCredentials.Count -or $application.AppRoles.Count -or $application.IdentifierUris.Count -or + $application.TokenEncryptionKeyId -or + ($application.Api.RequestedAccessTokenVersion -and $application.Api.RequestedAccessTokenVersion -ne 1)) { + throw "Created application '$($application.AppId)' does not match the approved new registration. Inspect it before rerunning setup." + } + return $application +} + function ConvertTo-EppGraphKeyCredentials { param([object[]] $Credentials) diff --git a/setup/support/Epp.Setup.psm1 b/setup/support/Epp.Setup.psm1 index 19cbbba..95f3eb6 100644 --- a/setup/support/Epp.Setup.psm1 +++ b/setup/support/Epp.Setup.psm1 @@ -97,7 +97,7 @@ function Get-EppServicePlan { function Read-EppInput { param( [string] $Name, [string] $Value, [string] $Hint, - [ValidateSet('Text', 'Guid', 'Location', 'Prefix', 'PackageUrl')] + [ValidateSet('Text', 'Guid', 'Location', 'Prefix', 'PackageUrl', 'ApplicationName')] [string] $Kind = 'Text', [switch] $NonInteractive, [switch] $AllowCancel, [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$')] @@ -121,6 +121,11 @@ function Read-EppInput { if (-not $Value -or $Value -match '[\x00-\x1f<>]') { throw 'A nonempty value without placeholders is required.' } switch ($Kind) { 'Guid' { $Value = ConvertTo-EppGuid $Value } + 'ApplicationName' { + if ($Value.Length -gt 256 -or $Value -match '[\x7f-\x9f]') { + throw 'Use an application display name of 1-256 characters without control characters.' + } + } 'Location' { if ($Value -cnotmatch '^[a-z][a-z0-9]+$') { throw 'Use an Azure region name such as westus2.' } } @@ -213,6 +218,10 @@ function Resolve-EppApplicationProvider { if (-not [string]::IsNullOrWhiteSpace($Provider) -or $NonInteractive) { return Read-EppProviderName -Entries $entries -Provider $Provider -NonInteractive:$NonInteractive } + if ($Inputs['NewApplicationDisplayName']) { + Write-Host 'Choose a provider for the new application. The application and its provider configuration will be created only after deployment approval.' + return Read-EppProviderName -Entries $entries + } $application = Get-EppApplication -Inputs $Inputs -ClientId $Inputs.ApplicationId $restriction = Get-EppSignInAudienceRestrictions -Inputs $Inputs -ApplicationObjectId $application.Id $state = Get-EppProviderRestrictionState -Restriction $restriction @@ -353,13 +362,20 @@ function ConvertTo-EppProviderSettings { } function Get-EppResourceNames { - param([string] $SubscriptionId, [string] $ApplicationId, [string] $ResourcePrefix) + param([string] $SubscriptionId, [string] $ApplicationId, [string] $ResourcePrefix, [switch] $PendingApplication) if ($ResourcePrefix -cnotmatch '^[a-z][a-z0-9]{1,7}$') { throw 'ResourcePrefix must be 2-8 lowercase letters/digits, starting with a letter.' } - $seed = "$(ConvertTo-EppGuid $SubscriptionId)|$(ConvertTo-EppGuid $ApplicationId)|$ResourcePrefix" - $sha = [Security.Cryptography.SHA256]::Create() - try { $suffix = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($seed))) -replace '-', '').Substring(0, 8).ToLowerInvariant() } - finally { $sha.Dispose() } + $SubscriptionId = ConvertTo-EppGuid $SubscriptionId + if ($PendingApplication) { + if ($ApplicationId) { throw 'Pending application names cannot include an application client ID.' } + $suffix = '' + } + else { + $seed = "$SubscriptionId|$(ConvertTo-EppGuid $ApplicationId)|$ResourcePrefix" + $sha = [Security.Cryptography.SHA256]::Create() + try { $suffix = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($seed))) -replace '-', '').Substring(0, 8).ToLowerInvariant() } + finally { $sha.Dispose() } + } return [ordered]@{ resourceGroup = "$ResourcePrefix-epp-rg-$suffix" functionApp = "$ResourcePrefix-epp-func-$suffix" @@ -756,8 +772,8 @@ function Select-EppApplication { return Read-EppInput -Name ApplicationId -Value $ApplicationId -Kind Guid -NonInteractive } $null = Connect-EppGraphAccount -Inputs $Inputs -ForceAuthentication:$ForceAuthentication - Write-Host "`nSelect the dedicated EPP app registration created in Step 1, not a shared application." -ForegroundColor Yellow - Write-Host 'Application (client) IDs are shown below, not application object IDs. No app is created by this picker.' + Write-Host "`nSelect a dedicated EPP app registration, or create a new application. Do not use a shared application." -ForegroundColor Yellow + Write-Host 'Application (client) IDs are shown below, not application object IDs. New applications are created only after deployment approval.' $search = '' while ($true) { $entries = @(Get-EppApplicationOptions -Inputs $Inputs -SearchPrefix $search) @@ -768,6 +784,7 @@ function Select-EppApplication { @{ id = 'search'; displayName = 'Search by application name prefix' } @{ id = 'refresh'; displayName = 'Refresh application list' } @{ 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 if (-not $selection) { return $null } @@ -1189,6 +1206,25 @@ function Initialize-EppResourceProviders { } } +function Assert-EppResourceGroup { + param([hashtable] $Inputs, [Collections.IDictionary] $Names) + + $null = ConvertTo-EppGuid $Inputs.ApplicationId + $groupExists = Invoke-EppAz group exists --name $Names.resourceGroup --subscription $Inputs.SubscriptionId --output tsv + if ($groupExists -eq 'true') { + $tags = Invoke-EppAz group show --name $Names.resourceGroup --subscription $Inputs.SubscriptionId --query tags --output json | + ConvertFrom-Json -AsHashtable + if (-not $tags -or $tags['eppApplicationId'] -ne $Inputs.ApplicationId -or $tags['managedBy'] -ne 'EPP-Setup') { + throw "Resource group '$($Names.resourceGroup)' is not owned by this EPP application. Choose another prefix; existing resources will not be adopted." + } + if ($tags['eppLanguage'] -and $tags['eppLanguage'] -ne $Inputs.Language) { + throw "This prefix already hosts '$($tags['eppLanguage'])'. Use a different prefix for '$($Inputs.Language)' instead of switching a running app's runtime." + } + Assert-EppExistingServicePlan -Inputs $Inputs -Names $Names -Tags $tags + } + elseif ($groupExists -ne 'false') { throw 'Azure returned an invalid resource-group existence result.' } +} + function Connect-EppContext { param( [hashtable] $Inputs, [Collections.IDictionary] $Names, @@ -1214,13 +1250,23 @@ function Connect-EppContext { $graphAccount = Connect-EppGraphAccount -Inputs $Inputs -NonInteractive:$NonInteractive $graph = $graphAccount.Context $graphOperator = $graphAccount.Operator - $application = Get-EppApplication -Inputs $Inputs -ClientId $Inputs.ApplicationId + $pendingApplication = $Inputs['NewApplicationDisplayName'] -and -not $Inputs['ApplicationId'] + $application = if ($pendingApplication) { + [pscustomobject]@{ + Id = $null; AppId = $null; DisplayName = $Inputs.NewApplicationDisplayName + SignInAudience = 'AzureADMyOrg'; Api = @{ RequestedAccessTokenVersion = $null } + AppRoles = @(); IdentifierUris = @(); KeyCredentials = @(); TokenEncryptionKeyId = $null + } + } + else { Get-EppApplication -Inputs $Inputs -ClientId $Inputs.ApplicationId } if ($application.SignInAudience -notin @('AzureADMyOrg', 'AzureADMultipleOrgs')) { - throw 'The existing EPP application must be an organizational single- or multi-tenant app. Create a dedicated organizational app in manual Step 1.' + throw 'The existing EPP application must be an organizational single- or multi-tenant app. Choose Create a new application or register a dedicated organizational app manually.' } if ($application.TokenEncryptionKeyId) { throw 'Clear tokenEncryptionKeyId manually on the endpoint app. Easy Auth requires signed, not encrypted, bearer access tokens.' } - $endpointPrincipals = @(Get-EppServicePrincipal -Inputs $Inputs -AppId $Inputs.ApplicationId -Description endpoint) + $endpointPrincipals = @(if (-not $pendingApplication) { + Get-EppServicePrincipal -Inputs $Inputs -AppId $Inputs.ApplicationId -Description endpoint + }) $callerPrincipals = @(Get-EppServicePrincipal -Inputs $Inputs -AppId $script:MicrosoftPhoneProviderAppId -Description 'Microsoft phone-provider') $graphPrincipals = @(Get-EppServicePrincipal -Inputs $Inputs -AppId $script:MicrosoftGraphAppId -Description 'Microsoft Graph') if ($graphPrincipals.Count -ne 1) { throw 'Microsoft Graph has no unique service principal in this tenant.' } @@ -1239,26 +1285,16 @@ function Connect-EppContext { $invokeRoles[0].AllowedMemberTypes -notcontains 'Application'))) { throw "The endpoint application has a conflicting '$script:EppInvokeAppRoleValue' app role or role ID." } - $tenantRestriction = Get-EppSignInAudienceRestrictions -Inputs $Inputs -ApplicationObjectId $application.Id + $tenantRestriction = if (-not $pendingApplication) { + Get-EppSignInAudienceRestrictions -Inputs $Inputs -ApplicationObjectId $application.Id + } else { $null } $providerRestrictionState = Get-EppProviderRestrictionState -Restriction $tenantRestriction $allowedTenants = @(if ($providerRestrictionState) { $providerRestrictionState.allowedTenantIds }) $Inputs.ProviderAllowedTenantIds = @(($allowedTenants + @($Inputs.ProviderTenantId)) | Sort-Object -Unique) $version = if ($application.Api -and $application.Api.RequestedAccessTokenVersion) { [int]$application.Api.RequestedAccessTokenVersion } else { 1 } if ($version -notin @(1, 2)) { throw 'The endpoint application has an unsupported access-token version.' } - $groupExists = Invoke-EppAz group exists --name $Names.resourceGroup --subscription $Inputs.SubscriptionId --output tsv - if ($groupExists -eq 'true') { - $tags = Invoke-EppAz group show --name $Names.resourceGroup --subscription $Inputs.SubscriptionId --query tags --output json | - ConvertFrom-Json -AsHashtable - if (-not $tags -or $tags['eppApplicationId'] -ne $Inputs.ApplicationId -or $tags['managedBy'] -ne 'EPP-Setup') { - throw "Resource group '$($Names.resourceGroup)' is not owned by this EPP application. Choose another prefix; existing resources will not be adopted." - } - if ($tags['eppLanguage'] -and $tags['eppLanguage'] -ne $Inputs.Language) { - throw "This prefix already hosts '$($tags['eppLanguage'])'. Use a different prefix for '$($Inputs.Language)' instead of switching a running app's runtime." - } - Assert-EppExistingServicePlan -Inputs $Inputs -Names $Names -Tags $tags - } - elseif ($groupExists -ne 'false') { throw 'Azure returned an invalid resource-group existence result.' } + if (-not $pendingApplication) { Assert-EppResourceGroup -Inputs $Inputs -Names $Names } $resourceProviders = @(Get-EppResourceProviders -SubscriptionId $Inputs.SubscriptionId) Assert-EppProviderLocations -Providers $resourceProviders -Location $Inputs.Location @@ -1312,7 +1348,10 @@ function Show-EppPlan { Write-Host "`nDeployment plan (create or update)" -ForegroundColor Cyan Write-Host "Tenant: $($Inputs.TenantId)" Write-Host "Subscription: $($Inputs.SubscriptionId)" - Write-Host "Application: $($Inputs.ApplicationId)" + if ($Inputs['NewApplicationDisplayName'] -and -not $Inputs['ApplicationId']) { + Write-Host "Application: Create '$($Inputs.NewApplicationDisplayName)' after approval (client ID not yet assigned)" + } + else { Write-Host "Application: $($Inputs.ApplicationId)" } Write-Host "Location: $($Inputs.Location)" Write-Host "Language: $($Inputs.Platform)" Write-Host "Service plan: $($Inputs.ServicePlan)" @@ -1347,6 +1386,10 @@ function Show-EppPlan { Write-Host "`nAzure resources and configuration" -ForegroundColor Cyan Get-EppResourceRows -Names $Names -ServicePlan $Inputs.ServicePlan | Format-Table Resource, Name, Description -AutoSize | Out-String -Width 240 | Write-Host + if ($Inputs['NewApplicationDisplayName'] -and -not $Inputs['ApplicationId']) { + Write-Host ' - is a name placeholder, not an actual resource name. The new client ID determines the eight-character suffix after approval.' + Write-Host ' - Final resource names and ownership are checked before Azure resource deployment. Reruns use the new client ID and this same prefix.' + } Write-Host ' - Required Azure resource providers are checked before deployment; existing registrations are reused.' $Context.ResourceProviders | Select-Object Namespace, RegistrationState | Format-Table -AutoSize | Out-String -Width 200 | Write-Host @@ -1359,6 +1402,10 @@ function Show-EppPlan { Write-Host ' - Setup operator, scoped to this Key Vault:' Write-Host ' Key Vault Certificates Officer - issue and inspect the encryption certificate.' Write-Host ' Key Vault Secrets Officer - manage provider credentials.' + if ($Inputs['NewApplicationDisplayName'] -and -not $Inputs['ApplicationId']) { + Write-Host " - Create a dedicated Microsoft Entra app registration named '$($Inputs.NewApplicationDisplayName)' in the selected tenant." + Write-Host ' No client secret or redirect URI is created. An existing app with the same name is not adopted.' + } if ($Context.Application.SignInAudience -ne 'AzureADMultipleOrgs') { Write-Host ' - Change the endpoint application from single-tenant to organizational multi-tenant.' } @@ -1551,7 +1598,18 @@ function Ensure-EppAppRoleAssignment { function Initialize-EppGraphAccess { param([hashtable] $Inputs, $Context) - $application = Get-EppApplication -Inputs $Inputs -ObjectId $Context.Application.Id + for ($attempt = 1; $attempt -le 12; $attempt++) { + try { + $application = Get-EppApplication -Inputs $Inputs -ObjectId $Context.Application.Id + break + } + catch { + if (-not $Inputs['NewApplicationDisplayName'] -or $attempt -eq 12 -or + $_.Exception.Message -notmatch 'Request_ResourceNotFound|ResourceNotFound') { throw } + Write-Warning "Waiting for the new app registration to propagate ($attempt/12)." + Start-Sleep -Seconds 5 + } + } if ($application.AppId -ne $Inputs.ApplicationId -or $application.SignInAudience -notin @('AzureADMyOrg', 'AzureADMultipleOrgs') -or $application.SignInAudience -cne $Context.Application.SignInAudience -or @@ -2313,9 +2371,33 @@ function Invoke-EppDeployment { } # The single setup approval covers these planned writes, including certificate issuance. $ConfirmPreference = 'None' + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + if ($Inputs['NewApplicationDisplayName'] -and -not $Inputs['ApplicationId']) { + $application = New-EppApplication -Inputs $Inputs -DisplayName $Inputs.NewApplicationDisplayName + $Inputs.ApplicationId = $application.AppId + $Context.Application = $application + Write-Host "`nCreated app registration: $($application.DisplayName)" -ForegroundColor Cyan + Write-Host "Application (client) ID: $($Inputs.ApplicationId)" + Write-Host "For reruns, use -ApplicationId '$($Inputs.ApplicationId)' -ResourcePrefix '$($Inputs.ResourcePrefix)' instead of creating another application." -ForegroundColor Yellow + + $Names = Get-EppResourceNames -SubscriptionId $Inputs.SubscriptionId -ApplicationId $Inputs.ApplicationId -ResourcePrefix $Inputs.ResourcePrefix + $Context.CertificatePlan.VaultName = $Names.keyVault + $Context.CertificatePlan.VaultResourceId = "/subscriptions/$($Inputs.SubscriptionId)/resourceGroups/$($Names.resourceGroup)/providers/Microsoft.KeyVault/vaults/$($Names.keyVault)" + $applicationPath = Join-Path $OutputDirectory "application-$($Inputs.ApplicationId).json" + [ordered]@{ + tenantId = $Inputs.TenantId; subscriptionId = $Inputs.SubscriptionId + applicationId = $Inputs.ApplicationId; applicationObjectId = $application.Id + displayName = $application.DisplayName; provider = $ProviderConfiguration.Id + resourcePrefix = $Inputs.ResourcePrefix; resources = $Names + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $applicationPath -Encoding utf8NoBOM + Write-Host "Application registration details saved to: $applicationPath" + Assert-EppResourceGroup -Inputs $Inputs -Names $Names + Write-Host "`nResolved Azure resource names" -ForegroundColor Cyan + Get-EppResourceRows -Names $Names -ServicePlan $Inputs.ServicePlan | + Format-Table Resource, Name, Description -AutoSize | Out-String -Width 240 | Write-Host + } $graphAccess = Initialize-EppGraphAccess -Inputs $Inputs -Context $Context Initialize-EppResourceProviders -Inputs $Inputs - New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null $settings = @{} + $ProviderConfiguration.Settings $settings.EPP_PROVIDER_AUTH_MODE = $Inputs.ProviderAuthentication $parameters = @{ @@ -2492,8 +2574,9 @@ function Invoke-EppSetup { Setup-Epp.ps1. Downloads and verifies the selected Function ZIP from a stable GitHub release. Azure CLI is required. The .NET selection also needs the .NET 8 SDK and NuGet access; Python dependencies are built in Azure. The Azure CLI Bicep component is also required - and can be installed after confirmation. Application registration and policy activation - remain manual. Review the complete plan before approving resource or tenant changes. + and can be installed after confirmation. Interactive setup can create a dedicated app + registration after deployment approval. Policy activation remains manual. + Review the complete plan before approving resource or tenant changes. Microsoft Graph calls reuse the existing Azure CLI user session through az ad and az rest. No Microsoft Graph PowerShell SDK or separate Graph sign-in is required. Interactive setup discovers cached Azure CLI accounts before asking for other settings. @@ -2506,12 +2589,13 @@ function Invoke-EppSetup { in the chosen tenant. Required in noninteractive mode. .PARAMETER ApplicationId Existing application client ID. When omitted interactively, use the current CLI tenant - to choose/search an app registration, or enter its client ID. - The picker does not create applications. Required in noninteractive mode. + to choose/search an app registration, enter its client ID, or plan a new application. + 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. Explicit input confirms the choice; required unattended. + by name among multiple matches. New applications show supported providers alphabetically. + 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. @@ -2619,6 +2703,15 @@ function Invoke-EppSetup { Write-Host 'Cancelled. No Azure resources or application credentials were changed.' -ForegroundColor Yellow return } + 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 + } + } if (-not [string]::IsNullOrWhiteSpace($Location) -or $NonInteractive) { $Location = Read-EppInput -Name Location -Value $Location -Kind Location -NonInteractive } @@ -2655,7 +2748,8 @@ function Invoke-EppSetup { $inputs.ServicePlan = $plan.id $inputs.ResourcePrefix = Read-EppInput ResourcePrefix $ResourcePrefix -Kind Prefix ` -PromptName 'Resource name prefix' -DefaultValue 'epp' -NonInteractive:$NonInteractive - $names = Get-EppResourceNames -SubscriptionId $inputs.SubscriptionId -ApplicationId $inputs.ApplicationId -ResourcePrefix $inputs.ResourcePrefix + $names = Get-EppResourceNames -SubscriptionId $inputs.SubscriptionId -ApplicationId $inputs.ApplicationId ` + -ResourcePrefix $inputs.ResourcePrefix -PendingApplication:([bool]$inputs['NewApplicationDisplayName']) Write-Host "`nChecking prerequisites and the selected Azure context (no resource changes)..." -ForegroundColor Cyan $context = Connect-EppContext -Inputs $inputs -Names $names -NonInteractive:$NonInteractive ` @@ -2676,7 +2770,7 @@ function Invoke-EppSetup { --outfile (Join-Path $assetDirectory 'main.json') | Out-Null Show-EppPlan -Inputs $inputs -Names $names -ProviderConfiguration $providerConfiguration -Context $context if (-not (Confirm-EppDeployment -NonInteractive:$NonInteractive -ApproveDeployment:$ApproveDeployment)) { - Write-Host 'Cancelled. No Azure resources were changed.' -ForegroundColor Yellow + Write-Host 'Cancelled. No Azure resources or app registrations were changed.' -ForegroundColor Yellow return } try { @@ -2685,7 +2779,15 @@ function Invoke-EppSetup { if ($PassThru) { return $result } } catch { - Write-Warning 'Deployment did not complete. Previously created resources are left in place; no rollback or policy activation was attempted. Correct the reported failure and rerun with the same inputs and prefix.' + Write-Warning 'Deployment did not complete. Previously created app registrations and Azure resources are left in place; no rollback or policy activation was attempted. Correct the reported failure and rerun with the same inputs and prefix.' + if ($inputs['NewApplicationDisplayName']) { + if ($inputs.ApplicationId) { + Write-Warning "Reuse the created app with -ApplicationId '$($inputs.ApplicationId)' -ResourcePrefix '$($inputs.ResourcePrefix)'. Do not create another app for this deployment." + } + else { + Write-Warning "If app creation was attempted, check the selected tenant for '$($inputs.NewApplicationDisplayName)' before retrying; a failed response does not prove that no app was created." + } + } throw } } diff --git a/setup/tests/Applications.Tests.ps1 b/setup/tests/Applications.Tests.ps1 new file mode 100644 index 0000000..27fcc0e --- /dev/null +++ b/setup/tests/Applications.Tests.ps1 @@ -0,0 +1,367 @@ +#Requires -Version 7.0 +param([string] $ModulePath = (Split-Path -Parent $PSScriptRoot)) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$module = Import-Module (Join-Path $ModulePath 'support\Epp.Setup.psm1') -Force -PassThru +$directory = Join-Path ([IO.Path]::GetTempPath()) "epp-application-tests-$([Guid]::NewGuid().ToString('N'))" +try { + & $module { + param($ModulePath, $Directory) + + function script:Assert($Condition, [string] $Message) { + if (-not $Condition) { throw $Message } + } + function script:Assert-Throws([scriptblock] $Action, [string] $Pattern) { + try { $null = & $Action } + catch { + Assert ($_.Exception.Message -match $Pattern) "Unexpected failure: $($_.Exception.Message)" + return + } + throw "Expected failure matching '$Pattern'." + } + $script:Tenant = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + $script:Subscription = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' + $script:Operator = 'cccccccc-cccc-cccc-cccc-cccccccccccc' + $script:AppId = '11111111-1111-1111-1111-111111111111' + $script:ObjectId = '22222222-2222-2222-2222-222222222222' + $script:ExistingAppId = '33333333-3333-3333-3333-333333333333' + $script:ApplicationName = 'Contoso EPP' + $script:GraphAccess = @{ + EndpointPrincipalId = $script:ObjectId; CallerPrincipalId = $script:Operator; GraphPrincipalId = $script:Tenant + } + $script:Answers = [Collections.Generic.Queue[string]]::new() + $script:Calls = [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() + foreach ($answer in $Answers) { $script:Answers.Enqueue($answer) } + $script:Approved = $false; $script:EmptyApplications = $false; $script:ChangedOperator = $false + $script:CreationFailure = ''; $script:PackageFailure = ''; $script:GroupExists = $false + $script:Creates = 0; $script:ApplicationReads = 0; $script:RestrictionReads = 0 + $script:GroupChecks = 0; $script:Initializations = 0 + $script:PlanInputs = $null; $script:PlanNames = $null; $script:PlanKeyId = $null + $script:DeploymentInputs = $null; $script:DeploymentContext = $null + $script:StopAtInitialization = $true; $script:DeploymentParameters = $null; $script:CertificateDisposed = $false + } + function script:Write-Host { + param([Parameter(ValueFromPipeline)] $Object, $ForegroundColor, [switch] $NoNewline) + process { $script:Messages.Add([string]$Object) } + } + function script:Write-Warning { + [CmdletBinding()] + param([string] $Message) + $script:Warnings.Add($Message) + } + function script:Read-Host { + param($Prompt) + Assert ($script:Answers.Count -gt 0) "Unexpected prompt: $Prompt" + $answer = $script:Answers.Dequeue() + if ($Prompt -like 'Proceed with deployment?*') { + $script:Calls.Add('approval') + $script:Approved = $answer.Trim() -in @('Yes', 'Y') + } + return $answer + } + function script:Get-Command { param($Name, $ErrorAction); return @{ Name = $Name } } + function script:Connect-EppAzureAccount { + param($Inputs, [switch] $NonInteractive, [switch] $ForceAuthentication) + return @{ id = $Inputs.SubscriptionId; tenantId = $Inputs.TenantId } + } + function script:Get-EppArmOperatorObjectId { param($Inputs); return $script:Operator } + function script:Get-EppGraphOperator { + param($Inputs) + $id = if ($script:Approved -and $script:ChangedOperator) { $script:Tenant } else { $script:Operator } + return @{ Id = $id; Account = 'operator@contoso.test' } + } + function script:Get-EppTenantRegionScope { param($TenantId); return 'NA' } + function script:New-TestApplication([string] $ClientId) { + return @{ + id = $script:ObjectId; appId = $ClientId; displayName = $script:ApplicationName + signInAudience = 'AzureADMyOrg'; api = @{ requestedAccessTokenVersion = $null } + appRoles = @(); identifierUris = @(); keyCredentials = @(); tokenEncryptionKeyId = $null + } + } + function script:Invoke-EppGraphRequest { + param($Inputs, $Path, $Query, $Method = 'GET', $Body) + Assert ($Path -ceq '/v1.0/applications') "Unexpected Graph path: $Path" + if ($Method -eq 'GET') { + $script:Calls.Add('list') + 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.' + $script:Calls.Add('create'); $script:Creates++ + if ($script:CreationFailure) { throw $script:CreationFailure } + return New-TestApplication $script:AppId + } + function script:Get-EppApplication { + param($Inputs, $ClientId, $ObjectId) + $script:ApplicationReads++ + Assert ($ClientId -ceq $script:ExistingAppId) 'Preflight may only read a selected existing application.' + return ConvertFrom-EppGraphApplication (New-TestApplication $ClientId) + } + function script:Get-EppSignInAudienceRestrictions { + param($Inputs, $ApplicationObjectId) + $script:RestrictionReads++ + return $null + } + function script:Get-EppServicePrincipal { + param($Inputs, $AppId, $Description) + $null = ConvertTo-EppGuid $AppId + if ($AppId -eq $script:MicrosoftGraphAppId) { + return @{ + Id = $script:Tenant + AppRoles = @(@{ + Id = $script:MicrosoftGraphApplicationReadAllRoleId; Value = 'Application.Read.All' + IsEnabled = $true; AllowedMemberTypes = @('Application') + }) + } + } + } + function script:Get-EppResourceProviders { + param($SubscriptionId) + return @([pscustomobject]@{ Namespace = 'Microsoft.Web'; RegistrationState = 'NotRegistered' }) + } + function script:Assert-EppProviderLocations { param($Providers, $Location); Assert ($Location -ceq 'westus2') 'Check the selected region.' } + function script:Get-EppLanguage { + param($AssetDirectory, $Language, $SourceRepository, $PackageReleaseTag, [switch] $NonInteractive) + return @{ + Id = 'javascript'; DisplayName = 'JavaScript'; BuildStrategy = 'ready' + Url = 'https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-1-1/epp-javascript.zip' + ReleaseTag = 'epp-packages-1-1' + } + } + function script:Get-EppPackage { + param($Selection, $Directory) + if ($script:PackageFailure) { throw $script:PackageFailure } + return @{ Sha256 = 'a' * 64; SourceSha256 = 'a' * 64; RequiresRemoteBuild = $false } + } + function script:Invoke-EppAz { + [CmdletBinding(PositionalBinding = $false)] + param([Parameter(Position = 0, ValueFromRemainingArguments)][string[]] $Arguments, [string] $Activity) + $command = $Arguments -join ' ' + if ($command -ceq 'version --output json') { return '{"azure-cli":"2.60.0"}' } + if ($command -ceq 'bicep version --output none' -or $command -like 'bicep build *') { return '' } + if ($command -like 'group exists *') { + Assert ($command -notmatch '<|>') 'Never query resources using placeholder names.' + $script:GroupChecks++; $script:Calls.Add('group-check') + return $script:GroupExists.ToString().ToLowerInvariant() + } + if ($command -like 'group show *') { + return @{ managedBy = 'other'; eppApplicationId = $script:ExistingAppId } | ConvertTo-Json + } + if ($command -like 'deployment sub create *') { + Assert ($script:Approved -and $script:Creates -eq 1 -and $script:GroupChecks -eq 1) 'Deploy Azure only after application creation and real-name ownership checks.' + $path = $Arguments[[Array]::IndexOf($Arguments, '--parameters') + 1].Substring(1) + $script:DeploymentParameters = (Read-EppJson $path).parameters + $names = $script:DeploymentParameters.resourceNames.value + $expectedNames = Get-EppResourceNames -SubscriptionId $script:Subscription -ApplicationId $script:AppId -ResourcePrefix unit + Assert ($script:DeploymentParameters.applicationId.value -ceq $script:AppId) 'Pass the actual new client ID to Bicep.' + foreach ($key in $expectedNames.Keys) { + Assert ($names[$key] -ceq $expectedNames[$key]) 'Pass final names, never placeholders, to Bicep.' + } + return @{ + functionAppName = @{ value = $names.functionApp }; storageAccountName = @{ value = $names.storageAccount } + keyVaultName = @{ value = $names.keyVault }; resourceGroupName = @{ value = $names.resourceGroup } + outboundPrincipalId = @{ value = $script:Operator } + endpointUrl = @{ value = "https://$($names.functionApp).azurewebsites.net/api/SendOtp" } + identifierUri = @{ value = "api://$script:AppId" } + } | ConvertTo-Json -Depth 4 + } + throw "Unexpected Azure call (no network allowed): $command" + } + function script:Invoke-WebRequest { throw 'Unexpected download.' } + function script:Invoke-RestMethod { throw 'Unexpected network call.' } + $script:OriginalShowPlan = ${function:Show-EppPlan} + function script:Show-EppPlan { + param($Inputs, $Names, $ProviderConfiguration, $Context) + $script:PlanInputs = @{} + $Inputs; $script:PlanNames = @{} + $Names + $script:PlanKeyId = $Context.CertificatePlan.KeyId + $script:Calls.Add('plan') + & $script:OriginalShowPlan -Inputs $Inputs -Names $Names -ProviderConfiguration $ProviderConfiguration -Context $Context + } + function script:Initialize-EppGraphAccess { + param($Inputs, $Context) + Assert ($script:Approved) 'Configure the application only after approval.' + $script:Initializations++; $script:Calls.Add('initialize') + $script:DeploymentInputs = @{} + $Inputs; $script:DeploymentContext = $Context + Assert ($Context.CertificatePlan.KeyId -ceq $script:PlanKeyId) 'Keep the encryption key ID reviewed before approval.' + Assert ($Context.CertificatePlan.VaultName -notmatch '<|>' -and + $Context.CertificatePlan.VaultResourceId -notmatch '<|>') 'Resolve every certificate vault reference before deployment.' + if ($script:StopAtInitialization) { throw 'application-deployment-boundary' } + return $script:GraphAccess + } + function script:Initialize-EppResourceProviders { + param($Inputs) + Assert ($script:Approved -and $Inputs.ApplicationId -ceq $script:AppId) 'Register resource providers only after the new app exists.' + } + function script:Get-EppEncryptionCertificate { + param($Inputs, $VaultName, $CertificateName, $OutputDirectory, $Directory) + $certificate = [pscustomobject]@{ Thumbprint = 'A' * 40; NotAfter = [DateTime]::UtcNow.AddYears(1) } + $certificate | Add-Member -MemberType ScriptMethod -Name Dispose -Value { $script:CertificateDisposed = $true } + return @{ + Certificate = $certificate + CertificateId = "https://$VaultName.vault.azure.net/certificates/$CertificateName/$('a' * 32)" + SecretId = "https://$VaultName.vault.azure.net/secrets/$CertificateName/$('b' * 32)" + } + } + function script:Initialize-EppCertificateAccess { + param($Inputs, $Names, $Plan, $Directory) + Assert ($Plan.VaultName -ceq $Names.keyVault) 'Use the finalized vault in the approved certificate plan.' + } + function script:Set-EppEncryptionSettings { + param($Inputs, $Names, $SecretId, $KeyId, $Directory) + Assert ($SecretId.StartsWith("https://$($Names.keyVault).vault.azure.net/secrets/")) 'Bind decryption to the real vault.' + } + function script:Assert-EppEncryptionReference { param($Inputs, $Names, $SecretId, $KeyId) } + function script:Set-EppApplicationEndpoint { + param($Inputs, $Context, $Outputs, $Certificate, $KeyId, [switch] $ConfigureFederation) + Assert ($Context.Application.AppId -ceq $script:AppId -and $Inputs.ApplicationId -ceq $script:AppId) 'Register the endpoint against the new app.' + Assert ($ConfigureFederation -eq ($Inputs.ProviderAuthentication -eq 'oauth')) 'Keep the selected provider authentication behavior.' + } + function script:Assert-EppGraphAccess { + param($Inputs, $Certificate, $KeyId, $IdentifierUri, $CertificatePlan) + return $script:GraphAccess + } + function script:Publish-EppFunction { + param($Inputs, $Names, $Context, $Outputs, $SiteId, $Package, $Directory) + Assert ($SiteId -ceq "/subscriptions/$script:Subscription/resourceGroups/$($Names.resourceGroup)/providers/Microsoft.Web/sites/$($Names.functionApp)") 'Publish to the finalized Function App.' + return 'b' * 64 + } + $setup = @{ + TenantId = $script:Tenant; SubscriptionId = $script:Subscription + Location = 'westus2'; ServicePlan = 'FC1'; ResourcePrefix = 'unit' + AssetDirectory = $ModulePath; OutputDirectory = $Directory + SourceBaseUri = "https://raw.githubusercontent.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/$('a' * 40)/setup" + } + + $preview = Get-EppResourceNames -SubscriptionId $script:Subscription -ResourcePrefix unit -PendingApplication + Assert (@($preview.Values | Where-Object { -not $_.Contains('') }).Count -eq 0) 'Preview all resource names with an explicit placeholder, not a fabricated client ID.' + Assert-Throws { Get-EppResourceNames -SubscriptionId $script:Subscription -ResourcePrefix unit } 'nonempty GUID' + Assert-Throws { + Get-EppResourceNames -SubscriptionId $script:Subscription -ApplicationId $script:AppId -ResourcePrefix unit -PendingApplication + } 'Pending application names' + + foreach ($answer in @('No', 'n', '')) { + Reset-TestState @('create', $script:ApplicationName, 'soprano', $answer) + Assert ($null -eq (Invoke-EppSetup @setup -PassThru)) 'Cancellation must not return a successful deployment.' + Assert ($script:Creates -eq 0 -and $script:Initializations -eq 0 -and $script:GroupChecks -eq 0) 'Cancel without creating an app or looking up placeholder resource names.' + Assert ($script:ApplicationReads -eq 0 -and $script:RestrictionReads -eq 0) 'A planned new application has no existing Graph configuration to read.' + Assert ($script:PlanInputs.NewApplicationDisplayName -ceq $script:ApplicationName -and -not $script:PlanInputs.ApplicationId) 'Carry the requested name, not a fake client ID, through preflight.' + Assert ($script:PlanNames.keyVault -ceq 'unit-epp-kv-') 'Disclose pending names in the approval plan.' + Assert ($script:Messages -contains 'Provider names: Soprano, Telesign') 'Offer all guided providers alphabetically for a new app.' + 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 @('create', ('x' * 257), "bad`nname", $script:ApplicationName, '1', 'TELESIGN', 'No') + $null = Invoke-EppSetup @setup + Assert ($script:Warnings.Count -eq 3 -and $script:Answers.Count -eq 0) 'Reprompt invalid names and provider numbers without selecting defaults.' + Reset-TestState @('create', $script:ApplicationName, 'soprano', 'No') + $script:EmptyApplications = $true + $null = Invoke-EppSetup @setup + Assert ($script:Creates -eq 0 -and $null -ne $script:PlanInputs) 'Offer creation even when the tenant has no matching applications.' + Reset-TestState @('create', $script:ApplicationName, 'soprano') + $script:PackageFailure = 'package-preflight-failed' + Assert-Throws { Invoke-EppSetup @setup } '^package-preflight-failed$' + Assert ($script:Creates -eq 0 -and -not (Test-Path -LiteralPath $Directory)) 'Preflight failure must not leave a new application behind.' + Reset-TestState @('create', $script:ApplicationName, 'soprano') + Assert-Throws { Invoke-EppSetup @setup -CertificateAction Reuse } 'No encryption certificate is registered' + Assert ($script:Creates -eq 0) 'Reject incompatible certificate choices before creating an app.' + + foreach ($explicitApproval in @($false, $true)) { + Reset-TestState @('create', $script:ApplicationName, 'soprano') + if ($explicitApproval) { $script:Approved = $true } + else { $script:Answers.Enqueue('y') } + $setup.OutputDirectory = Join-Path $Directory "approved-$explicitApproval" + Assert-Throws { Invoke-EppSetup @setup -ApproveDeployment:$explicitApproval } '^application-deployment-boundary$' + Assert ($script:Creates -eq 1 -and $script:Initializations -eq 1 -and $script:GroupChecks -eq 1) 'Create once, resolve resource ownership, then configure the endpoint.' + Assert ($script:Calls.IndexOf('create') -gt $script:Calls.IndexOf('plan') -and + $script:Calls.IndexOf('group-check') -gt $script:Calls.IndexOf('create') -and + $script:Calls.IndexOf('initialize') -gt $script:Calls.IndexOf('group-check')) 'Keep creation and resolved-name checks inside the approved deployment phase.' + if (-not $explicitApproval) { + Assert ($script:Calls.IndexOf('create') -gt $script:Calls.IndexOf('approval')) 'Wait for the final affirmative response before creation.' + } + Assert ($script:DeploymentInputs.ApplicationId -ceq $script:AppId -and + $script:DeploymentContext.Application.Id -ceq $script:ObjectId) 'Use the returned client and object IDs, never an existing app with the same display name.' + $recordPath = Join-Path $setup.OutputDirectory "application-$script:AppId.json" + $record = Read-EppJson $recordPath + $expectedNames = Get-EppResourceNames -SubscriptionId $script:Subscription -ApplicationId $script:AppId -ResourcePrefix unit + Assert ($record.applicationId -ceq $script:AppId -and $record.applicationObjectId -ceq $script:ObjectId -and + $record.provider -ceq 'soprano' -and $record.displayName -ceq $script:ApplicationName) 'Persist creation details for recovery before later deployment steps can fail.' + foreach ($key in $expectedNames.Keys) { + Assert ($record.resources[$key] -ceq $expectedNames[$key]) 'New registrations and later client-ID reruns must use identical resource names.' + } + Assert ($script:DeploymentContext.CertificatePlan.VaultName -ceq $expectedNames.keyVault) 'Bind certificate creation to the final vault name.' + Assert (($script:Warnings -join "`n").Contains("-ApplicationId '$script:AppId'")) 'Failures must identify the app to reuse rather than encourage duplicate creation.' + Assert ($script:Answers.Count -eq 0) 'Do not introduce a second deployment approval.' + } + + Reset-TestState @('Yes') + $setup.OutputDirectory = Join-Path $Directory 'existing' + Assert-Throws { + Invoke-EppSetup @setup -ApplicationId $script:ExistingAppId -Provider telesign + } '^application-deployment-boundary$' + Assert ($script:Creates -eq 0 -and $script:GroupChecks -eq 1 -and + $script:DeploymentInputs.ApplicationId -ceq $script:ExistingAppId) 'Preserve supplied-client-ID setup and its preapproval resource checks.' + + Reset-TestState @('create', $script:ApplicationName, 'Yes') + $setup.OutputDirectory = Join-Path $Directory 'explicit-provider' + Assert-Throws { Invoke-EppSetup @setup -Provider TELESIGN } '^application-deployment-boundary$' + $record = Read-EppJson (Join-Path $setup.OutputDirectory "application-$script:AppId.json") + Assert ($record.provider -ceq 'telesign' -and $script:Answers.Count -eq 0) 'Respect an explicit provider without another provider prompt.' + + Reset-TestState @('create', $script:ApplicationName, 'soprano', 'Yes') + $setup.OutputDirectory = Join-Path $Directory 'changed-user' + $script:ChangedOperator = $true + Assert-Throws { Invoke-EppSetup @setup } 'user changed after the plan' + Assert ($script:Creates -eq 0 -and -not (Test-Path -LiteralPath $setup.OutputDirectory)) 'Revalidate the operator before creating the application.' + + Reset-TestState @('create', $script:ApplicationName, 'soprano', 'Yes') + $setup.OutputDirectory = Join-Path $Directory 'creation-failed' + $script:CreationFailure = 'Authorization_RequestDenied: cannot create application' + Assert-Throws { Invoke-EppSetup @setup } 'Authorization_RequestDenied' + Assert ($script:Creates -eq 1 -and $script:GroupChecks -eq 0 -and $script:Initializations -eq 0) 'Do not retry an uncertain create or continue deployment after failure.' + Assert (-not (Test-Path -LiteralPath (Join-Path $setup.OutputDirectory "application-$script:AppId.json"))) 'Do not write success-shaped creation output after a failed request.' + + Reset-TestState @('create', $script:ApplicationName, 'soprano', 'Yes') + $setup.OutputDirectory = Join-Path $Directory 'resource-collision' + $script:GroupExists = $true + Assert-Throws { Invoke-EppSetup @setup } 'not owned by this EPP application' + Assert ($script:Creates -eq 1 -and $script:Initializations -eq 0) 'Check final resource ownership before mutating app configuration or deploying Azure resources.' + 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') + $setup.OutputDirectory = Join-Path $Directory "complete-$provider" + $script:StopAtInitialization = $false + $result = Invoke-EppSetup @setup -PassThru + Assert ($result.applicationId -ceq $script:AppId -and $result.provider -ceq $provider -and + $result.policyChanged -eq $false -and $script:Creates -eq 1) 'Complete the existing deployment flow with the new app and chosen provider.' + $summaries = @(Get-ChildItem -LiteralPath $setup.OutputDirectory -Filter 'deployment-*.json') + 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 ($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) { + Assert ($summary.resources[$key] -ceq $record.resources[$key] -and + $result.resources[$key] -ceq $script:DeploymentParameters.resourceNames.value[$key]) 'Keep registration, deployment, return value, and summary resource names consistent.' + } + Assert ($summary.encryptionKeyId -ceq $script:PlanKeyId -and + $summary.certificateSecretId.StartsWith("https://$($record.resources.keyVault).vault.azure.net/secrets/")) 'Persist the reviewed key ID and finalized certificate location.' + Assert ($script:CertificateDisposed -and $script:Warnings.Count -eq 0) 'Complete cleanup without a failure warning.' + } + } $ModulePath $directory + Write-Host 'New application planning, alphabetized providers, approval, creation, deterministic names, recovery, and existing-app checks passed.' +} +finally { + Remove-Module -ModuleInfo $module -Force + if (Test-Path -LiteralPath $directory) { Remove-Item -LiteralPath $directory -Recurse -Force } +} diff --git a/setup/tests/Graph.Tests.ps1 b/setup/tests/Graph.Tests.ps1 index 91e8bba..f11964c 100644 --- a/setup/tests/Graph.Tests.ps1 +++ b/setup/tests/Graph.Tests.ps1 @@ -171,6 +171,53 @@ try { $invalid = @{} + $credential; $invalid.key = 'invalid-base64' Assert-Throws { ConvertFrom-EppGraphApplication @{ id = $script:ObjectId; appId = $script:ApplicationId; keyCredentials = @($invalid) } } 'invalid base64' + $newApplication = @{ + id = $script:ObjectId; appId = $script:ApplicationId + displayName = "O'Reilly & Co EPP"; signInAudience = 'AzureADMyOrg' + } + Reset-TestState + $script:Responses.Enqueue($newApplication) + $created = New-EppApplication -Inputs $inputs -DisplayName " O'Reilly & Co EPP " + Assert ($created.AppId -ceq $script:ApplicationId -and $created.Id -ceq $script:ObjectId) 'Return the new client and object IDs separately.' + Assert ($script:Calls.Count -eq 1 -and $script:Calls[0].Method -ceq 'post' -and + $script:Calls[0].Url -ceq 'https://graph.microsoft.com/v1.0/applications') 'Create exactly one application without a name lookup or automatic retry.' + $body = $script:Calls[0].Body + Assert (($body.Keys | Sort-Object) -join ',' -ceq 'displayName,signInAudience') 'Do not create credentials, redirect URIs, roles, or API permissions with the registration.' + Assert ($body.displayName -ceq "O'Reilly & Co EPP" -and $body.signInAudience -ceq 'AzureADMyOrg') 'Preserve the approved name through JSON and start with a single-tenant app.' + foreach ($path in $script:Files) { Assert (-not (Test-Path -LiteralPath $path)) 'Remove the application creation request file.' } + + foreach ($name in @('x', ('x' * 256))) { + Reset-TestState + $response = @{} + $newApplication; $response.displayName = $name + $script:Responses.Enqueue($response) + Assert ((New-EppApplication -Inputs $inputs -DisplayName $name).DisplayName -ceq $name) 'Accept both ends of the 1-256 character display-name range.' + } + foreach ($name in @('', ' ', ('x' * 257), "bad`nname", "bad$([char]0x7f)name", '')) { + Reset-TestState + Assert-Throws { New-EppApplication -Inputs $inputs -DisplayName $name } 'ApplicationDisplayName' + Assert ($script:Calls.Count -eq 0) 'Reject invalid application names before a Graph write.' + } + Reset-TestState + $script:CurrentTenant = $script:Subscription + Assert-Throws { New-EppApplication -Inputs $inputs -DisplayName $newApplication.displayName } 'does not match' + Assert ($script:Calls.Count -eq 0) 'Never create an application in a different CLI tenant.' + foreach ($change in @( + @{ displayName = 'different name' }, @{ signInAudience = 'AzureADMultipleOrgs' }, + @{ appId = 'invalid' }, @{ keyCredentials = @($credential) }, + @{ api = @{ requestedAccessTokenVersion = 2 } } + )) { + Reset-TestState + $response = @{} + $newApplication + foreach ($entry in $change.GetEnumerator()) { $response[$entry.Key] = $entry.Value } + $script:Responses.Enqueue($response) + Assert-Throws { New-EppApplication -Inputs $inputs -DisplayName $newApplication.displayName } 'approved new registration|nonempty GUID' + Assert ($script:Calls.Count -eq 1) 'An invalid creation response must not trigger another create request.' + } + Reset-TestState + $script:Failure = 'Authorization_RequestDenied: application creation denied' + Assert-Throws { New-EppApplication -Inputs $inputs -DisplayName $newApplication.displayName } 'Authorization_RequestDenied' + Assert ($script:Calls.Count -eq 1) 'Surface creation failures without retrying a non-idempotent POST.' + Reset-TestState Set-EppProviderTenantRestriction -Inputs $inputs -ApplicationObjectId $script:ObjectId -ProviderTenantId $script:User Assert ($script:Calls[0].Method -ceq 'patch' -and $script:Calls[0].Url -like '*/beta/applications/*') 'Use beta only for the allowed-tenants preview.' @@ -215,6 +262,6 @@ try { Assert-Throws { Invoke-EppGraphRequest -Inputs $inputs -Method PATCH -Path "/v1.0/applications/$script:ObjectId" -Body @{ displayName = 'test' } } 'Authorization_RequestDenied' foreach ($path in $script:Files) { Assert (-not (Test-Path -LiteralPath $path)) 'Clean up after permission failures without swallowing the error.' } } - Write-Host 'CLI Graph identity, audience, tenant guards, requests, pagination, credentials, exact grants, federation, and failure checks passed.' + Write-Host 'CLI Graph identity, audience, tenant guards, application creation, requests, pagination, credentials, exact grants, federation, and failure checks passed.' } finally { Remove-Module -ModuleInfo $module -Force } diff --git a/setup/tests/ProviderConfiguration.Tests.ps1 b/setup/tests/ProviderConfiguration.Tests.ps1 index c68f0db..16d11b2 100644 --- a/setup/tests/ProviderConfiguration.Tests.ps1 +++ b/setup/tests/ProviderConfiguration.Tests.ps1 @@ -46,6 +46,8 @@ try { foreach ($answer in $Answers) { $script:Answers.Enqueue($answer) } $script:Restriction = $null $script:ApplicationReads = 0; $script:RestrictionReads = 0; $script:EnsureCalls = 0 + $script:ApplicationReadErrors = [Collections.Generic.Queue[string]]::new() + $script:Sleeps = 0 $script:Application = [pscustomobject]@{ Id = $script:ObjectId; AppId = $script:AppId; SignInAudience = 'AzureADMyOrg' TokenEncryptionKeyId = $null; KeyCredentials = @(); IdentifierUris = @() @@ -74,6 +76,7 @@ try { function script:Get-EppApplication { param($Inputs, $ClientId, $ObjectId) $script:ApplicationReads++ + if ($script:ApplicationReadErrors.Count) { throw $script:ApplicationReadErrors.Dequeue() } return $script:Application } function script:Get-EppSignInAudienceRestrictions { @@ -92,11 +95,17 @@ try { throw 'provider-configuration-finished' } function script:Invoke-EppAz { throw 'Unexpected Azure operation.' } + function script:Start-Sleep { + param($Seconds) + Assert ($Seconds -eq 5) 'Use a bounded app-registration propagation delay.' + $script:Sleeps++ + } Reset-TestState @('Telesign') $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 (($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.' Reset-TestState @('1', 'soprano') Assert ((Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory) -ceq 'soprano') 'Reject a provider number, then accept a name.' @@ -191,6 +200,29 @@ try { Assert-Throws { Initialize-EppGraphAccess -Inputs $inputs -Context $context } 'changed after preflight' Assert ($script:Patches.Count -eq 0) 'Do not re-expose an app whose sign-in audience changed after review.' } + + foreach ($case in @( + @{ New = $true; Error = 'Request_ResourceNotFound'; Failures = 2; Reads = 3; Sleeps = 2; Expected = '^provider-configuration-finished$' }, + @{ New = $true; Error = 'Request_ResourceNotFound'; Failures = 12; Reads = 12; Sleeps = 11; Expected = 'Request_ResourceNotFound' }, + @{ New = $true; Error = 'Authorization_RequestDenied'; Failures = 1; Reads = 1; Sleeps = 0; Expected = 'Authorization_RequestDenied' }, + @{ New = $false; Error = 'Request_ResourceNotFound'; Failures = 1; Reads = 1; Sleeps = 0; Expected = 'Request_ResourceNotFound' } + )) { + Reset-TestState + $inputs = New-TestInputs + $inputs.ProviderAllowedTenantIds = @($telesign.TenantId) + if ($case.New) { $inputs.NewApplicationDisplayName = 'New EPP application' } + for ($index = 0; $index -lt $case.Failures; $index++) { $script:ApplicationReadErrors.Enqueue($case.Error) } + $context = @{ + Application = @{ Id = $script:ObjectId; SignInAudience = $script:Application.SignInAudience } + CertificatePlan = @{ Snapshot = Get-EppKeyCredentialSnapshot @() } + ProviderRestrictionState = $null + } + Assert-Throws { Initialize-EppGraphAccess -Inputs $inputs -Context $context } $case.Expected + Assert ($script:ApplicationReads -eq $case.Reads -and $script:Sleeps -eq $case.Sleeps) 'Retry only bounded new-application read propagation, not permission errors or existing-app failures.' + if ($case.Expected -ne '^provider-configuration-finished$') { + Assert ($script:Patches.Count -eq 0 -and $script:EnsureCalls -eq 0) 'Failed readback must stop before any application configuration.' + } + } } $ModulePath Write-Host 'Named provider input, zero/one/multiple app configurations, confirmation, preservation, and stale-plan checks passed.' }