Skip to content

Commit b2406ae

Browse files
Handle async and non-200 REST responses in functional test helpers (#2209)
* Fix functional test helpers for async REST responses * Fix Invoke-WebRequest compatibility in functional REST helper * tweaked Get-FunctionalTestHeaderValue since it was not working locally * changed Write-Host to Write-Information to satisfy PS linter --------- Co-authored-by: Ted Kolovos <107076927+tkol2022@users.noreply.github.com>
1 parent eb7923b commit b2406ae

2 files changed

Lines changed: 261 additions & 19 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
Describe 'Invoke-FunctionalTestRestRequest' {
2+
BeforeAll {
3+
$FunctionalTestUtilsPath = Join-Path -Path $PSScriptRoot -ChildPath '../../../../../../Testing/Functional/Products/FunctionalTestUtils.ps1'
4+
. $FunctionalTestUtilsPath
5+
6+
$script:PPBaseUrl = 'https://api.contoso.test'
7+
$script:PPAccessToken = 'pp-token'
8+
$script:PBIBaseUrl = 'https://fabric.contoso.test'
9+
$script:PBIAccessToken = 'pbi-token'
10+
}
11+
12+
BeforeEach {
13+
$script:InvokeWebRequestCalls = @()
14+
$script:SleepCalls = @()
15+
16+
Mock Write-Warning { }
17+
Mock Start-Sleep {
18+
param([int] $Seconds)
19+
$script:SleepCalls += $Seconds
20+
}
21+
}
22+
23+
It 'polls a location URL after a 202 response until the request completes' {
24+
Mock Invoke-WebRequest {
25+
param(
26+
[string] $Uri,
27+
[string] $Method,
28+
[hashtable] $Headers,
29+
[string] $Body,
30+
[string] $ContentType,
31+
[string] $ErrorAction
32+
)
33+
34+
$script:InvokeWebRequestCalls += [PSCustomObject]@{
35+
Uri = $Uri
36+
Method = $Method
37+
Headers = $Headers
38+
Body = $Body
39+
ContentType = $ContentType
40+
ErrorAction = $ErrorAction
41+
}
42+
43+
if ($script:InvokeWebRequestCalls.Count -eq 1) {
44+
return [PSCustomObject]@{
45+
StatusCode = 202
46+
Headers = @{
47+
Location = '/operations/tenant-isolation/123'
48+
'Retry-After' = '7'
49+
}
50+
Content = ''
51+
}
52+
}
53+
54+
return [PSCustomObject]@{
55+
StatusCode = 200
56+
Headers = @{}
57+
Content = '{"value":{"isDisabled":true}}'
58+
}
59+
}
60+
61+
$result = Invoke-FunctionalTestRestRequest -Uri 'https://api.contoso.test/providers/example' -Method 'PUT' -Headers @{ Authorization = 'Bearer token' } -Body '{"enabled":true}' -ContentType 'application/json'
62+
63+
$result.value.isDisabled | Should -BeTrue
64+
$script:SleepCalls | Should -Be @(7)
65+
$script:InvokeWebRequestCalls.Count | Should -Be 2
66+
$script:InvokeWebRequestCalls[0].Method | Should -Be 'PUT'
67+
$script:InvokeWebRequestCalls[1].Method | Should -Be 'GET'
68+
$script:InvokeWebRequestCalls[1].Uri | Should -Be 'https://api.contoso.test/operations/tenant-isolation/123'
69+
}
70+
71+
It 'retries and throws when a Power BI update returns a non-success status code' {
72+
Mock Invoke-WebRequest {
73+
$exception = [System.Exception]::new('Forbidden')
74+
$exception | Add-Member -NotePropertyName Response -NotePropertyValue ([PSCustomObject]@{ StatusCode = 403 })
75+
throw $exception
76+
}
77+
78+
{ Set-PowerBITenantSetting -SettingName 'ExportData' -Enabled $true } | Should -Throw '*403*'
79+
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
80+
$script:SleepCalls | Should -Be @(5, 5)
81+
}
82+
}

Testing/Functional/Products/FunctionalTestUtils.ps1

Lines changed: 179 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,166 @@
11
$UtilityModulePath = Join-Path -Path $PSScriptRoot -ChildPath "../../../PowerShell/ScubaGear/Modules/Utility/Utility.psm1" -Resolve
22
Import-Module $UtilityModulePath -Function Get-Utf8NoBom, Set-Utf8NoBom
33

4+
function Get-FunctionalTestHeaderValue {
5+
param(
6+
[Parameter(Mandatory = $true)] $Headers,
7+
[Parameter(Mandatory = $true)] [string] $Name
8+
)
9+
10+
if ($null -eq $Headers) {
11+
return $null
12+
}
13+
14+
if (-not $Response.Headers.ContainsKey($Name)) {
15+
return $null
16+
}
17+
18+
$HeadersValue = $Response.Headers[$Name]
19+
$HeadersValueType = $Response.Headers[$Name].GetType()
20+
21+
if ($HeadersValueType -eq [string]) {
22+
# Write-Information "PS 5 Header '$Name' value: $HeadersValue" -InformationAction Continue
23+
return $HeadersValue
24+
} elseif ($HeadersValueType -eq [string[]]) {
25+
# Write-Information "PS 7 Header '$Name' values: $($HeadersValue[0])" -InformationAction Continue
26+
# Return the first item in array
27+
return $HeadersValue[0]
28+
} else {
29+
throw "Unexpected HTTP header value type: $HeadersValueType"
30+
}
31+
32+
# if ($Headers -is [System.Collections.IDictionary]) {
33+
# foreach ($Key in $Headers.Keys) {
34+
# if ([string]::Equals([string]$Key, $Name, [System.StringComparison]::OrdinalIgnoreCase)) {
35+
# return [string]$Headers[$Key]
36+
# }
37+
# }
38+
# return $null
39+
# }
40+
41+
# $Property = $Headers.PSObject.Properties | Where-Object {
42+
# [string]::Equals($_.Name, $Name, [System.StringComparison]::OrdinalIgnoreCase)
43+
# } | Select-Object -First 1
44+
45+
# if ($null -eq $Property) {
46+
# return $null
47+
# }
48+
49+
# return [string]$Property.Value
50+
}
51+
52+
function Resolve-FunctionalTestPollingUri {
53+
param(
54+
[Parameter(Mandatory = $true)] [string] $RequestUri,
55+
[Parameter(Mandatory = $true)] [string] $PollingUri
56+
)
57+
58+
return [System.Uri]::new([System.Uri]$RequestUri, $PollingUri).AbsoluteUri
59+
}
60+
61+
function Invoke-FunctionalTestRestRequest {
62+
[CmdletBinding()]
63+
param(
64+
[Parameter(Mandatory = $true)] [string] $Uri,
65+
[Parameter(Mandatory = $true)] [string] $Method,
66+
[Parameter(Mandatory = $true)] [hashtable] $Headers,
67+
[string] $Body,
68+
[string] $ContentType,
69+
[int] $DefaultRetryAfterSeconds = 5,
70+
[int] $MaxPollAttempts = 10
71+
)
72+
73+
$RequestUri = $Uri
74+
$RequestMethod = $Method
75+
$PollAttempts = 0
76+
77+
while ($true) {
78+
$RequestParams = @{
79+
Uri = $RequestUri
80+
Method = $RequestMethod
81+
Headers = $Headers
82+
ErrorAction = 'Stop'
83+
}
84+
85+
# PowerShell 5.1 can throw parser null-reference errors for Invoke-WebRequest
86+
# unless -UseBasicParsing is explicitly supplied.
87+
if ($null -ne (Get-Command Invoke-WebRequest).Parameters['UseBasicParsing']) {
88+
$RequestParams.UseBasicParsing = $true
89+
}
90+
91+
if (-not [string]::IsNullOrWhiteSpace($Body) -and $RequestMethod -ne 'GET') {
92+
$RequestParams.Body = $Body
93+
}
94+
95+
if (-not [string]::IsNullOrWhiteSpace($ContentType) -and $RequestMethod -ne 'GET') {
96+
$RequestParams.ContentType = $ContentType
97+
}
98+
99+
try {
100+
$Response = Invoke-WebRequest @RequestParams
101+
}
102+
catch {
103+
$StatusCode = $null
104+
if ($null -ne $_.Exception.Response -and $null -ne $_.Exception.Response.StatusCode) {
105+
$StatusCode = [int] $_.Exception.Response.StatusCode
106+
}
107+
108+
if ($null -ne $StatusCode) {
109+
throw "Request to $RequestUri failed with HTTP status code $StatusCode. $($_.Exception.Message)"
110+
}
111+
112+
throw
113+
}
114+
115+
$StatusCode = [int] $Response.StatusCode
116+
if ($StatusCode -eq 202) {
117+
$PollAttempts++
118+
if ($PollAttempts -gt $MaxPollAttempts) {
119+
throw "Request to $Uri did not complete after $MaxPollAttempts polling attempt(s)."
120+
}
121+
122+
$PollingUri = Get-FunctionalTestHeaderValue -Headers $Response.Headers -Name 'Location'
123+
if ([string]::IsNullOrWhiteSpace($PollingUri)) {
124+
$PollingUri = Get-FunctionalTestHeaderValue -Headers $Response.Headers -Name 'Operation-Location'
125+
}
126+
if ([string]::IsNullOrWhiteSpace($PollingUri)) {
127+
$PollingUri = Get-FunctionalTestHeaderValue -Headers $Response.Headers -Name 'Azure-AsyncOperation'
128+
}
129+
if ([string]::IsNullOrWhiteSpace($PollingUri)) {
130+
throw "Request to $RequestUri returned HTTP 202 without a polling location."
131+
}
132+
133+
$RetryAfter = Get-FunctionalTestHeaderValue -Headers $Response.Headers -Name 'Retry-After'
134+
$RetryAfterSeconds = 0
135+
if (-not [int]::TryParse([string] $RetryAfter, [ref] $RetryAfterSeconds) -or $RetryAfterSeconds -lt 1) {
136+
$RetryAfterSeconds = $DefaultRetryAfterSeconds
137+
}
138+
139+
Write-Information "Request to $RequestUri is still processing." -InformationAction Continue
140+
Write-Information "Polling $PollingUri in $RetryAfterSeconds seconds... (Attempt $PollAttempts of $MaxPollAttempts)" -InformationAction Continue
141+
Start-Sleep -Seconds $RetryAfterSeconds
142+
$RequestUri = Resolve-FunctionalTestPollingUri -RequestUri $RequestUri -PollingUri $PollingUri
143+
$RequestMethod = 'GET'
144+
continue
145+
}
146+
147+
if ($StatusCode -lt 200 -or $StatusCode -ge 300) {
148+
throw "Request to $RequestUri returned unexpected HTTP status code $StatusCode."
149+
}
150+
151+
if ([string]::IsNullOrWhiteSpace($Response.Content)) {
152+
return $null
153+
}
154+
155+
try {
156+
return $Response.Content | ConvertFrom-Json
157+
}
158+
catch {
159+
return $Response.Content
160+
}
161+
}
162+
}
163+
4164
# -----------------------------------------------------------------------
5165
# Exchange Online REST wrappers for functional test pre/postconditions.
6166
# These replace ExchangeOnlineManagement cmdlets in EXO test plans.
@@ -484,23 +644,23 @@ function Set-ExoOrganizationAuditDisabled {
484644
# Products.Tests.ps1 BeforeAll before these functions are called.
485645
# -----------------------------------------------------------------------
486646
function Get-TenantSettings {
487-
$Response = Invoke-RestMethod -Uri "$script:PPBaseUrl/providers/Microsoft.BusinessAppPlatform/listTenantSettings?api-version=2023-06-01" `
488-
-Method POST -Headers @{ Authorization = "Bearer $script:PPAccessToken" } -ContentType "application/json"
647+
$Response = Invoke-FunctionalTestRestRequest -Uri "$script:PPBaseUrl/providers/Microsoft.BusinessAppPlatform/listTenantSettings?api-version=2023-06-01" `
648+
-Method 'POST' -Headers @{ Authorization = "Bearer $script:PPAccessToken" } -ContentType 'application/json'
489649
return $Response
490650
}
491651

492652
function Set-TenantSettings {
493653
param([Parameter(Position=0)][object]$Settings, [hashtable]$RequestBody)
494654
if ($RequestBody) { $Body = $RequestBody | ConvertTo-Json -Depth 10 }
495655
else { $Body = $Settings | ConvertTo-Json -Depth 10 }
496-
Invoke-RestMethod -Uri "$script:PPBaseUrl/providers/Microsoft.BusinessAppPlatform/scopes/admin/updateTenantSettings?api-version=2023-06-01" `
497-
-Method POST -Headers @{ Authorization = "Bearer $script:PPAccessToken" } -Body $Body -ContentType "application/json" | Out-Null
656+
Invoke-FunctionalTestRestRequest -Uri "$script:PPBaseUrl/providers/Microsoft.BusinessAppPlatform/scopes/admin/updateTenantSettings?api-version=2023-06-01" `
657+
-Method 'POST' -Headers @{ Authorization = "Bearer $script:PPAccessToken" } -Body $Body -ContentType 'application/json' | Out-Null
498658
}
499659

500660
function Get-AdminPowerAppEnvironment {
501661
param([switch]$Default)
502-
$Response = Invoke-RestMethod -Uri "$script:PPBaseUrl/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments?api-version=2023-06-01" `
503-
-Method GET -Headers @{ Authorization = "Bearer $script:PPAccessToken" }
662+
$Response = Invoke-FunctionalTestRestRequest -Uri "$script:PPBaseUrl/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments?api-version=2023-06-01" `
663+
-Method 'GET' -Headers @{ Authorization = "Bearer $script:PPAccessToken" }
504664
if ($Default) {
505665
return $Response.value | Where-Object { $_.properties.isDefault -eq $true } | Select-Object -First 1 |
506666
Select-Object @{ Name="EnvironmentName"; Expression={ $_.name } }
@@ -509,8 +669,8 @@ function Get-AdminPowerAppEnvironment {
509669
}
510670

511671
function Get-DlpPolicy {
512-
$Response = Invoke-RestMethod -Uri "$script:PPBaseUrl/providers/Microsoft.BusinessAppPlatform/scopes/admin/apiPolicies?api-version=2016-11-01" `
513-
-Method GET -Headers @{ Authorization = "Bearer $script:PPAccessToken" }
672+
$Response = Invoke-FunctionalTestRestRequest -Uri "$script:PPBaseUrl/providers/Microsoft.BusinessAppPlatform/scopes/admin/apiPolicies?api-version=2016-11-01" `
673+
-Method 'GET' -Headers @{ Authorization = "Bearer $script:PPAccessToken" }
514674
# Normalize ARM response: hoist properties.displayName and id to root to
515675
# match original Get-DlpPolicy cmdlet output expected by test plan filters.
516676
# id is preserved as the full ARM resource path for use in Remove-DlpPolicy.
@@ -536,8 +696,8 @@ function Remove-DlpPolicy {
536696
} else {
537697
"/providers/Microsoft.BusinessAppPlatform/scopes/admin/apiPolicies/$PolicyName"
538698
}
539-
Invoke-RestMethod -Uri "$script:PPBaseUrl${Path}?api-version=2016-11-01" `
540-
-Method DELETE -Headers @{ Authorization = "Bearer $script:PPAccessToken" } | Out-Null
699+
Invoke-FunctionalTestRestRequest -Uri "$script:PPBaseUrl${Path}?api-version=2016-11-01" `
700+
-Method 'DELETE' -Headers @{ Authorization = "Bearer $script:PPAccessToken" } | Out-Null
541701
}
542702
}
543703

@@ -565,17 +725,17 @@ function New-AdminDlpPolicy {
565725
}
566726
}
567727
} | ConvertTo-Json -Depth 10
568-
Invoke-RestMethod -Uri "$script:PPBaseUrl/providers/Microsoft.BusinessAppPlatform/scopes/admin/apiPolicies?api-version=2016-11-01" `
569-
-Method POST -Headers @{ Authorization = "Bearer $script:PPAccessToken" } -Body $Body -ContentType "application/json" | Out-Null
728+
Invoke-FunctionalTestRestRequest -Uri "$script:PPBaseUrl/providers/Microsoft.BusinessAppPlatform/scopes/admin/apiPolicies?api-version=2016-11-01" `
729+
-Method 'POST' -Headers @{ Authorization = "Bearer $script:PPAccessToken" } -Body $Body -ContentType 'application/json' | Out-Null
570730
}
571731

572732
function Get-PowerAppTenantIsolationPolicy {
573733
param([string]$TenantId)
574734
$MaxAttempts = 3
575735
for ($Attempt = 1; $Attempt -le $MaxAttempts; $Attempt++) {
576736
try {
577-
return Invoke-RestMethod -Uri "$script:PPBaseUrl/providers/PowerPlatform.Governance/v1/tenants/$TenantId/tenantIsolationPolicy?api-version=2020-06-01" `
578-
-Method GET -Headers @{ Authorization = "Bearer $script:PPAccessToken" }
737+
return Invoke-FunctionalTestRestRequest -Uri "$script:PPBaseUrl/providers/PowerPlatform.Governance/v1/tenants/$TenantId/tenantIsolationPolicy?api-version=2020-06-01" `
738+
-Method 'GET' -Headers @{ Authorization = "Bearer $script:PPAccessToken" }
579739
}
580740
catch {
581741
if ($Attempt -ge $MaxAttempts) { throw }
@@ -591,8 +751,8 @@ function Set-PowerAppTenantIsolationPolicy {
591751
$MaxAttempts = 3
592752
for ($Attempt = 1; $Attempt -le $MaxAttempts; $Attempt++) {
593753
try {
594-
Invoke-RestMethod -Uri "$script:PPBaseUrl/providers/PowerPlatform.Governance/v1/tenants/$TenantId/tenantIsolationPolicy?api-version=2020-06-01" `
595-
-Method PUT -Headers @{ Authorization = "Bearer $script:PPAccessToken" } -Body $Body -ContentType "application/json" | Out-Null
754+
Invoke-FunctionalTestRestRequest -Uri "$script:PPBaseUrl/providers/PowerPlatform.Governance/v1/tenants/$TenantId/tenantIsolationPolicy?api-version=2020-06-01" `
755+
-Method 'PUT' -Headers @{ Authorization = "Bearer $script:PPAccessToken" } -Body $Body -ContentType 'application/json' | Out-Null
596756
return
597757
}
598758
catch {
@@ -619,9 +779,9 @@ function Set-PowerBITenantSetting {
619779
$MaxAttempts = 3
620780
for ($Attempt = 1; $Attempt -le $MaxAttempts; $Attempt++) {
621781
try {
622-
Invoke-RestMethod -Uri "$script:PBIBaseUrl/v1/admin/tenantsettings/$SettingName/update" `
623-
-Method POST -Headers @{ Authorization = "Bearer $script:PBIAccessToken" } `
624-
-Body $Body -ContentType "application/json" | Out-Null
782+
Invoke-FunctionalTestRestRequest -Uri "$script:PBIBaseUrl/v1/admin/tenantsettings/$SettingName/update" `
783+
-Method 'POST' -Headers @{ Authorization = "Bearer $script:PBIAccessToken" } `
784+
-Body $Body -ContentType 'application/json' | Out-Null
625785
return
626786
}
627787
catch {

0 commit comments

Comments
 (0)