diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 22b7e4b..8b827c5 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 deployment progress without Azure access
+ shell: pwsh
+ run: ./setup/tests/Progress.Tests.ps1
- name: Compile Bicep without deploying
shell: pwsh
run: |
diff --git a/README.md b/README.md
index 683811c..43f4c9c 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# External Phone Provider Azure Function Sample
Connect Microsoft Entra ID to a phone provider for SMS and voice one-time passwords.
-Guided setup deploys **one regional endpoint** using JavaScript, C#, or Python.
+Guided setup deploys **one regional endpoint** using JavaScript by default, with C# and Python options.
> [!IMPORTANT]
> Start in a separate nonproduction environment. Confirm provider access and Microsoft's approved
@@ -50,7 +50,7 @@ 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.
-3. [Deploy](docs/GETTING-STARTED.md#3-run-guided-setup): choose the provider, runtime, and plan.
+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 af8f929..7ad1cb8 100644
--- a/docs/GETTING-STARTED.md
+++ b/docs/GETTING-STARTED.md
@@ -99,15 +99,23 @@ Review the downloaded script, then run it:
On a shared workstation, use `.\Setup-Epp.ps1 -ForceAuthentication` for a fresh sign-in.
-Select the tenant, subscription, existing application **client ID**, and Azure region. Then choose:
-
-- **Provider and route:** enter or confirm the provider by name. **Tenant scope** means Global/EU
- provider routing, not Azure region or a data-residency guarantee.
-- **Platform:** Node.js, .NET, or Python. Setup downloads the matching stable release asset,
- verifies `SHA256SUMS.txt`, and builds when required.
+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
+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).
+ 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
+ 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).
-- **Resource prefix:** 2-8 lowercase letters/digits, starting with a letter.
+ [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.
Complete required sign-in, MFA, or administrator consent. Resolve preflight errors before approval.
@@ -115,17 +123,22 @@ For existing encryption credentials, follow
[certificate selection](../setup/docs/README.md#encryption-certificate-selection). Coordinate
changes across every endpoint sharing the application.
-**Review the deployment plan before typing `Yes`.** Check accounts, region, provider URLs,
+**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.
The plan includes Microsoft Graph `Application.Read.All` for the Microsoft phone-provider service
-principal. `No` or Enter cancels.
+principal. `No`, `N`, or Enter cancels. Responses are case-insensitive.
**Plan review:** run interactively and choose **No** at the final prompt. See
[plan review](../setup/docs/README.md#reviewing-the-plan-without-deploying).
-Wait for **Deployment completed** and the saved summary before Step 4. Setup creates or updates
+Azure resource deployment and Function publication can each take several minutes. Keep the session
+open; a rotating indicator identifies the operation in progress, without a percentage or estimated
+completion time. Unattended or redirected output uses static status messages instead.
+
+Wait for **Deployment completed successfully** and the saved summary before Step 4. Setup creates or updates
the Function App, hosting plan, storage, Key Vault, identities, Log Analytics, Application
Insights, diagnostics, Easy Auth, and scoped role assignments. Do not precreate these resources.
+**Save the output: the endpoint URL and Application (client) ID are required for policy updates.**
Keep `epp-output` private. Its [summary](../setup/docs/README.md#read-the-deployment-summary)
records the selected tenant, subscription, provider route, runtime, plan, resource names, endpoint,
application, package provenance, and encryption certificate references. No private key is downloaded.
diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md
index 4d7989a..582643d 100644
--- a/docs/ONBOARDING.md
+++ b/docs/ONBOARDING.md
@@ -49,14 +49,16 @@ renewal.
| Endpoint application client ID | Microsoft Entra admin center > App registrations > application > Overview | `-ApplicationId` |
| 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 | Provider-confirmed Global or EU route | `-EndpointRegion global` or `eu` |
-| Runtime and plan | JavaScript, .NET, or Python with FC1 or EP1 | `-Language`, `-ServicePlan` |
-| Resource prefix | Unique 2-8 character lowercase label that starts with a letter | `-ResourcePrefix` |
+| 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` |
+| 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 |
| 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
-are different values. Global or EU selects provider routing, not Azure location or data residency.
+are different values. Global or EU identifies provider routing, not Azure location or data residency.
+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.
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.
@@ -68,7 +70,8 @@ and [certificate selection](../setup/docs/README.md#encryption-certificate-selec
### Values setup configures
-Keep `deployment-*.json` and the public certificate in private storage. Compare the summary with
+Save the deployment output: the endpoint URL and Application (client) ID are required for policy
+updates. Keep `deployment-*.json` and the public certificate in private storage. Compare the summary with
the approved plan. `policyChanged: false` is expected because setup does not activate policy.
| Setting | Purpose | Check |
diff --git a/setup/Setup-Epp.ps1 b/setup/Setup-Epp.ps1
index e6debb8..78df066 100644
--- a/setup/Setup-Epp.ps1
+++ b/setup/Setup-Epp.ps1
@@ -17,6 +17,14 @@
.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.
+.PARAMETER Language
+ JavaScript by default, without a prompt. Use csharp or python to select another language.
+ The legacy dotnet value remains supported for C#.
+.PARAMETER EndpointRegion
+ Optional eu/global provider route assertion. Setup detects the tenant replication scope
+ and rejects a conflicting value. Omit to use the detected route.
+.PARAMETER ResourcePrefix
+ Interactive resource-name prefix, defaulting to epp on Enter. Required for unattended setup.
.PARAMETER InstallPrerequisites
Install the Azure CLI Bicep component after explicit opt-in. The Graph SDK is not required.
.PARAMETER ForceAuthentication
@@ -47,7 +55,7 @@ param(
[string] $Provider,
[string] $EndpointRegion,
[string] $ResourcePrefix,
- [string] $Language,
+ [string] $Language = 'javascript',
[string] $ServicePlan,
[ValidateSet('Create', 'Reuse', 'Replace')]
[string] $CertificateAction,
diff --git a/setup/docs/README.md b/setup/docs/README.md
index c17c812..7d9aac3 100644
--- a/setup/docs/README.md
+++ b/setup/docs/README.md
@@ -23,9 +23,13 @@ Setup creates **one regional endpoint**. See [Front Door](../../docs/FRONTDOOR.m
## Availability
-Choose **Telesign or Soprano**, **Global/EU** provider routing (prompt: **Tenant scope**),
-**Node.js/.NET/Python**, and **FC1/EP1** hosting. Setup verifies `SHA256SUMS.txt`, builds as needed,
-and publishes; no package URL/hash input is needed. See [source/package selection](#source-versioning).
+Choose **Telesign or Soprano** 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`.
+The legacy `-Language dotnet` value remains accepted for C#.
+Setup verifies `SHA256SUMS.txt`, builds as needed, and publishes; no package URL/hash input is needed.
+See [source/package selection](#source-versioning).
Both channels are configured. Profile objects `deployment.routes.global` and
`deployment.routes.eu` define `smsEndpoint`, `voiceEndpoint`, tenant, authentication, and timing
@@ -38,10 +42,36 @@ The current profiles use identical Global/EU URLs, and Soprano uses the same app
The route label is not a residency or Azure-region selection. Infobip and Sinch are bundled
adapters without guided profiles.
+### Tenant scope and Azure region recommendations
+
+Setup reads `tenant_region_scope` from the selected tenant's public-cloud OpenID Connect discovery
+document before region selection. It does not infer replication scope from the tenant's country,
+the subscription, or the chosen Azure region. Missing or invalid discovery metadata stops setup.
+`-EndpointRegion` is optional in both interactive and unattended runs; an explicit value must match
+the detected EU/Global provider route.
+
+Only tenants with replication scope **EU** receive a filtered shortlist: Azure-recommended physical
+regions within the [EU Data Boundary](https://learn.microsoft.com/privacy/eudb/eu-data-boundary-learn#eu-data-boundary-countries-and-datacenter-locations),
+which includes EU and EFTA countries. Other scopes retain the existing shortlist and ordering.
+The region catalog comes from the subscription's live location metadata, not a separately hosted
+datacenter configuration file. The filter checks Azure **geography**, not the broader
+`geographyGroup`: the [Europe geography](https://learn.microsoft.com/azure/reliability/regions-list)
+contains North Europe (Ireland) and West Europe (Netherlands), while the Europe group also contains
+the United Kingdom, which is outside the boundary.
+
+**Show all regions**, manual entry, and explicit `-Location` remain available. For EU tenants,
+an out-of-boundary selection warns; missing geography metadata produces an unverifiable-location
+warning instead. The selected region remains in the deployment plan for review. An empty EU
+shortlist does not silently fall back to global recommendations. Availability and quota checks
+still apply. These are deployment recommendations, not enforcement or a GDPR compliance guarantee.
+
## Service plan selection
Setup offers these two Linux hosting plans:
+Review the [Azure hosting and provider cost estimate](../../docs/COST-CALCULATOR.md), also linked
+directly from the service-plan prompt.
+
| Option | Hosting |
|---|---|
| 1. Flex Consumption (FC1) | On-demand with a free usage grant; zero always-ready instances, 2048 MB, up to 40 on-demand instances |
@@ -118,7 +148,7 @@ Setup configures:
- The selected **Linux FC1 or EP1** plan available in the region with enough quota. Resolve
[`SubscriptionIsOverQuotaForSku`](Troubleshooting.md#deployment-fails-with-subscriptionisoverquotaforsku)
before retrying. The operator needs subscription-scoped `/register/action`.
-- **.NET only:** install the .NET 8 SDK and allow NuGet access. Setup publishes for `linux-x64`,
+- **C# only:** install the .NET 8 SDK and allow NuGet access. Setup publishes for `linux-x64`,
packages, and deploys automatically.
- **Python:** Azure builds Linux dependencies; no local Python/pip install is required. The source
archive is not a run-from-package artifact.
@@ -148,7 +178,7 @@ Open a new **PowerShell 7** window after installation, then check:
```powershell
$PSVersionTable.PSVersion
az version
-# Only when choosing dotnet:
+# Only when choosing csharp:
dotnet --list-sdks
```
@@ -157,7 +187,7 @@ Interactive setup can install missing Bicep.
| Choice | Azure runtime | Automatic deployment path |
|---|---|---|
| JavaScript | Node.js 22, Functions v4 | Verify and publish the ready ZIP with its production dependencies |
-| .NET | .NET 8 isolated, Functions v4 | Verify source ZIP, publish for Linux with .NET 8, repackage and publish |
+| C# | .NET 8 isolated, Functions v4 | Verify source ZIP, publish for Linux with .NET 8, repackage and publish |
| Python | Python 3.11, Functions v4 | Verify source ZIP; One Deploy with remote build on FC1, or validate/download and publish the SCM-built output on EP1 |
Setup records source and built-package hashes separately. FC1 Python leaves `packageSha256` null
@@ -197,13 +227,16 @@ 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.
-2. **Choose the dedicated app registration and Azure region.** The application picker shows
+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. The region picker uses Azure's current Recommended
- metadata for a short list, with all-physical-region and manual-entry options. Supplied values
- skip these selectors.
-3. **Choose the Global or EU provider route** (shown as **Tenant scope**).
-4. **Choose a provider by name**, then an Azure Function **platform**: Node.js, .NET, or Python.
+ entry, and 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.
+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. Existing allowed provider tenants are preserved; provider options are never numbered.
@@ -214,11 +247,13 @@ The flow is:
5. **Service plan selection:** choose **Flex Consumption FC1** (free usage grant, zero always-ready
instances) or **Premium EP1** (warm instance).
Supply `-ServicePlan FC1` or `-ServicePlan EP1` to reuse a known selection.
-6. **Enter a resource prefix**, such as `contoso`. All resources created by the script start with
+6. **Accept the default resource prefix `epp`, or enter your own**, such as `contoso`.
+ All resources created by the script start with
this prefix. Use 2-8 lowercase letters or digits, starting with a letter. Every top-level
resource name then adds the meaningful `epp` marker, for example
`contoso-epp-rg-`. A deterministic suffix derived from the
- subscription, application ID, and prefix reduces global-name collisions. Reruns use the same names.
+ subscription, application ID, and prefix reduces global-name collisions. Reruns use the same names
+ when given the original prefix. Unattended setup still requires `-ResourcePrefix`.
7. **Check prerequisites and revalidate authentication.** Missing Bicep can be installed after a
separate confirmation. Graph operations reuse the selected Azure CLI user session; no Graph
SDK installation or separate sign-in occurs.
@@ -233,8 +268,15 @@ The flow is:
exact names; it does not independently calculate a different naming scheme.
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. **Type `Yes` once to deploy.** `No` or Enter cancels without Azure changes. Invalid answers prompt
- again; individual resources do not request additional approvals.
+10. **Enter `Yes` or `Y` to deploy.** `No`, `N`, or Enter cancels without Azure changes. Responses
+ are case-insensitive. Invalid answers prompt again; individual resources do not request
+ additional approvals.
+
+Azure resource deployment and Function publication can each take several minutes; sign-in, builds,
+and permission propagation can add time. Keep the session open until deployment completes.
+A rotating activity indicator identifies long-running deployment operations without displaying
+elapsed time, a percentage, or a completion estimate. Redirected output and `-NonInteractive` runs
+use static status messages.
### Encryption certificate selection
@@ -295,8 +337,9 @@ before activation or returning the endpoint to service.
### Reviewing the plan without deploying
`Setup-Epp.ps1` does not implement `-DryRun`, `-WhatIf`, or PowerShell `ShouldProcess`.
-To review a real plan, run interactively, complete preflight, then answer **No** or press Enter
-at `Deploy this complete plan? Type Yes or No [No]`. Do not supply `-ApproveDeployment`:
+To review a real plan, run interactively, complete preflight, then answer **No**, **N**, or press Enter
+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
@@ -328,11 +371,12 @@ provider's ID (`soprano` or `telesign`); neither is a default or recommendation:
-Language javascript `
-ServicePlan FC1 `
-Provider '' `
- -EndpointRegion global `
-ResourcePrefix contoso
```
-Replace the ID placeholders with quoted GUID strings before execution. The `-Channel` parameter
+Replace the ID placeholders with quoted GUID strings before execution. Provider routing is detected
+from the tenant; omit `-EndpointRegion` unless you need to assert an expected matching route.
+The `-Channel` parameter
has been removed; omit it from existing setup commands. For an independent second provider
deployment, use a different prefix **and a dedicated app**.
The suffix does not include provider/region, so changing those inputs with the same
@@ -377,11 +421,13 @@ there is no separate `phone-provider-decryption-key` secret.
### Noninteractive example
-Supply every input, including `-ServicePlan`, and **both `-NonInteractive -ApproveDeployment`**
+Supply the required inputs, including `-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.
-Replace the IDs/provider placeholder and choose approved route, region, prefix, and plan.
+Replace the IDs/provider placeholder and choose an approved region, prefix, and plan.
+Language defaults to JavaScript; provider routing is detected from the tenant scope.
This JavaScript example supports both channels. Establish the CLI user session for ARM and Graph
first; MFA/consent may still be required. There is no headless service-principal path.
Preinstall Bicep or explicitly allow its installation. In Cloud Shell, reuse the intended
@@ -405,7 +451,6 @@ if ($LASTEXITCODE -ne 0) { throw 'Microsoft Graph token acquisition failed.' }
-ApplicationId $applicationId `
-Location westus2 `
-Provider '' `
- -EndpointRegion global `
-Language javascript `
-ServicePlan FC1 `
-ResourcePrefix contoso `
@@ -445,12 +490,14 @@ already registered.
Open the timestamped JSON under `epp-output` in a private editor. It contains identifiers and
configuration metadata, not provider credentials or private-key bytes. Keep it access-controlled.
+**Save the output: the endpoint URL and Application (client) ID are required for policy updates.**
| Summary field | How to use it |
|---|---|
| `tenantId`, `subscriptionId`, `applicationId` | Verify the intended customer directory, Azure subscription, and endpoint **client ID**. |
+| `tenantRegionScope` | Detected tenant replication scope used to select the provider route and apply EU-only region recommendations. |
| `resources.functionApp`, `resources.keyVault`, `resources.applicationInsights`, `resources.logAnalytics` | Find the exact Azure resources; do not guess names or use a different environment's vault. |
-| `provider`, `endpointRegion`, `providerTenantId` | Confirm provider routing, separate from the customer's directory and Azure location. Both SMS and voice use this configuration. |
+| `provider`, `endpointRegion`, `providerTenantId` | Confirm the tenant-derived provider route, separate from Azure location. Both SMS and voice use this configuration. |
| `allowedProviderTenantIds` | Review the complete preserved provider-tenant allowlist, including the selected provider. This does not enable runtime switching between providers. |
| `providerSmsEndpoint`, `providerVoiceEndpoint` | Confirm the distinct or identical provider URLs for SMS and voice. These are outbound provider URLs, not the Function's `endpointUrl`. |
| `endpointUrl`, `identifierUri` | Exact SendOtp URL and registered app audience information for the authorized test/policy operator. |
@@ -458,7 +505,7 @@ configuration metadata, not provider credentials or private-key bytes. Keep it a
| `certificateAction`, `removedEncryptionKeyIds`, `certificateVaultSubscriptionId` | Review whether creation, reuse, or replacement occurred, the exact retired application key IDs, and the source certificate's subscription. |
| `certificateReferenceResolved` | `true` means the Function's exact secret reference resolved before registration. It is not proof of successful authorized decryption or provider delivery. |
| `endpointServicePrincipalId`, `microsoftPhoneProviderServicePrincipalId` | Tenant-local enterprise-application Object IDs; neither is the endpoint app client ID. |
-| `source`, `sourceRevision`, `packageReleaseTag`, `packageUrl`, `sourcePackageSha256`, `packageSha256`, `language`, `servicePlan` | Commit-pinned setup source, package release provenance, and runtime/plan. FC1 Python's built hash can be null; source and built hashes describe different artifacts. |
+| `source`, `sourceRevision`, `packageReleaseTag`, `packageUrl`, `sourcePackageSha256`, `packageSha256`, `language`, `servicePlan` | Commit-pinned setup source, package release provenance, and language/plan. C# is recorded as `csharp`; older summaries can contain `dotnet`. FC1 Python's built hash can be null; source and built hashes describe different artifacts. |
| `policyChanged` | Expected `false`: deployment has not activated policy. |
The `provider` field must match your selected provider. For example, a record for the Global route
@@ -544,6 +591,7 @@ $tests = @(
'Graph.Tests.ps1',
'ProviderConfiguration.Tests.ps1',
'Selections.Tests.ps1',
+ 'Progress.Tests.ps1',
'Certificates.Tests.ps1',
'Providers.Tests.ps1'
)
@@ -560,9 +608,11 @@ 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
-checks. They also cover certificate choices, provider and package selection, and FC1/EP1
-deployment. GitHub, Azure, Microsoft Graph, certificate, and publication calls are mocked.
-CI runs the same eight suites on Windows and Linux.
+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.
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 571e356..277ae0d 100644
--- a/setup/docs/Troubleshooting.md
+++ b/setup/docs/Troubleshooting.md
@@ -11,7 +11,7 @@ Select the failed stage and keep existing resources. Before rerunning, use
| --- | --- |
| Account missing or wrong tenant | [Account picker](#the-expected-cached-account-or-subscription-is-missing), [directory context](#azure-cli-directory-context-does-not-match-the-selected-tenant) |
| Permission or prerequisite failure | [Preflight](#authentication-permission-or-runtime-preflight-fails), [Graph 403](#az-ad-or-az-rest-returns-403-forbidden), [PrincipalNotFound](#principalnotfound-for-the-azure-operator) |
-| Region or hosting rejected | [Quota](#deployment-fails-with-subscriptionisoverquotaforsku), [plan selection](#service-plan-selection-or-availability-fails), [FC1 object-reference error](#fc1-deployment-preflight-reports-an-object-reference-error), [EP1 location check](#appservice-list-locations-rejects-ep1) |
+| Region or hosting rejected | [Tenant scope and EU recommendations](#tenant-scope-discovery-or-eu-region-recommendations-fail), [quota](#deployment-fails-with-subscriptionisoverquotaforsku), [plan selection](#service-plan-selection-or-availability-fails), [FC1 object-reference error](#fc1-deployment-preflight-reports-an-object-reference-error), [EP1 location check](#appservice-list-locations-rejects-ep1) |
| Certificate or resource registration fails | [Certificate selection](#certificate-selection-reuse-or-cleanup-fails), [resource providers](#a-required-azure-resource-provider-is-not-registered) |
| Download, build, or parser failure | [Old package prompts](#setup-still-asks-for-packageurl-or-packagesha256), [checksum/download](#a-checksum-or-package-download-fails), [.NET](#net-build-fails), [Python](#python-remote-build-fails), [CLI JSON](#azure-cli-warnings-break-json-parsing) |
| Deployment stops after approval | [Publication and startup](#deployment-stops-after-approval) |
@@ -107,6 +107,21 @@ registration. A supported Linux EP1 region can still have zero quota for your su
[quota support](https://learn.microsoft.com/azure/quotas/quotas-overview). Rerun with the reviewed
inputs after quota is available.
+## Tenant scope discovery or EU region recommendations fail
+
+Verify the selected tenant ID and network access to
+`https://login.microsoftonline.com//v2.0/.well-known/openid-configuration`.
+Setup requires valid public-cloud tenant metadata and `tenant_region_scope`; it does not infer a
+scope from country or Azure region, and it does not silently fall back to Global on lookup failure.
+If `-EndpointRegion` conflicts with the detected route, omit it or use the matching value.
+
+EU recommendations use the subscription's Azure-recommended physical regions and EU/EFTA
+geography. The United Kingdom is outside the EU Data Boundary even though Azure groups it under
+Europe. If Azure returns no verified EU recommendations, inspect the full region list and your
+organization's residency requirements. An explicit `-Location` or manual selection remains
+possible, but an out-of-boundary or unverifiable selection warns and still requires plan review.
+Non-EU tenants retain the original recommendation behavior.
+
## Service plan selection or availability fails
**Check:** `-ServicePlan FC1` needs Azure CLI **2.60.0+**; `EP1` needs **2.48.1+**.
diff --git a/setup/packages/catalog.json b/setup/packages/catalog.json
index b4173eb..20af9aa 100644
--- a/setup/packages/catalog.json
+++ b/setup/packages/catalog.json
@@ -4,13 +4,13 @@
"packages": [
{
"id": "javascript",
- "displayName": "Node.js",
+ "displayName": "JavaScript",
"assetName": "epp-javascript.zip",
"buildStrategy": "ready"
},
{
"id": "dotnet",
- "displayName": ".NET",
+ "displayName": "C#",
"assetName": "epp-dotnet-source.zip",
"buildStrategy": "dotnet-publish"
},
diff --git a/setup/support/Epp.Packages.ps1 b/setup/support/Epp.Packages.ps1
index a320654..f604712 100644
--- a/setup/support/Epp.Packages.ps1
+++ b/setup/support/Epp.Packages.ps1
@@ -40,7 +40,7 @@ function Get-EppPackageRelease {
function Get-EppLanguage {
param(
- [string] $AssetDirectory, [string] $Language, [string] $SourceRepository,
+ [string] $AssetDirectory, [string] $Language = 'javascript', [string] $SourceRepository,
[string] $PackageReleaseTag, [switch] $NonInteractive
)
@@ -62,8 +62,15 @@ function Get-EppLanguage {
}
$seen[$entry['id']] = $true
}
- $entry = Select-EppOption -Entries $entries -Name Language -PromptName Platform `
- -Value $Language -NonInteractive:$NonInteractive
+ if ([string]::IsNullOrWhiteSpace($Language)) { throw 'Language must not be empty. Use javascript, csharp, or python.' }
+ $languageId = switch ($Language.ToLowerInvariant()) {
+ { $_ -in @('csharp', 'c#', 'dotnet', '.net') } { 'dotnet'; break }
+ { $_ -in @('javascript', 'node.js') } { 'javascript'; break }
+ 'python' { 'python'; break }
+ default { throw "Unsupported language '$Language'. Use javascript, csharp, or python." }
+ }
+ $entry = $entries | Where-Object { $_['id'] -ceq $languageId } | Select-Object -First 1
+ if (-not $entry) { throw "The package catalog does not contain the selected language '$Language'." }
$release = Get-EppPackageRelease -SourceRepository $SourceRepository -Tag $PackageReleaseTag -TagPrefix $tagPrefix
$releaseTag = [string]$release.tag_name
$releaseBaseUrl = "https://github.com/$SourceRepository/releases/download/$releaseTag"
@@ -162,12 +169,12 @@ function Build-EppDotNetPackage {
param([string] $SourcePath, [string] $Directory)
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
- throw 'The .NET language needs the .NET 8 SDK on this computer. Install it once; setup performs the build automatically.'
+ throw 'Deploying C# requires the .NET 8 SDK on this computer. Install the SDK; setup builds the package automatically.'
}
$versions = @((Invoke-EppDotNet --list-sdks) -split '\r?\n' | ForEach-Object {
if ($_ -match '^(8\.\d+\.\d+)\s') { [Version]$Matches[1] }
} | Sort-Object -Descending)
- if (-not $versions.Count) { throw 'Install the .NET 8 SDK before deploying the .NET language. No Azure resources were changed.' }
+ if (-not $versions.Count) { throw 'Install the .NET 8 SDK before deploying C#. No Azure resources were changed.' }
$sourceDirectory = Join-Path $Directory 'dotnet-source'
$publishDirectory = Join-Path $Directory 'dotnet-publish'
[IO.Compression.ZipFile]::ExtractToDirectory($SourcePath, $sourceDirectory)
diff --git a/setup/support/Epp.Setup.psm1 b/setup/support/Epp.Setup.psm1
index 3dd0dec..19cbbba 100644
--- a/setup/support/Epp.Setup.psm1
+++ b/setup/support/Epp.Setup.psm1
@@ -85,6 +85,8 @@ function Get-EppServicePlan {
param([string] $ServicePlan, [switch] $NonInteractive)
Write-Host "`nService plan selection" -ForegroundColor Cyan
+ Write-Host 'Review estimated Azure hosting and provider costs:'
+ Write-Host 'https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/blob/main/docs/COST-CALCULATOR.md'
Write-Host 'Flex Consumption has a free usage grant, not a zero-cost guarantee. Additional usage, Key Vault, storage, and telemetry can incur charges.'
return Select-EppOption -Entries @(
@{ id = 'FC1'; displayName = 'Flex Consumption FC1 (free grant, cold starts)' }
@@ -99,18 +101,22 @@ function Read-EppInput {
[string] $Kind = 'Text',
[switch] $NonInteractive, [switch] $AllowCancel,
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$')]
- [string] $SourceRepository = 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample'
+ [string] $SourceRepository = 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample',
+ [string] $PromptName, [string] $DefaultValue
)
$supplied = -not [string]::IsNullOrWhiteSpace($Value)
while ($true) {
if (-not $supplied) {
if ($NonInteractive) { throw "-$Name is required in noninteractive mode." }
- $prompt = if ($Hint) { "$Name - $Hint" } else { $Name }
+ $displayName = if ($PromptName) { $PromptName } else { $Name }
+ $prompt = if ($Hint) { "$displayName - $Hint" } else { $displayName }
+ if ($DefaultValue) { $prompt += " [$DefaultValue]" }
$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 -not $Value -and $DefaultValue) { $Value = $DefaultValue }
try {
if (-not $Value -or $Value -match '[\x00-\x1f<>]') { throw 'A nonempty value without placeholders is required.' }
switch ($Kind) {
@@ -224,10 +230,10 @@ function Resolve-EppApplicationProvider {
if ($configured.Count -eq 1) {
$entry = $configured[0]
while ($true) {
- $answer = ([string](Read-Host "Application is configured for $($entry['displayName']). Use this provider? Type Yes or No [No]")).Trim()
- if ($answer -ieq 'Yes') { return [string]$entry['id'] }
- if (-not $answer -or $answer -ieq 'No') { return $null }
- Write-Warning 'Type Yes to use the configured provider, or No/Enter to cancel.'
+ $answer = ([string](Read-Host "Application is configured for $($entry['displayName']). Use this provider? Enter Yes or Y, or No or N to cancel [No]")).Trim()
+ if ($answer -in @('Yes', 'Y')) { return [string]$entry['id'] }
+ if (-not $answer -or $answer -in @('No', 'N')) { return $null }
+ Write-Warning 'Enter Yes or Y to use the configured provider, or No or N to cancel. Press Enter to cancel.'
}
}
Write-Host 'Multiple providers are configured on this application. Enter the provider name to use for this deployment.'
@@ -366,8 +372,16 @@ function Get-EppResourceNames {
}
}
+function Test-EppAnimationEnabled {
+ return $ProgressPreference -eq 'Continue' -and $Host.Name -eq 'ConsoleHost' -and -not [Console]::IsOutputRedirected
+}
+
function Invoke-EppAz {
- param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments)
+ [CmdletBinding(PositionalBinding = $false)]
+ param(
+ [Parameter(Position = 0, ValueFromRemainingArguments)][string[]] $Arguments,
+ [string] $Activity
+ )
$PSNativeCommandUseErrorActionPreference = $false
if ($Arguments[0] -eq 'login') {
@@ -380,13 +394,57 @@ function Invoke-EppAz {
}
$errorPath = Join-Path ([IO.Path]::GetTempPath()) "epp-az-$([Guid]::NewGuid().ToString('N')).stderr"
try {
- $output = & az @Arguments --only-show-errors 2> $errorPath
- $exitCode = $LASTEXITCODE
+ if ($Activity -and (Test-EppAnimationEnabled)) {
+ $command = Get-Command az -CommandType Application, ExternalScript -ErrorAction Stop
+ $worker = [PowerShell]::Create()
+ $line = ''
+ try {
+ # Keep native invocation in PowerShell so az.cmd and argument quoting work on Windows too.
+ $null = $worker.AddScript({
+ param($CommandPath, $Arguments, $ErrorPath)
+ $ErrorActionPreference = 'Stop'
+ $PSNativeCommandUseErrorActionPreference = $false
+ $output = & $CommandPath @Arguments --only-show-errors 2> $ErrorPath
+ @{ Output = $output; ExitCode = $LASTEXITCODE }
+ }).AddArgument($command.Source).AddArgument($Arguments).AddArgument($errorPath)
+ $pending = $worker.BeginInvoke()
+ $frames = @('|', '/', '-', '\')
+ $frame = 0
+ while (-not $pending.IsCompleted) {
+ $line = "$($frames[$frame % $frames.Count]) $Activity..."
+ Write-Host "`r$line" -NoNewline -ForegroundColor Cyan
+ $frame++
+ Start-Sleep -Milliseconds 150
+ }
+ $completed = $worker.EndInvoke($pending)
+ if ($worker.Streams.Error.Count) { throw $worker.Streams.Error[0] }
+ if ($completed.Count -ne 1 -or $null -eq $completed[0].ExitCode) {
+ throw 'Azure CLI did not return an operation result.'
+ }
+ $output = $completed[0].Output
+ $exitCode = $completed[0].ExitCode
+ }
+ finally {
+ try {
+ if ($worker.InvocationStateInfo.State -eq 'Running') { $worker.Stop() }
+ }
+ finally {
+ $worker.Dispose()
+ if ($line) { Write-Host ("`r" + (' ' * $line.Length) + "`r") -NoNewline }
+ }
+ }
+ }
+ else {
+ if ($Activity) { Write-Host "$Activity..." -ForegroundColor Cyan }
+ $output = & az @Arguments --only-show-errors 2> $errorPath
+ $exitCode = $LASTEXITCODE
+ }
$errorText = if (Test-Path -LiteralPath $errorPath) { [string](Get-Content -LiteralPath $errorPath -Raw) } else { '' }
$message = (($output -join "`n") + "`n" + $errorText) -replace '(?i)([?&](?:sig|token|code|client_secret|password)=)[^&\s]+', '$1[REDACTED]'
$message = $message -replace '(?i)(Bearer\s+)[^\s,;]+', '$1[REDACTED]'
if ($exitCode -ne 0) {
- throw "Azure CLI operation '$($Arguments[0]) $($Arguments[1])' failed (exit $exitCode): $message"
+ $operation = ($Arguments | Select-Object -First 2) -join ' '
+ throw "Azure CLI operation '$operation' failed (exit $exitCode): $message"
}
if (-not [string]::IsNullOrWhiteSpace($errorText)) {
$warning = $errorText -replace '(?i)([?&](?:sig|token|code|client_secret|password)=)[^&\s]+', '$1[REDACTED]'
@@ -416,10 +474,10 @@ function Confirm-EppPrerequisiteInstall {
if ($InstallPrerequisites) { return }
if ($NonInteractive) { throw "$Description is missing. Rerun with -InstallPrerequisites or install it manually." }
while ($true) {
- $answer = ([string](Read-Host "Install $Description now? Type Yes or No [No]")).Trim()
- if ($answer -ieq 'Yes') { return }
- if (-not $answer -or $answer -ieq 'No') { throw "$Description is required. No Azure or tenant resources were changed." }
- Write-Warning 'Type Yes to install the prerequisite, or No/Enter to stop.'
+ $answer = ([string](Read-Host "Install $Description now? Enter Yes or Y to install, or No or N to cancel [No]")).Trim()
+ if ($answer -in @('Yes', 'Y')) { return }
+ if (-not $answer -or $answer -in @('No', 'N')) { throw "$Description is required. No Azure or tenant resources were changed." }
+ Write-Warning 'Enter Yes or Y to install the prerequisite, or No or N to cancel. Press Enter to cancel.'
}
}
@@ -728,6 +786,54 @@ function Select-EppApplication {
}
}
+function Get-EppTenantRegionScope {
+ param([string] $TenantId)
+
+ $tenantId = ConvertTo-EppGuid $TenantId
+ $authority = "https://login.microsoftonline.com/$tenantId/v2.0"
+ $metadata = Invoke-RestMethod -Uri "$authority/.well-known/openid-configuration" -TimeoutSec 60 -MaximumRedirection 0
+ if ($null -eq $metadata -or $metadata -is [Array]) {
+ throw 'Microsoft Entra returned invalid tenant discovery metadata. Setup cannot determine the tenant replication scope.'
+ }
+ $issuer = $metadata.PSObject.Properties['issuer']
+ if (-not $issuer -or $issuer.Value -isnot [string] -or $issuer.Value -ine $authority) {
+ throw 'Tenant discovery did not return the selected tenant in the Azure public cloud. Verify the tenant ID; this setup supports Azure public cloud only.'
+ }
+ $scope = $metadata.PSObject.Properties['tenant_region_scope']
+ if (-not $scope -or $scope.Value -isnot [string] -or $scope.Value -notmatch '\A[A-Za-z][A-Za-z0-9]{1,15}\z') {
+ throw 'Microsoft Entra did not return a valid tenant replication scope. Setup cannot determine provider routing; verify the tenant ID and retry.'
+ }
+ return $scope.Value.ToUpperInvariant()
+}
+
+function Resolve-EppEndpointRegion {
+ param([string] $TenantRegionScope, [string] $EndpointRegion)
+
+ if ([string]::IsNullOrWhiteSpace($TenantRegionScope)) { throw 'The tenant replication scope must be determined before selecting provider routing.' }
+ $expected = if ($TenantRegionScope -eq 'EU') { 'eu' } else { 'global' }
+ if ($EndpointRegion) {
+ if ($EndpointRegion -notin @('eu', 'global')) { throw "Unknown EndpointRegion '$EndpointRegion'. Use eu or global, or omit the parameter for automatic detection." }
+ if ($EndpointRegion -ine $expected) {
+ throw "-EndpointRegion '$EndpointRegion' is incompatible with tenant replication scope '$TenantRegionScope'. Omit the parameter or use '$expected'."
+ }
+ }
+ return $expected
+}
+
+function Test-EppEuDataBoundaryGeography {
+ param([string] $Geography)
+
+ if ([string]::IsNullOrWhiteSpace($Geography)) { return $null }
+ # EU/EFTA membership: https://learn.microsoft.com/privacy/eudb/eu-data-boundary-learn
+ # Azure's Europe geography is Ireland/Netherlands, not the Europe geographyGroup (which includes the UK).
+ return $Geography -in @(
+ 'Europe', 'Austria', 'Belgium', 'Bulgaria', 'Croatia', 'Cyprus', 'Czechia', 'Czech Republic',
+ 'Denmark', 'Estonia', 'Finland', 'France', 'Germany', 'Greece', 'Hungary', 'Ireland', 'Italy',
+ 'Latvia', 'Lithuania', 'Luxembourg', 'Malta', 'Netherlands', 'Poland', 'Portugal', 'Romania',
+ 'Slovakia', 'Slovenia', 'Spain', 'Sweden', 'Iceland', 'Liechtenstein', 'Norway', 'Switzerland'
+ )
+}
+
function Get-EppRegionOptions {
param([hashtable] $Inputs)
@@ -745,7 +851,8 @@ function Get-EppRegionOptions {
throw 'Azure returned invalid region metadata.'
}
if ($region.metadata['regionType'] -isnot [string] -or [string]::IsNullOrWhiteSpace($region.metadata['regionType']) -or
- ($null -ne $region.metadata['regionCategory'] -and $region.metadata['regionCategory'] -isnot [string])) {
+ ($null -ne $region.metadata['regionCategory'] -and $region.metadata['regionCategory'] -isnot [string]) -or
+ ($null -ne $region.metadata['geography'] -and $region.metadata['geography'] -isnot [string])) {
throw 'Azure returned invalid region classification metadata.'
}
if ($region.metadata['regionType'] -ne 'Physical') { continue }
@@ -758,36 +865,50 @@ function Get-EppRegionOptions {
$seen[$id] = $true
$recommended = $region.metadata['regionCategory'] -eq 'Recommended'
$label = if ($recommended) { "$name ($id) - Azure recommended" } else { "$name ($id)" }
- @{ id = $id; displayName = $label; recommended = $recommended }
+ @{
+ id = $id; name = $name; displayName = $label; recommended = $recommended
+ euDataBoundary = Test-EppEuDataBoundaryGeography -Geography $region.metadata['geography']
+ }
}
}
function Select-EppLocation {
param([hashtable] $Inputs, [string] $Location, [switch] $NonInteractive, [switch] $ForceAuthentication)
+ $euTenant = $Inputs['TenantRegionScope'] -eq 'EU'
if (-not [string]::IsNullOrWhiteSpace($Location) -or $NonInteractive) {
- return Read-EppInput -Name Location -Value $Location -Kind Location -NonInteractive
+ $Location = Read-EppInput -Name Location -Value $Location -Kind Location -NonInteractive
+ if (-not $euTenant) { return $Location }
}
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
throw 'Azure CLI is not installed or not on PATH. Install Azure CLI, open a new PowerShell 7 window, and rerun setup.'
}
- $null = Connect-EppAzureAccount -Inputs $Inputs `
+ $null = Connect-EppAzureAccount -Inputs $Inputs -NonInteractive:$NonInteractive `
-ForceAuthentication:($ForceAuthentication -and -not $Inputs['AzureSelectionAuthenticated'])
$Inputs.AzureSelectionAuthenticated = $true
$regions = @(Get-EppRegionOptions -Inputs $Inputs)
$preferred = @('eastus', 'westus2', 'centralus', 'westeurope', 'northeurope', 'southeastasia', 'australiaeast', 'japaneast')
- $recommended = @($regions | Where-Object { $_.recommended })
+ $recommended = @($regions | Where-Object { $_.recommended -and (-not $euTenant -or $_.euDataBoundary -eq $true) })
$ordered = @(
foreach ($id in $preferred) { $recommended | Where-Object { $_.id -ceq $id } }
$recommended | Where-Object { $_.id -notin $preferred } | Sort-Object { $_.displayName }
)
$shortlist = @($ordered | Select-Object -First 8)
- Write-Host "`nChoose a region near your users and consistent with your data-residency requirements." -ForegroundColor Cyan
- Write-Host 'The shortlist uses Azure Recommended metadata, not a latency/cost ranking or a guarantee of FC1/EP1 availability or quota. Existing preflight and deployment checks still apply.' -ForegroundColor Yellow
+ if (-not $Location) {
+ if ($euTenant) { Write-Host "`nRecommended Azure regions within the EU Data Boundary:" -ForegroundColor Cyan }
+ else { Write-Host "`nChoose a region near your users and consistent with your data-residency requirements." -ForegroundColor Cyan }
+ Write-Host 'The shortlist uses Azure Recommended metadata, not a latency/cost ranking or a guarantee of FC1/EP1 availability or quota. Existing preflight and deployment checks still apply.' -ForegroundColor Yellow
+ }
$showAll = $false
- while ($true) {
+ while (-not $Location) {
$entries = @(if ($showAll) { $regions | Sort-Object { $_.displayName } } else { $shortlist })
- if (-not $entries.Count) { Write-Warning 'No regions are available in this view. Show all regions or enter a region name manually.' }
+ 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.'
+ }
+ else { 'No regions are available in this view. Show all regions or enter a region name manually.' }
+ Write-Warning $message
+ }
$entries += if ($showAll) {
@{ id = 'shortlist'; displayName = 'Back to recommended regions' }
}
@@ -801,11 +922,24 @@ function Select-EppLocation {
'all' { $showAll = $true }
'shortlist' { $showAll = $false }
'manual' {
- return Read-EppInput -Name Location -Kind Location -Hint 'Programmatic region name, for example westus2; Enter cancels' -AllowCancel
+ $example = if ($euTenant) { 'westeurope' } else { 'westus2' }
+ $Location = Read-EppInput -Name Location -Kind Location -Hint "Programmatic region name, for example $example; Enter cancels" -AllowCancel
+ if (-not $Location) { return $null }
}
- default { return $selection.id }
+ default { $Location = $selection.id }
}
}
+ $selectedRegion = $regions | Where-Object { $_.id -ceq $Location } | Select-Object -First 1
+ $selectedName = if ($selectedRegion) { "$($selectedRegion.name) ($Location)" } else { $Location }
+ Write-Host "Selected Azure region: $selectedName"
+ if ($euTenant -and (-not $selectedRegion -or $selectedRegion.euDataBoundary -ne $true)) {
+ $message = if (-not $selectedRegion -or $null -eq $selectedRegion.euDataBoundary) {
+ 'The selected region could not be verified as an EU Data Boundary location.'
+ }
+ else { 'The selected region is outside the EU Data Boundary.' }
+ Write-Warning "$message Verify that this selection meets your organization's data-residency requirements. The selected region will be included in the deployment plan for review."
+ }
+ return $Location
}
function Get-EppSignInAudienceRestrictions {
@@ -1180,12 +1314,13 @@ function Show-EppPlan {
Write-Host "Subscription: $($Inputs.SubscriptionId)"
Write-Host "Application: $($Inputs.ApplicationId)"
Write-Host "Location: $($Inputs.Location)"
- Write-Host "Platform: $($Inputs.Platform)"
+ Write-Host "Language: $($Inputs.Platform)"
Write-Host "Service plan: $($Inputs.ServicePlan)"
Write-Host "Provider: $($ProviderConfiguration.DisplayName)"
Write-Host 'Channels: SMS and voice (selected by each request)'
- $tenantScope = if ($ProviderConfiguration.EndpointRegion -eq 'eu') { 'EU' } else { 'Global' }
- Write-Host "Tenant scope: $tenantScope"
+ if ($Inputs['TenantRegionScope']) { Write-Host "Tenant replication scope: $($Inputs.TenantRegionScope)" }
+ $providerRoute = if ($ProviderConfiguration.EndpointRegion -eq 'eu') { 'EU' } else { 'Global' }
+ Write-Host "Provider route: $providerRoute"
Write-Host "Provider auth: $($ProviderConfiguration.AuthenticationMode)"
Write-Host "Provider tenant: $($ProviderConfiguration.Settings.EPP_PROVIDER_TENANT_ID)"
if ($Inputs['ProviderAllowedTenantIds']) {
@@ -1250,12 +1385,13 @@ function Show-EppPlan {
Write-Host "`nDeployment notes" -ForegroundColor Cyan
Write-Host ' - Deploy the verified package, synchronize Function triggers, and enable HTTPS ingress after Easy Auth is verified.'
+ Write-Host ' - Azure resource and Function deployment may each take several minutes. Keep this session open until deployment completes.'
if ($Inputs.ServicePlan -eq 'FC1') {
- Write-Host ' - Flex Consumption: publish through One Deploy; Python builds remotely in Azure. No always-ready instances.'
+ Write-Host ' - Flex Consumption: publish through One Deploy. No always-ready instances.'
Write-Host ' - FC1 includes a free usage grant. Usage beyond the grant, Key Vault, storage, and telemetry can incur charges.' -ForegroundColor Yellow
}
- elseif ($Inputs.BuildStrategy -eq 'remote-build') {
- Write-Host ' - Python: use the Entra-protected SCM endpoint for remote build, then save only the built output in private package storage.'
+ if ($Inputs.BuildStrategy -eq 'remote-build') {
+ Write-Host ' - Azure installs Python dependencies and prepares the Function deployment package. No local Python installation is required.'
}
if ($Inputs.ServicePlan -eq 'EP1') {
Write-Host ' - Premium EP1, Key Vault, storage, and telemetry incur Azure charges.' -ForegroundColor Yellow
@@ -1271,10 +1407,10 @@ function Confirm-EppDeployment {
if ($ApproveDeployment) { return $true }
if ($NonInteractive) { throw 'Deployment requires -ApproveDeployment in noninteractive mode. No Azure resources were changed.' }
while ($true) {
- $answer = ([string](Read-Host 'Deploy this complete plan? Type Yes or No [No]')).Trim()
- if ($answer -ieq 'Yes') { return $true }
- if (-not $answer -or $answer -ieq 'No') { return $false }
- Write-Warning 'Type Yes to deploy, or No/Enter to cancel.'
+ $answer = ([string](Read-Host 'Proceed with deployment? Enter Yes or Y to deploy, or No or N to cancel [No]')).Trim()
+ if ($answer -in @('Yes', 'Y')) { return $true }
+ if (-not $answer -or $answer -in @('No', 'N')) { return $false }
+ Write-Warning 'Enter Yes or Y to deploy, or No or N to cancel. Press Enter to cancel.'
}
}
@@ -1323,7 +1459,8 @@ function Show-EppDeploymentResult {
[string] $EndpointUrl, [string] $ResultPath
)
- Write-Host "`nDeployment completed" -ForegroundColor Green
+ Write-Host "`nDeployment completed successfully." -ForegroundColor Green
+ Write-Host 'IMPORTANT: Save this output. The endpoint URL and Application (client) ID are required for policy updates.' -ForegroundColor Yellow
Write-Host "`nAzure resources created or updated" -ForegroundColor Cyan
Get-EppResourceRows -Names $Names -ServicePlan $Inputs.ServicePlan |
Format-Table Resource, Name -AutoSize | Out-String -Width 160 | Write-Host
@@ -1352,6 +1489,8 @@ function Show-EppDeploymentResult {
Write-Host ' appId: ' -NoNewline
Write-Host $Inputs.ApplicationId -ForegroundColor Yellow
Write-Host ' The setup script did not change the External Phone Provider policy.'
+ Write-Host ' Policy configuration and validation guidance:'
+ Write-Host ' https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/blob/main/docs/GETTING-STARTED.md#4-connect-your-provider-and-test'
}
function Get-EppServicePrincipal {
@@ -2104,8 +2243,8 @@ function Set-EppPackageSettings {
function Build-EppPythonPackage {
param([hashtable] $Inputs, [Collections.IDictionary] $Names, [string] $SiteId, [string] $SourcePath, [string] $Directory)
- Write-Host 'Building Python and its Linux dependencies in Azure automatically...' -ForegroundColor Cyan
- Invoke-EppAz functionapp deployment source config-zip --resource-group $Names.resourceGroup --name $Names.functionApp `
+ Invoke-EppAz -Activity 'Preparing the Python deployment package in Azure' `
+ functionapp deployment source config-zip --resource-group $Names.resourceGroup --name $Names.functionApp `
--subscription $Inputs.SubscriptionId --src $SourcePath --build-remote true --timeout 1800 --output none | Out-Null
$site = Invoke-EppAz rest --method get --url "https://management.azure.com$SiteId`?api-version=2024-04-01" `
--subscription $Inputs.SubscriptionId --output json | ConvertFrom-Json -AsHashtable
@@ -2196,10 +2335,10 @@ function Invoke-EppDeployment {
$parameterPath = Join-Path $WorkingDirectory 'deployment.parameters.json'
@{ '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#'; contentVersion = '1.0.0.0'; parameters = $parameters } |
ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $parameterPath -Encoding utf8NoBOM
- Write-Host 'Deploying the compiled ARM template with your approved parameters...' -ForegroundColor Cyan
+ Write-Host 'Deployment may take several minutes. Keep this session open.' -ForegroundColor Cyan
$deploymentName = "epp-$($Inputs.ResourcePrefix)-$([Guid]::NewGuid().ToString('N').Substring(0, 8))"
$outputs = Invoke-EppRegistrationRetry -Operation {
- Invoke-EppAz deployment sub create --name $deploymentName `
+ Invoke-EppAz -Activity 'Deploying Azure resources' deployment sub create --name $deploymentName `
--subscription $Inputs.SubscriptionId --location $Inputs.Location --template-file (Join-Path $AssetDirectory 'main.json') `
--parameters "@$parameterPath" --query properties.outputs --output json
} | ConvertFrom-Json
@@ -2240,6 +2379,7 @@ function Invoke-EppDeployment {
-SiteId $siteId -Package $Package -Directory $WorkingDirectory
$result = [ordered]@{
tenantId = $Inputs.TenantId; subscriptionId = $Inputs.SubscriptionId; applicationId = $Inputs.ApplicationId
+ tenantRegionScope = $Inputs['TenantRegionScope']
provider = $ProviderConfiguration.Id
endpointRegion = $ProviderConfiguration.EndpointRegion; providerAuthentication = $Inputs.ProviderAuthentication
providerTenantId = $Inputs.ProviderTenantId
@@ -2247,7 +2387,7 @@ function Invoke-EppDeployment {
providerSmsEndpoint = $ProviderConfiguration.Settings.EPP_PROVIDER_SMS_ENDPOINT
providerVoiceEndpoint = $ProviderConfiguration.Settings.EPP_PROVIDER_VOICE_ENDPOINT
resourcePrefix = $Inputs.ResourcePrefix; resources = $Names
- language = $Inputs.Language; servicePlan = $Inputs.ServicePlan
+ language = $(if ($Inputs.Language -eq 'dotnet') { 'csharp' } else { $Inputs.Language }); servicePlan = $Inputs.ServicePlan
endpointUrl = $outputs.endpointUrl.value; identifierUri = $outputs.identifierUri.value
encryptionKeyId = $keyId; certificateThumbprint = $certificate.Thumbprint
certificateId = $issued.CertificateId; certificateSecretId = $issued.SecretId
@@ -2290,9 +2430,12 @@ function Publish-EppFunction {
if ($Inputs.ServicePlan -eq 'FC1') {
$ingressOpened = $true
Set-EppPublicAccess -SiteId $SiteId -SubscriptionId $Inputs.SubscriptionId -Access Enabled
- Write-Host 'Publishing the Function package through Flex Consumption One Deploy...' -ForegroundColor Cyan
+ if ($Package.RequiresRemoteBuild) {
+ Write-Host 'Azure installs Python dependencies and prepares the Function deployment package. No local Python installation is required.'
+ }
$remoteBuild = ([bool]$Package.RequiresRemoteBuild).ToString().ToLowerInvariant()
- Invoke-EppAz functionapp deployment source config-zip --resource-group $Names.resourceGroup --name $Names.functionApp `
+ Invoke-EppAz -Activity 'Deploying the Azure Function' `
+ functionapp deployment source config-zip --resource-group $Names.resourceGroup --name $Names.functionApp `
--subscription $Inputs.SubscriptionId --src $Package.Path --build-remote $remoteBuild --timeout 1800 --output none | Out-Null
# One Deploy builds and stores Python output in Azure; its bytes are not downloaded or hashed locally.
if ($Package.RequiresRemoteBuild) { $deployedHash = $null }
@@ -2305,9 +2448,9 @@ function Publish-EppFunction {
$packagePath = Build-EppPythonPackage -Inputs $Inputs -Names $Names -SiteId $SiteId -SourcePath $Package.Path -Directory $Directory
$deployedHash = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant()
}
- Write-Host 'Publishing the ready-to-run Function package...' -ForegroundColor Cyan
Invoke-EppDataOperation {
- Invoke-EppAz storage blob upload --account-name $Names.storageAccount --container-name packages `
+ Invoke-EppAz -Activity 'Publishing the Azure Function package' `
+ storage blob upload --account-name $Names.storageAccount --container-name packages `
--name "$deployedHash.zip" --file $packagePath --auth-mode login --overwrite true `
--subscription $Inputs.SubscriptionId --output none
} | Out-Null
@@ -2323,7 +2466,7 @@ function Publish-EppFunction {
$ingressOpened = $true
Set-EppPublicAccess -SiteId $SiteId -SubscriptionId $Inputs.SubscriptionId -Access Enabled
}
- Invoke-EppAz functionapp restart --resource-group $Names.resourceGroup --name $Names.functionApp `
+ Invoke-EppAz -Activity 'Starting the Azure Function' functionapp restart --resource-group $Names.resourceGroup --name $Names.functionApp `
--subscription $Inputs.SubscriptionId --output none | Out-Null
Sync-EppFunctionTriggers -SiteId $SiteId -SubscriptionId $Inputs.SubscriptionId
}
@@ -2372,7 +2515,16 @@ function Invoke-EppSetup {
.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.
+ 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
+ Optional eu/global provider route assertion. Setup detects the tenant replication scope
+ and rejects a conflicting value. Omit to use the detected route.
+ .PARAMETER Language
+ JavaScript by default, without a prompt. Use csharp or python to select another language.
+ The legacy dotnet value remains supported for C#.
+ .PARAMETER ResourcePrefix
+ Interactive resource-name prefix, defaulting to epp on Enter. Required noninteractively.
.PARAMETER PackageReleaseTag
Latest stable package release by default. Supply an epp-packages-- tag
to pin a reviewed Function release.
@@ -2396,7 +2548,8 @@ function Invoke-EppSetup {
Return the structured deployment summary. By default, only the ordered human-readable
result is displayed, ending with Next steps; the summary JSON is always saved.
.PARAMETER NonInteractive
- Require explicit settings and existing user authentication. Does not approve deployment.
+ Require mandatory settings and existing user authentication. Language defaults to JavaScript
+ and provider routing is detected from the tenant. Does not approve deployment.
.PARAMETER ApproveDeployment
Authorize the displayed deployment plan without another prompt. This changes Azure
resources and application configuration; it is not a preview.
@@ -2413,7 +2566,7 @@ function Invoke-EppSetup {
[string] $TenantId, [string] $SubscriptionId, [string] $ApplicationId, [string] $Location,
[string] $Provider, [string] $EndpointRegion,
[string] $ResourcePrefix,
- [string] $Language,
+ [string] $Language = 'javascript',
[string] $PackageReleaseTag,
[string] $ServicePlan,
[ValidateSet('Create', 'Reuse', 'Replace')]
@@ -2430,6 +2583,7 @@ function Invoke-EppSetup {
if ($NonInteractive -and $ForceAuthentication) {
throw '-ForceAuthentication requires interactive device-code sign-in and cannot be combined with -NonInteractive.'
}
+ if ($NonInteractive) { $ProgressPreference = 'SilentlyContinue' }
if (-not (Test-Path -LiteralPath $AssetDirectory -PathType Container)) {
throw 'Setup assets are missing. Run Setup-Epp.ps1 instead of invoking the temporary helper directly.'
}
@@ -2465,17 +2619,19 @@ function Invoke-EppSetup {
Write-Host 'Cancelled. No Azure resources or application credentials were changed.' -ForegroundColor Yellow
return
}
+ if (-not [string]::IsNullOrWhiteSpace($Location) -or $NonInteractive) {
+ $Location = Read-EppInput -Name Location -Value $Location -Kind Location -NonInteractive
+ }
+ $inputs.TenantRegionScope = Get-EppTenantRegionScope -TenantId $inputs.TenantId
+ $endpointRegion = Resolve-EppEndpointRegion -TenantRegionScope $inputs.TenantRegionScope -EndpointRegion $EndpointRegion
+ Write-Host "Tenant replication scope: $($inputs.TenantRegionScope)"
+ Write-Host "Provider route: $(if ($endpointRegion -eq 'eu') { 'EU' } else { 'Global' })"
$inputs.Location = Select-EppLocation -Inputs $inputs -Location $Location -NonInteractive:$NonInteractive `
-ForceAuthentication:$ForceAuthentication
if (-not $inputs.Location) {
Write-Host 'Cancelled. No Azure resources or application credentials were changed.' -ForegroundColor Yellow
return
}
- $tenantScopeSelection = Select-EppOption -Entries @(
- @{ id = 'global'; displayName = 'Global' }
- @{ id = 'eu'; displayName = 'EU' }
- ) -Name EndpointRegion -PromptName 'Tenant scope' -Value $EndpointRegion -NonInteractive:$NonInteractive
-
if (-not $NonInteractive -and [string]::IsNullOrWhiteSpace($Provider)) {
$null = Connect-EppGraphAccount -Inputs $inputs -ForceAuthentication:$ForceAuthentication
}
@@ -2485,19 +2641,20 @@ function Invoke-EppSetup {
return
}
$providerConfiguration = Get-EppProvider -AssetDirectory $assetDirectory -Provider $providerName `
- -EndpointRegion $tenantScopeSelection['id'] -NonInteractive:$NonInteractive
+ -EndpointRegion $endpointRegion -NonInteractive:$NonInteractive
$inputs.ProviderAuthentication = $providerConfiguration.AuthenticationMode
$inputs.ProviderTenantId = $providerConfiguration.Settings.EPP_PROVIDER_TENANT_ID
$selection = Get-EppLanguage -AssetDirectory $assetDirectory -Language $Language -SourceRepository $SourceRepository `
-PackageReleaseTag $PackageReleaseTag -NonInteractive:$NonInteractive
$inputs.Language = $selection.Id
$inputs.Platform = $selection.DisplayName
+ Write-Host "Programming language: $($selection.DisplayName)"
$inputs.PackageFile = ([Uri]$selection.Url).Segments[-1]
$inputs.BuildStrategy = $selection.BuildStrategy
$plan = Get-EppServicePlan -ServicePlan $ServicePlan -NonInteractive:$NonInteractive
$inputs.ServicePlan = $plan.id
$inputs.ResourcePrefix = Read-EppInput ResourcePrefix $ResourcePrefix -Kind Prefix `
- -Hint 'All resources created by setup will start with this prefix' -NonInteractive:$NonInteractive
+ -PromptName 'Resource name prefix' -DefaultValue 'epp' -NonInteractive:$NonInteractive
$names = Get-EppResourceNames -SubscriptionId $inputs.SubscriptionId -ApplicationId $inputs.ApplicationId -ResourcePrefix $inputs.ResourcePrefix
Write-Host "`nChecking prerequisites and the selected Azure context (no resource changes)..." -ForegroundColor Cyan
diff --git a/setup/tests/Accounts.Tests.ps1 b/setup/tests/Accounts.Tests.ps1
index 1a83033..c7d816e 100644
--- a/setup/tests/Accounts.Tests.ps1
+++ b/setup/tests/Accounts.Tests.ps1
@@ -293,6 +293,7 @@ exit $ExitCode
$null = Connect-EppAzureAccount -Inputs @{ TenantId = $tenantA; SubscriptionId = $subA } -ForceAuthentication
Assert ($script:Calls[0] -ceq "login --tenant $tenantA --use-device-code --output none") 'ForceAuthentication must still reauthenticate to the final selected tenant.'
+ function script:Get-EppTenantRegionScope { param($TenantId); return 'NA' }
function script:Get-EppLanguage {
param($AssetDirectory, $Language, $SourceRepository, $PackageReleaseTag, [switch] $NonInteractive)
return @{
diff --git a/setup/tests/Certificates.Tests.ps1 b/setup/tests/Certificates.Tests.ps1
index a99ce0e..8592035 100644
--- a/setup/tests/Certificates.Tests.ps1
+++ b/setup/tests/Certificates.Tests.ps1
@@ -74,7 +74,8 @@ try {
}
}
function script:Invoke-EppAz {
- param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments)
+ [CmdletBinding(PositionalBinding = $false)]
+ param([Parameter(Position = 0, ValueFromRemainingArguments)][string[]] $Arguments, [string] $Activity)
$command = $Arguments -join ' '
$script:Calls.Add($command)
switch -Regex ($command) {
@@ -166,7 +167,7 @@ try {
$script:Exists = $false
$script:CreatedCertificate = $certificate
$inputs = @{
- TenantId = '11111111-1111-1111-1111-111111111111'
+ TenantId = '11111111-1111-1111-1111-111111111111'; TenantRegionScope = 'NA'
SubscriptionId = '33333333-3333-3333-3333-333333333333'; ApplicationId = '22222222-2222-2222-2222-222222222222'
ServicePlan = 'FC1'; Language = 'javascript'; ProviderAuthentication = 'apiKey'
Location = 'westus2'; ResourcePrefix = 'unit'; PackageSha256 = ('a' * 64); SourcePackageSha256 = ('a' * 64)
@@ -515,6 +516,9 @@ try {
AssetDirectory = $Directory; WorkingDirectory = $Directory; Package = @{ RequiresRemoteBuild = $false }; OutputDirectory = $Directory
}
foreach ($action in @('Create', 'Reuse', 'Replace')) {
+ $inputs.Language = if ($action -eq 'Reuse') { 'dotnet' } else { 'javascript' }
+ $inputs.PackageFile = if ($inputs.Language -eq 'dotnet') { 'epp-dotnet-source.zip' } else { 'epp-javascript.zip' }
+ $inputs.PackageUrl = "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-1-1/$($inputs.PackageFile)"
Set-TestBundle $certificate
$script:Application.KeyCredentials = if ($action -eq 'Create') { @($signing) } else { @($first, (New-TestCredential $other $secondId), $signing) }
$context.CertificatePlan = if ($action -eq 'Reuse') { Get-EppCertificatePlan @planArgs @reuseArgs -NonInteractive }
@@ -527,8 +531,10 @@ try {
$script:ReferenceError = ''; $script:Calls.Clear()
$result = Invoke-EppDeployment @deployment
Assert ($result.certificateAction -ceq $action -and $result.certificateReferenceResolved -eq $true) 'The persisted result must identify the action and resolved reference.'
+ $expectedLanguage = if ($inputs.Language -eq 'dotnet') { 'csharp' } else { 'javascript' }
+ Assert ($result.language -ceq $expectedLanguage -and $result.tenantRegionScope -ceq 'NA') 'Persist the customer-facing language identifier and detected tenant scope.'
Assert ($result.sourceRevision -ceq ('a' * 40) -and $result.source -ceq $inputs.SourceBaseUri -and
- $result.packageFile -ceq 'epp-javascript.zip' -and $result.packageUrl -ceq $inputs.PackageUrl -and
+ $result.packageFile -ceq $inputs.PackageFile -and $result.packageUrl -ceq $inputs.PackageUrl -and
$result.packageReleaseTag -ceq $inputs.PackageReleaseTag) 'The persisted result must identify the setup source and downloaded Function release.'
Assert ($result.removedEncryptionKeyIds.Count -eq $context.CertificatePlan.RemoveKeyIds.Count) 'Record the exact retired credential IDs.'
$trace = $script:Calls.ToArray()
@@ -570,6 +576,7 @@ try {
param($Inputs, $AssetDirectory, $Provider, [switch] $NonInteractive)
return $Provider
}
+ function script:Get-EppTenantRegionScope { param($TenantId); return 'NA' }
function script:Get-EppLanguage {
param($AssetDirectory, $Language, $SourceRepository, $PackageReleaseTag, [switch] $NonInteractive)
return @{
diff --git a/setup/tests/Progress.Tests.ps1 b/setup/tests/Progress.Tests.ps1
new file mode 100644
index 0000000..839298c
--- /dev/null
+++ b/setup/tests/Progress.Tests.ps1
@@ -0,0 +1,129 @@
+#Requires -Version 7.0
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+$module = Import-Module (Join-Path $PSScriptRoot '..\support\Epp.Setup.psm1') -Force -PassThru
+$directory = Join-Path ([IO.Path]::GetTempPath()) "epp-progress-tests-$([Guid]::NewGuid().ToString('N'))"
+New-Item -ItemType Directory -Path $directory | Out-Null
+try {
+ $fakeAz = Join-Path $directory 'fake-az.ps1'
+ @'
+param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments)
+Microsoft.PowerShell.Utility\Start-Sleep -Milliseconds 450
+if ($Arguments[0] -eq 'throw') { throw 'simulated worker failure' }
+$pwsh = Join-Path $PSHOME $(if ($IsWindows) { 'pwsh.exe' } else { 'pwsh' })
+if ($Arguments[0] -in @('failure', 'warning')) {
+ $code = if ($Arguments[0] -eq 'failure') { 7 } else { 0 }
+ & $pwsh -NoProfile -NonInteractive -Command "[Console]::Error.WriteLine('simulated CLI message https://contoso.invalid/?sig=private-signature Bearer private-token'); exit $code"
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+}
+if ($Arguments[0] -ne 'empty') {
+ @{ arguments = @($Arguments) } | ConvertTo-Json -Compress
+}
+exit 0
+'@ | Set-Content -LiteralPath $fakeAz -Encoding utf8NoBOM
+ $fakeCommand = $fakeAz
+ if ($IsWindows) {
+ $fakeCommand = Join-Path $directory 'fake az.cmd'
+ "@echo off`r`n`"$PSHOME\pwsh.exe`" -NoProfile -NonInteractive -File `"%~dp0fake-az.ps1`" %*`r`nexit /b %errorlevel%" |
+ Set-Content -LiteralPath $fakeCommand -Encoding ascii
+ }
+
+ & $module {
+ param($FakeAz)
+
+ 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)"
+ Assert ($_.Exception.Message -notmatch 'private-signature|private-token') 'Do not disclose credentials in CLI errors.'
+ return
+ }
+ throw "Expected failure matching '$Pattern'."
+ }
+
+ $ProgressPreference = 'SilentlyContinue'
+ Assert (-not (Test-EppAnimationEnabled)) 'Honor disabled progress for unattended execution.'
+ $ProgressPreference = 'Continue'
+ if ([Console]::IsOutputRedirected) {
+ Assert (-not (Test-EppAnimationEnabled)) 'Do not animate redirected output.'
+ }
+ $script:FakeAz = $FakeAz
+ $script:Animate = $true
+ $script:Messages = [Collections.Generic.List[string]]::new()
+ $script:Warnings = [Collections.Generic.List[string]]::new()
+ $script:ErrorPaths = [Collections.Generic.List[string]]::new()
+ $script:Interrupt = $false
+ function script:Test-EppAnimationEnabled { return $script:Animate }
+ function script:Get-Command {
+ param($Name, $CommandType, $ErrorAction)
+ Assert ($Name -ceq 'az') 'Resolve only the Azure CLI command.'
+ return @{ Source = $script:FakeAz }
+ }
+ function script:az {
+ & $script:FakeAz @args
+ $global:LASTEXITCODE = $LASTEXITCODE
+ }
+ function script:Get-Content {
+ param($LiteralPath, [switch] $Raw)
+ $script:ErrorPaths.Add($LiteralPath)
+ Microsoft.PowerShell.Management\Get-Content -LiteralPath $LiteralPath -Raw:$Raw
+ }
+ function script:Write-Host {
+ param([Parameter(ValueFromPipeline)] $Object, $ForegroundColor, [switch] $NoNewline)
+ process { $script:Messages.Add([string]$Object) }
+ }
+ function script:Write-Warning { param($Message); $script:Warnings.Add($Message) }
+ function script:Start-Sleep {
+ param($Milliseconds)
+ if ($script:Interrupt) { throw 'simulated cancellation' }
+ Microsoft.PowerShell.Utility\Start-Sleep -Milliseconds $Milliseconds
+ }
+
+ foreach ($animate in @($true, $false)) {
+ $script:Animate = $animate
+ $script:Messages.Clear()
+ $output = Invoke-EppAz -Activity 'Deploying Azure resources' success --parameters '@path with spaces & symbols.json' --output json
+ $parsed = $output | ConvertFrom-Json
+ Assert (($parsed.arguments -join '|') -ceq 'success|--parameters|@path with spaces & symbols.json|--output|json|--only-show-errors') 'Preserve CLI argument boundaries and unmodified JSON output.'
+ $frames = @($script:Messages | Where-Object { $_ -match '^\r[|/\\-] Deploying Azure resources\.\.\.$' })
+ if ($animate) {
+ Assert ($frames.Count -ge 2 -and @($frames | Select-Object -Unique).Count -ge 2) 'Update the indicator while the command runs, not just once before it starts.'
+ Assert ($script:Messages[-1] -match '^\r +\r$') 'Clear the indicator on success.'
+ }
+ else {
+ Assert ($frames.Count -eq 0 -and $script:Messages.Count -eq 1 -and $script:Messages[0] -ceq 'Deploying Azure resources...') 'Use a single static status line for redirected or unattended output.'
+ }
+ Assert (($script:Messages -join "`n") -notmatch 'elapsed|remaining|\d+:\d+|%') 'Do not display elapsed time, a countdown, or invented percentages.'
+
+ $script:Warnings.Clear()
+ $output = Invoke-EppAz -Activity 'Deploying the Azure Function' warning
+ Assert (($output | ConvertFrom-Json).arguments[0] -ceq 'warning') 'Warnings must not contaminate stdout.'
+ Assert ($script:Warnings.Count -eq 1 -and $script:Warnings[0] -match '\[REDACTED\]' -and
+ $script:Warnings[0] -notmatch 'private-signature|private-token') 'Keep CLI warnings visible and redact sensitive values.'
+ Assert-Throws { Invoke-EppAz -Activity 'Deploying the Azure Function' failure --output none } 'failed \(exit 7\)'
+ if ($animate) { Assert ($script:Messages[-1] -match '^\r +\r$') 'Clear the indicator on a CLI failure.' }
+ Assert ([string]::IsNullOrEmpty((Invoke-EppAz -Activity 'Starting the Azure Function' empty))) 'Support successful commands with no stdout.'
+ }
+ $script:Animate = $true
+ $script:Messages.Clear()
+ $output = Invoke-EppAz success --output json
+ Assert ((($output | ConvertFrom-Json).arguments -join '|') -ceq 'success|--output|json|--only-show-errors') 'An omitted activity must not consume any native CLI argument.'
+ Assert ($script:Messages.Count -eq 0) 'Keep commands without an activity label quiet.'
+ Assert-Throws { Invoke-EppAz -Activity 'Deploying Azure resources' throw } 'simulated worker failure'
+ Assert ($script:Messages[-1] -match '^\r +\r$') 'Clear the indicator on a terminating worker error.'
+ $script:Interrupt = $true
+ Assert-Throws { Invoke-EppAz -Activity 'Deploying Azure resources' success } 'simulated cancellation'
+ Assert ($script:Messages[-1] -match '^\r +\r$') 'Stop the worker and clear the indicator when interrupted.'
+ foreach ($path in $script:ErrorPaths) {
+ Assert (-not (Test-Path -LiteralPath $path)) 'Remove captured CLI stderr files after success or failure.'
+ }
+ } $fakeCommand
+ Write-Host 'Deployment animation, argument preservation, stdout/stderr handling, redaction, failure, cancellation, and cleanup checks passed.'
+}
+finally {
+ Remove-Module -ModuleInfo $module -Force
+ Remove-Item -LiteralPath $directory -Recurse -Force
+}
diff --git a/setup/tests/ProviderConfiguration.Tests.ps1 b/setup/tests/ProviderConfiguration.Tests.ps1
index 86d00b4..c68f0db 100644
--- a/setup/tests/ProviderConfiguration.Tests.ps1
+++ b/setup/tests/ProviderConfiguration.Tests.ps1
@@ -104,14 +104,14 @@ try {
Reset-TestState @('')
Assert ($null -eq (Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory)) 'Empty provider input cancels.'
- foreach ($answer in @('Yes', 'No', '')) {
+ foreach ($answer in @('Yes', 'y', ' YES ', 'No', 'n', '')) {
Reset-TestState @($answer)
$script:Application.SignInAudience = 'AzureADMultipleOrgs'
$script:Restriction = New-TestRestriction @($telesign.TenantId)
$selected = Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory
- if ($answer -eq 'Yes') { Assert ($selected -ceq 'telesign') 'Confirm the single configured provider.' }
+ if ($answer.Trim() -in @('Yes', 'Y')) { Assert ($selected -ceq 'telesign') 'Confirm the single configured provider.' }
else { Assert ($null -eq $selected) 'Do not silently accept the single configured provider.' }
- Assert ($script:Prompts[0] -match 'configured for Telesign.*Yes or No') 'Show the configured provider in the confirmation.'
+ 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')
$script:Application.SignInAudience = 'AzureADMultipleOrgs'
diff --git a/setup/tests/Providers.Tests.ps1 b/setup/tests/Providers.Tests.ps1
index 54025a0..6c8541b 100644
--- a/setup/tests/Providers.Tests.ps1
+++ b/setup/tests/Providers.Tests.ps1
@@ -27,10 +27,16 @@ try {
$script:Messages = [Collections.Generic.List[string]]::new()
$script:Prompts = [Collections.Generic.List[string]]::new()
$script:Answers = [Collections.Generic.Queue[string]]::new()
+ $script:Warnings = [Collections.Generic.List[string]]::new()
+ $script:Highlights = [Collections.Generic.List[string]]::new()
function script:Write-Host {
param([Parameter(ValueFromPipeline)] $Object, $ForegroundColor, [switch] $NoNewline)
- process { $script:Messages.Add([string]$Object) }
+ process {
+ $script:Messages.Add([string]$Object)
+ if ($ForegroundColor -eq 'Yellow') { $script:Highlights.Add([string]$Object) }
+ }
}
+ function script:Write-Warning { param($Message); $script:Warnings.Add($Message) }
function script:Read-Host {
param($Prompt)
$script:Prompts.Add($Prompt)
@@ -71,6 +77,24 @@ try {
}
}
}
+ foreach ($noninteractive in @($false, $true)) {
+ $selection = Get-EppLanguage -AssetDirectory $SetupDirectory -SourceRepository 'unit/epp-fork' `
+ -PackageReleaseTag $script:PackageRelease.tag_name -NonInteractive:$noninteractive
+ Assert ($selection.Id -ceq 'javascript' -and $selection.DisplayName -ceq 'JavaScript') 'Default to JavaScript without asking in either mode.'
+ }
+ foreach ($name in @('csharp', 'CSharp', 'C#', 'dotnet', '.NET')) {
+ $selection = Get-EppLanguage -AssetDirectory $SetupDirectory -Language $name -SourceRepository 'unit/epp-fork' `
+ -PackageReleaseTag $script:PackageRelease.tag_name
+ Assert ($selection.Id -ceq 'dotnet' -and $selection.DisplayName -ceq 'C#') 'Expose C# while preserving the existing .NET package/runtime and legacy aliases.'
+ }
+ Assert-Throws {
+ Get-EppLanguage -AssetDirectory $SetupDirectory -Language unknown -SourceRepository 'unit/epp-fork'
+ } 'Use javascript, csharp, or python'
+ foreach ($emptyLanguage in @('', ' ')) {
+ Assert-Throws {
+ Get-EppLanguage -AssetDirectory $SetupDirectory -Language $emptyLanguage -SourceRepository 'unit/epp-fork'
+ } 'Language must not be empty'
+ }
foreach ($url in @(
'http://github.com/unit/epp-fork/releases/download/epp-packages-42-1/epp-javascript.zip',
'https://github.com/unrelated/repository/releases/download/epp-packages-42-1/epp-javascript.zip'
@@ -149,16 +173,24 @@ try {
function script:Get-EppLanguage {
param($AssetDirectory, $Language, $SourceRepository, $PackageReleaseTag, [switch] $NonInteractive)
+ $script:SelectedLanguage = $Language
return @{
- Id = 'javascript'; DisplayName = 'Node.js'; BuildStrategy = 'ready'
+ 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:Connect-EppContext {
param($Inputs, $Names, [switch] $NonInteractive, [switch] $InstallPrerequisites, [switch] $ForceAuthentication)
+ $script:PreflightInputs = @{} + $Inputs
throw 'preflight-reached'
}
+ $script:TenantScope = 'NA'
+ function script:Get-EppTenantRegionScope { param($TenantId); return $script:TenantScope }
+ function script:Select-EppLocation {
+ param($Inputs, $Location, [switch] $NonInteractive, [switch] $ForceAuthentication)
+ return Read-EppInput -Name Location -Value $Location -Kind Location -NonInteractive
+ }
$script:GraphConnections = 0
function script:Connect-EppGraphAccount {
param($Inputs, [switch] $NonInteractive, [switch] $ForceAuthentication)
@@ -181,24 +213,62 @@ try {
}
foreach ($id in @('telesign', 'soprano')) {
foreach ($region in @('global', 'eu')) {
+ $script:TenantScope = if ($region -eq 'eu') { 'EU' } else { 'NA' }
Assert-Throws {
Invoke-EppSetup @inputs -Provider $id -EndpointRegion $region -OutputDirectory $Directory -NonInteractive
} '^preflight-reached$'
}
}
Assert ($script:Prompts.Count -eq 0) 'Unattended setup must reach preflight without any channel input.'
+ $script:TenantScope = 'NA'
Assert-Throws {
Invoke-EppSetup @inputs -EndpointRegion global -OutputDirectory $Directory -NonInteractive
} '\-Provider is required'
Assert ($script:GraphConnections -eq 0) 'Reject a missing unattended provider before contacting Graph.'
- $script:Answers.Enqueue('2')
$script:Answers.Enqueue('soprano')
Assert-Throws {
Invoke-EppSetup @inputs -OutputDirectory $Directory
} '^preflight-reached$'
- Assert (($script:Prompts -join ',') -ceq 'Tenant scope number or name,Provider name (Enter cancels)') 'Provider selection must use a name rather than a numbered ranking.'
+ Assert (($script:Prompts -join ',') -ceq 'Provider name (Enter cancels)') '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
+ $defaults.Remove('ResourcePrefix')
+ $defaults.Remove('Language')
+ $script:Answers.Enqueue('')
+ Assert-Throws { Invoke-EppSetup @defaults -Provider telesign -OutputDirectory $Directory } '^preflight-reached$'
+ Assert ($script:PreflightInputs.ResourcePrefix -ceq 'epp' -and $script:SelectedLanguage -ceq 'javascript') 'The setup entry point must use the agreed prefix and language defaults.'
+ Assert ($script:Prompts[-1] -ceq 'Resource name prefix [epp]') 'Show the prefix default with a customer-facing label.'
+ foreach ($prefix in @('epp', 'contoso')) {
+ Assert ((Read-EppInput -Name ResourcePrefix -Value $prefix -Kind Prefix -DefaultValue epp -NonInteractive) -ceq $prefix) 'Preserve explicitly supplied prefixes.'
+ }
+ Assert-Throws { Read-EppInput -Name ResourcePrefix -Kind Prefix -DefaultValue epp -NonInteractive } 'ResourcePrefix is required'
+ Assert-Throws { Read-EppInput -Name ResourcePrefix -Value 'Bad-Prefix' -Kind Prefix -DefaultValue epp } 'Invalid -ResourcePrefix'
+ $script:Answers.Enqueue('bad-prefix'); $script:Answers.Enqueue('myapp')
+ Assert ((Read-EppInput -Name ResourcePrefix -Kind Prefix -DefaultValue epp) -ceq 'myapp') 'Reprompt invalid interactive prefixes instead of silently using the default.'
+ foreach ($answer in @('Yes', 'yes', 'Y', ' y ')) {
+ $script:Answers.Enqueue($answer)
+ Assert (Confirm-EppDeployment) 'Accept the documented case-insensitive approval responses.'
+ }
+ foreach ($answer in @('No', 'no', 'N', ' n ', '', ' ')) {
+ $script:Answers.Enqueue($answer)
+ Assert (-not (Confirm-EppDeployment)) 'Accept cancellation aliases and keep Enter as cancel.'
+ }
+ $script:Warnings.Clear()
+ $script:Answers.Enqueue('continue'); $script:Answers.Enqueue('n')
+ Assert (-not (Confirm-EppDeployment) -and $script:Warnings.Count -eq 1) 'Invalid confirmation responses must reprompt.'
+ Assert-Throws { Confirm-EppDeployment -NonInteractive } 'requires -ApproveDeployment'
+ Assert (Confirm-EppDeployment -NonInteractive -ApproveDeployment) 'Keep explicit unattended approval.'
+ foreach ($answer in @('Yes', 'y', ' YES ')) {
+ $script:Answers.Enqueue($answer)
+ Confirm-EppPrerequisiteInstall -Description Bicep
+ }
+ foreach ($answer in @('No', 'n', '')) {
+ $script:Answers.Enqueue($answer)
+ Assert-Throws { Confirm-EppPrerequisiteInstall -Description Bicep } 'Bicep is required'
+ }
+ Assert ($script:Answers.Count -eq 0) 'Consume every expected input without adding language or save-output prompts.'
+
$names = Get-EppResourceNames -SubscriptionId $inputs.SubscriptionId -ApplicationId $inputs.ApplicationId -ResourcePrefix $inputs.ResourcePrefix
$configuration = Get-EppProvider -AssetDirectory $Directory -Provider telesign -EndpointRegion global -NonInteractive
$script:Messages.Clear()
@@ -210,6 +280,8 @@ try {
}
Assert ($result.Contains('each channel you intend to enable')) 'Policy activation must remain an explicit per-channel choice.'
Assert ($result.Contains('did not change the External Phone Provider policy')) 'Setup must not claim policy activation.'
+ Assert ($script:Highlights -contains 'IMPORTANT: Save this output. The endpoint URL and Application (client) ID are required for policy updates.') 'Highlight the save-output reminder in yellow.'
+ Assert ($result.Contains('docs/GETTING-STARTED.md#4-connect-your-provider-and-test')) 'Link the existing provider, policy, and testing guidance from the completion output.'
} $setupDirectory $directory
Write-Host 'Provider profiles, channel-independent setup, prompts, and policy handoff checks passed.'
}
diff --git a/setup/tests/Selections.Tests.ps1 b/setup/tests/Selections.Tests.ps1
index b2d0c88..6ed8ae6 100644
--- a/setup/tests/Selections.Tests.ps1
+++ b/setup/tests/Selections.Tests.ps1
@@ -33,10 +33,13 @@ try {
SignInAudience = 'AzureADMyOrg'; AdditionalProperties = @{}
}
}
- function script:New-TestRegion([string] $Id, [string] $Category = 'Recommended', [string] $Type = 'Physical') {
+ function script:New-TestRegion(
+ [string] $Id, [string] $Category = 'Recommended', [string] $Type = 'Physical',
+ [string] $Geography = 'United States', [string] $GeographyGroup = 'US'
+ ) {
return @{
name = $Id; displayName = $Id.ToUpperInvariant()
- metadata = @{ regionCategory = $Category; regionType = $Type }
+ metadata = @{ regionCategory = $Category; regionType = $Type; geography = $Geography; geographyGroup = $GeographyGroup }
}
}
$script:Calls = [Collections.Generic.List[string]]::new()
@@ -46,9 +49,16 @@ try {
$script:Answers = [Collections.Generic.Queue[string]]::new()
$script:AzureForces = [Collections.Generic.List[bool]]::new()
$script:GraphForces = [Collections.Generic.List[bool]]::new()
+ $script:DiscoveryCalls = [Collections.Generic.List[string]]::new()
function script:Reset-TestState([string[]] $Answers = @()) {
$script:Calls.Clear(); $script:Messages.Clear(); $script:Warnings.Clear(); $script:Prompts.Clear(); $script:Answers.Clear()
$script:AzureForces.Clear(); $script:GraphForces.Clear()
+ $script:DiscoveryCalls.Clear()
+ $script:DiscoveryMetadata = [pscustomobject]@{
+ issuer = "https://login.microsoftonline.com/$script:Tenant/v2.0"
+ tenant_region_scope = 'NA'
+ }
+ $script:DiscoveryError = ''
foreach ($answer in $Answers) { $script:Answers.Enqueue($answer) }
$script:Graph = [pscustomobject]@{
TenantId = $script:Tenant; Authentication = 'AzureCli'
@@ -118,7 +128,52 @@ try {
return $script:RegionsJson
}
function script:Invoke-WebRequest { throw 'Unexpected download.' }
- function script:Invoke-RestMethod { throw 'Unexpected REST call.' }
+ function script:Invoke-RestMethod {
+ param($Uri, $TimeoutSec, $MaximumRedirection)
+ Assert ($Uri -ceq "https://login.microsoftonline.com/$script:Tenant/v2.0/.well-known/openid-configuration") 'Read only the selected tenant discovery document.'
+ Assert ($TimeoutSec -eq 60 -and $MaximumRedirection -eq 0) 'Bound discovery and do not follow redirects.'
+ $script:DiscoveryCalls.Add($Uri)
+ if ($script:DiscoveryError) { throw $script:DiscoveryError }
+ return ,$script:DiscoveryMetadata
+ }
+
+ foreach ($scope in @('EU', 'NA', 'AS', 'OC', 'AF', 'SA', 'AN', 'JPR', 'WW')) {
+ Reset-TestState
+ $script:DiscoveryMetadata.tenant_region_scope = $scope.ToLowerInvariant()
+ Assert ((Get-EppTenantRegionScope -TenantId $script:Tenant) -ceq $scope) 'Read and normalize the actual tenant scope without a country-to-scope mapping.'
+ $expectedRoute = if ($scope -eq 'EU') { 'eu' } else { 'global' }
+ Assert ((Resolve-EppEndpointRegion -TenantRegionScope $scope) -ceq $expectedRoute) 'Only EU tenants select the EU provider route.'
+ Assert ((Resolve-EppEndpointRegion -TenantRegionScope $scope -EndpointRegion $expectedRoute.ToUpperInvariant()) -ceq $expectedRoute) 'Accept matching explicit routes case-insensitively.'
+ $wrongRoute = if ($scope -eq 'EU') { 'global' } else { 'eu' }
+ Assert-Throws { Resolve-EppEndpointRegion -TenantRegionScope $scope -EndpointRegion $wrongRoute } 'incompatible with tenant replication scope'
+ Assert ($script:Prompts.Count -eq 0) 'Tenant scope and provider routing must not prompt.'
+ }
+ Reset-TestState
+ Assert-Throws { Get-EppTenantRegionScope -TenantId invalid } 'nonempty GUID'
+ Assert ($script:DiscoveryCalls.Count -eq 0) 'Validate tenant IDs before discovery.'
+ Assert-Throws { Resolve-EppEndpointRegion -TenantRegionScope EU -EndpointRegion invalid } 'Unknown EndpointRegion'
+ Assert-Throws { Resolve-EppEndpointRegion -TenantRegionScope '' } 'must be determined'
+ foreach ($invalidScope in @('', ' ', "EU`n", @('EU', 'NA'), 1)) {
+ Reset-TestState
+ $script:DiscoveryMetadata.tenant_region_scope = $invalidScope
+ Assert-Throws { Get-EppTenantRegionScope -TenantId $script:Tenant } 'valid tenant replication scope'
+ }
+ foreach ($metadata in @($null, @(), @([pscustomobject]@{ tenant_region_scope = 'EU' }))) {
+ Reset-TestState
+ $script:DiscoveryMetadata = $metadata
+ Assert-Throws { Get-EppTenantRegionScope -TenantId $script:Tenant } 'invalid tenant discovery metadata'
+ }
+ Reset-TestState
+ $script:DiscoveryMetadata = [pscustomobject]@{ issuer = "https://login.microsoftonline.com/$script:Tenant/v2.0" }
+ Assert-Throws { Get-EppTenantRegionScope -TenantId $script:Tenant } 'valid tenant replication scope'
+ foreach ($issuer in @("https://login.microsoftonline.com/$script:Subscription/v2.0", "https://login.microsoftonline.us/$script:Tenant/v2.0", '')) {
+ Reset-TestState
+ $script:DiscoveryMetadata.issuer = $issuer
+ Assert-Throws { Get-EppTenantRegionScope -TenantId $script:Tenant } 'selected tenant in the Azure public cloud'
+ }
+ Reset-TestState
+ $script:DiscoveryError = 'tenant discovery unavailable'
+ Assert-Throws { Get-EppTenantRegionScope -TenantId $script:Tenant } 'tenant discovery unavailable'
foreach ($noninteractive in @($false, $true)) {
Reset-TestState
@@ -195,6 +250,62 @@ try {
Assert (-not ($script:Messages -join "`n").Contains('GLOBAL (global)')) 'Logical locations must not be offered.'
Reset-TestState @('all', 'shortlist', 'westus2')
Assert ((Select-EppLocation -Inputs (New-TestInputs)) -ceq 'westus2') 'Return from all regions to the recommended shortlist.'
+ $euRegions = @(
+ (New-TestRegion westeurope -Geography Europe -GeographyGroup Europe),
+ (New-TestRegion northeurope -Geography Europe -GeographyGroup Europe),
+ (New-TestRegion francecentral -Geography France -GeographyGroup Europe),
+ (New-TestRegion norwayeast -Geography Norway -GeographyGroup Europe),
+ (New-TestRegion switzerlandnorth -Geography Switzerland -GeographyGroup Europe),
+ (New-TestRegion belgiumcentral -Geography Belgium -GeographyGroup Europe),
+ (New-TestRegion uksouth -Geography 'United Kingdom' -GeographyGroup Europe),
+ (New-TestRegion eastus),
+ (New-TestRegion unclassified -Geography '' -GeographyGroup Europe),
+ (New-TestRegion franceother Other -Geography France -GeographyGroup Europe)
+ )
+ Reset-TestState @('westeurope')
+ $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.'
+ $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.'
+ }
+ foreach ($region in @('UKSOUTH', 'EASTUS', 'UNCLASSIFIED', 'FRANCEOTHER')) {
+ Assert (-not $text.Contains($region)) 'Never recommend non-EUDB, unverified, or non-Recommended locations to EU tenants.'
+ }
+ Assert ($text.Contains('Recommended Azure regions within the EU Data Boundary:') -and $script:Warnings.Count -eq 0) 'Label EU recommendations without warning for an in-boundary selection.'
+ foreach ($answers in @(@('all', 'uksouth'), @('manual', 'eastus'))) {
+ Reset-TestState $answers
+ $script:RegionsJson = @{ value = $euRegions } | ConvertTo-Json -Depth 8
+ $selected = Select-EppLocation -Inputs $inputs
+ Assert ($selected -ceq $answers[-1] -and $script:Warnings.Count -eq 1) 'Allow explicit out-of-boundary selections with one warning and no extra approval.'
+ Assert ($script:Warnings[0].Contains('outside the EU Data Boundary')) 'Explain the boundary mismatch.'
+ }
+ foreach ($location in @('westeurope', 'eastus', 'unclassified', 'customregion5')) {
+ Reset-TestState
+ $script:RegionsJson = @{ value = $euRegions } | ConvertTo-Json -Depth 8
+ Assert ((Select-EppLocation -Inputs $inputs -Location $location -NonInteractive) -ceq $location) 'Preserve explicit EU-tenant locations, including unattended overrides.'
+ Assert ($script:Prompts.Count -eq 0) 'An explicit location must never prompt.'
+ if ($location -eq 'westeurope') { Assert ($script:Warnings.Count -eq 0) 'An in-boundary parameter needs no warning.' }
+ else {
+ Assert ($script:Warnings.Count -eq 1) 'An out-of-boundary or unverifiable parameter must warn.'
+ if ($location -in @('unclassified', 'customregion5')) {
+ Assert ($script:Warnings[0].Contains('could not be verified')) 'Do not claim unknown geography is known to be outside the boundary.'
+ }
+ }
+ }
+ 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.'
+ foreach ($scope in @('NA', 'AS', 'OC', 'AF', 'SA', 'AN', 'JPR', 'WW')) {
+ Reset-TestState @('westus2')
+ $inputs = New-TestInputs; $inputs.TenantRegionScope = $scope
+ Assert ((Select-EppLocation -Inputs $inputs) -ceq 'westus2') 'Keep non-EU recommendations unchanged.'
+ Assert ($script:Warnings.Count -eq 0 -and -not ($script:Messages -join "`n").Contains('EU Data Boundary')) 'Do not add geographic warnings or filtering for non-EU tenants.'
+ Reset-TestState
+ Assert ((Select-EppLocation -Inputs $inputs -Location westus2 -NonInteractive) -ceq 'westus2' -and $script:Calls.Count -eq 0) 'Non-EU explicit locations must still bypass region discovery.'
+ }
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', 'West US 2', 'westus2')
@@ -218,7 +329,7 @@ try {
Reset-TestState
$script:RegionsJson = @{ value = @((New-TestRegion westus2), (New-TestRegion westus2)) } | ConvertTo-Json -Depth 8
Assert-Throws { Get-EppRegionOptions -Inputs (New-TestInputs) } 'invalid or duplicate region'
- foreach ($field in @('regionType', 'regionCategory')) {
+ foreach ($field in @('regionType', 'regionCategory', 'geography')) {
Reset-TestState
$invalid = New-TestRegion westus2
$invalid.metadata[$field] = @{}
@@ -251,7 +362,7 @@ try {
function script:Get-EppLanguage {
param($AssetDirectory, $Language, $SourceRepository, $PackageReleaseTag, [switch] $NonInteractive)
return @{
- Id = 'javascript'; DisplayName = 'Node.js'; BuildStrategy = 'ready'
+ 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'
}
@@ -272,6 +383,7 @@ try {
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: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.'
Reset-TestState @('0')
Assert ($null -eq (Invoke-EppSetup @setup)) 'Application cancellation must return no success object.'
@@ -282,6 +394,11 @@ try {
Assert-Throws { Invoke-EppSetup @setup -NonInteractive -ApproveDeployment } 'ApplicationId is required'
Assert-Throws { Invoke-EppSetup @setup -ApplicationId $script:AppId -NonInteractive -ApproveDeployment } 'Location is required'
Assert ($script:Calls.Count -eq 0) 'Unattended missing inputs must not trigger discovery or authentication.'
+ Assert ($script:DiscoveryCalls.Count -eq 0) 'Reject missing unattended IDs/locations before tenant discovery.'
+ Reset-TestState
+ $script:DiscoveryMetadata.tenant_region_scope = 'EU'
+ Assert-Throws { Invoke-EppSetup @setup -ApplicationId $script:AppId -Location westeurope -NonInteractive } 'incompatible with tenant replication scope'
+ Assert ($script:Calls.Count -eq 0 -and $null -eq $script:PreflightInputs) 'Reject a conflicting provider route before Azure lookup or preflight.'
Assert (-not (Test-Path -LiteralPath $Directory)) 'Selection must not produce deployment output.'
} $directory $ModulePath
Write-Host 'Application/client-ID selection, scoped Graph search, recommended/manual regions, cancellation, authentication reuse, and unattended checks passed.'
diff --git a/setup/tests/ServicePlans.Tests.ps1 b/setup/tests/ServicePlans.Tests.ps1
index fa9bb85..66653f1 100644
--- a/setup/tests/ServicePlans.Tests.ps1
+++ b/setup/tests/ServicePlans.Tests.ps1
@@ -23,6 +23,7 @@ try {
}
$script:Messages = [Collections.Generic.List[string]]::new()
$script:Calls = [Collections.Generic.List[string]]::new()
+ $script:Activities = [Collections.Generic.List[string]]::new()
$script:Answers = [Collections.Generic.Queue[string]]::new()
$script:Regions = '[{"name":"West US 2"}]'
$script:ExistingPlans = '[]'
@@ -37,9 +38,11 @@ try {
}
function script:Read-Host { param($Prompt); return $script:Answers.Dequeue() }
function script:Invoke-EppAz {
- param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments)
+ [CmdletBinding(PositionalBinding = $false)]
+ param([Parameter(Position = 0, ValueFromRemainingArguments)][string[]] $Arguments, [string] $Activity)
$command = $Arguments -join ' '
$script:Calls.Add($command)
+ if ($Activity) { $script:Activities.Add($Activity) }
switch -Regex ($command) {
'^version ' { return @{ 'azure-cli' = $script:CliVersion } | ConvertTo-Json }
'^bicep version ' { return '' }
@@ -78,6 +81,7 @@ try {
$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.'
+ Assert (($script:Messages -join "`n").Contains('https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/blob/main/docs/COST-CALCULATOR.md')) 'Link the repository cost estimator at plan selection.'
Assert ($script:Calls.Count -eq 0) 'Selecting a plan must not contact Azure.'
$inputs = @{
@@ -121,6 +125,8 @@ try {
Show-EppPlan -Inputs $inputs -Names $names -ProviderConfiguration $provider -Context $context
$plan = $script:Messages -join "`n"
Assert ($plan.Contains("Service plan: $id")) 'The approval must name the selected plan.'
+ Assert ($plan.Contains('Language:') -and -not $plan.Contains('Platform:')) 'Use customer-facing language terminology.'
+ Assert (-not $plan.Contains('Python')) 'Do not describe Python builds for JavaScript deployments.'
Assert ($plan.Contains($inputs.SourceBaseUri) -and $plan.Contains($inputs.PackageUrl) -and
$plan.Contains("Release: $($inputs.PackageReleaseTag)")) 'The approval must identify the setup source and exact Function release URL.'
Assert ($plan.Contains('SMS and voice (selected by each request)')) 'The approval must disclose support for both request channels.'
@@ -129,6 +135,11 @@ try {
foreach ($setting in @('EPP_KEY_VAULT_CACHE_ENABLED', 'EPP_ACCESS_TOKEN_CACHE_ENABLED')) {
Assert (-not $plan.Contains($setting)) 'The approval must not advertise obsolete cache settings.'
}
+ $inputs.BuildStrategy = 'remote-build'
+ $script:Messages.Clear()
+ Show-EppPlan -Inputs $inputs -Names $names -ProviderConfiguration $provider -Context $context
+ Assert (($script:Messages -join "`n").Contains('Azure installs Python dependencies and prepares the Function deployment package. No local Python installation is required.')) 'Explain Python packaging only for the Python build strategy.'
+ $inputs.BuildStrategy = 'ready'
$script:Calls.Clear()
Assert-EppServicePlanLocation -Inputs $inputs
Assert ($script:Calls.Count -eq 1) 'Availability must check only the selected plan.'
@@ -211,11 +222,12 @@ try {
foreach ($language in @('javascript', 'dotnet', 'python')) {
$inputs.ServicePlan = $id; $inputs.Language = $language
$publish.Package.RequiresRemoteBuild = $language -eq 'python'
- $script:Calls.Clear()
+ $script:Calls.Clear(); $script:Activities.Clear()
$hash = Publish-EppFunction @publish
$trace = $script:Calls -join "`n"
Assert ($script:Calls[0] -ceq 'authentication') 'Authentication must be verified before any publication or ingress change.'
Assert ($script:Calls[-1] -ceq 'published') 'Publication must finish with Function metadata readback.'
+ Assert ($script:Activities.Count -gt 0) 'Every plan/language publication path must provide an activity indicator.'
if ($id -eq 'FC1') {
$remoteBuild = ($language -eq 'python').ToString().ToLowerInvariant()
Assert ($script:Calls[1] -ceq 'ingress:Enabled') 'Flex must open its authenticated deployment endpoint before One Deploy.'
@@ -257,10 +269,12 @@ try {
function script:Initialize-EppResourceProviders { param($Inputs) }
foreach ($id in @('FC1', 'EP1')) {
$inputs.ServicePlan = $id
+ $script:Activities.Clear()
Assert-Throws {
Invoke-EppDeployment -Inputs $inputs -Names $names -ProviderConfiguration $provider -Context $context `
-AssetDirectory $Directory -WorkingDirectory $Directory -Package $publish.Package -OutputDirectory $Directory
} 'stop-after-parameters'
+ Assert ($script:Activities -contains 'Deploying Azure resources') 'ARM deployment must use the shared activity indicator.'
Assert ($script:DeploymentParameters.servicePlan.value -ceq $id) 'The approved plan must be passed to Bicep.'
foreach ($setting in @('EPP_PROVIDER_SMS_ENDPOINT', 'EPP_PROVIDER_VOICE_ENDPOINT')) {
Assert ($script:DeploymentParameters.providerSettings.value[$setting] -ceq $provider.Settings[$setting]) 'Both approved endpoints must be passed to Bicep.'