Tests/Unit/Private/Invoke-SIARequest.Tests.ps1
|
BeforeAll { $moduleRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path Import-Module (Join-Path $moduleRoot 'psSIA.psd1') -Force } Describe 'Invoke-SIARequest' { BeforeEach { InModuleScope psSIA { $script:SIASession = [pscustomobject]@{ Subdomain = 'contoso' AccessToken = (ConvertTo-SecureString 'test-token' -AsPlainText -Force) ExpiresAt = (Get-Date).ToUniversalTime().AddMinutes(10) } $script:SIAModuleConfig.RetryCount = 3 $script:SIAModuleConfig.RetryDelay = 0 } } # New-TransientFailure is redefined inside every It block below that needs it: functions # defined in one InModuleScope invocation (like this BeforeEach) are not visible from a # later, separate InModuleScope invocation (like the It blocks). It 'sends the session token as a bearer header' { InModuleScope psSIA { Mock Invoke-RestMethod { param($Uri, $Method, $Headers) $Headers.Authorization | Should -Be 'Bearer test-token' [pscustomobject]@{ ok = $true } } $result = Invoke-SIARequest -Method GET -Path '/api/connectors' $result.ok | Should -Be $true Should -Invoke Invoke-RestMethod -Times 1 -Exactly } } It 'resolves the URI against the session subdomain' { InModuleScope psSIA { Mock Invoke-RestMethod { param($Uri) $Uri.Host | Should -Be 'contoso.dpa.cyberark.cloud' [pscustomobject]@{} } Invoke-SIARequest -Method GET -Path '/api/connectors' | Out-Null } } It 'serializes and attaches a JSON body when one is given' { InModuleScope psSIA { Mock Invoke-RestMethod { param($Body, $ContentType) $ContentType | Should -Be 'application/json' ($Body | ConvertFrom-Json).name | Should -Be 'contoso' [pscustomobject]@{} } Invoke-SIARequest -Method POST -Path '/api/connectors' -Body @{ name = 'contoso' } | Out-Null } } It 'retries a transient failure on GET and succeeds' { InModuleScope psSIA { function New-TransientFailure { param([System.Net.HttpStatusCode]$StatusCode) $response = [System.Net.Http.HttpResponseMessage]::new($StatusCode) $exception = [System.Exception]::new('Request failed') $exception | Add-Member -NotePropertyName Response -NotePropertyValue $response $exception } $script:attempt = 0 Mock Invoke-RestMethod { $script:attempt++ if ($script:attempt -eq 1) { throw (New-TransientFailure -StatusCode TooManyRequests) } [pscustomobject]@{ ok = $true } } $result = Invoke-SIARequest -Method GET -Path '/api/connectors' $result.ok | Should -Be $true Should -Invoke Invoke-RestMethod -Times 2 -Exactly } } It 'does not retry a non-idempotent request' { InModuleScope psSIA { function New-TransientFailure { param([System.Net.HttpStatusCode]$StatusCode) $response = [System.Net.Http.HttpResponseMessage]::new($StatusCode) $exception = [System.Exception]::new('Request failed') $exception | Add-Member -NotePropertyName Response -NotePropertyValue $response $exception } Mock Invoke-RestMethod { throw (New-TransientFailure -StatusCode ServiceUnavailable) } { Invoke-SIARequest -Method POST -Path '/api/connectors' -Body @{} } | Should -Throw Should -Invoke Invoke-RestMethod -Times 1 -Exactly } } It 'stops retrying once RetryCount is exhausted and throws a readable error' { InModuleScope psSIA { function New-TransientFailure { param([System.Net.HttpStatusCode]$StatusCode) $response = [System.Net.Http.HttpResponseMessage]::new($StatusCode) $exception = [System.Exception]::new('Request failed') $exception | Add-Member -NotePropertyName Response -NotePropertyValue $response $exception } $script:SIAModuleConfig.RetryCount = 2 Mock Invoke-RestMethod { throw (New-TransientFailure -StatusCode TooManyRequests) } { Invoke-SIARequest -Method GET -Path '/api/connectors' } | Should -Throw '*SIA request failed with HTTP 429*' Should -Invoke Invoke-RestMethod -Times 2 -Exactly } } It 'does not retry when -NoRetry is specified' { InModuleScope psSIA { function New-TransientFailure { param([System.Net.HttpStatusCode]$StatusCode) $response = [System.Net.Http.HttpResponseMessage]::new($StatusCode) $exception = [System.Exception]::new('Request failed') $exception | Add-Member -NotePropertyName Response -NotePropertyValue $response $exception } Mock Invoke-RestMethod { throw (New-TransientFailure -StatusCode TooManyRequests) } { Invoke-SIARequest -Method GET -Path '/api/connectors' -NoRetry } | Should -Throw Should -Invoke Invoke-RestMethod -Times 1 -Exactly } } It 'does not retry a non-transient client error' { InModuleScope psSIA { function New-TransientFailure { param([System.Net.HttpStatusCode]$StatusCode) $response = [System.Net.Http.HttpResponseMessage]::new($StatusCode) $exception = [System.Exception]::new('Request failed') $exception | Add-Member -NotePropertyName Response -NotePropertyValue $response $exception } Mock Invoke-RestMethod { throw (New-TransientFailure -StatusCode Forbidden) } { Invoke-SIARequest -Method GET -Path '/api/connectors' } | Should -Throw '*HTTP 403*' Should -Invoke Invoke-RestMethod -Times 1 -Exactly } } It 'handles a failure with no HTTP response at all, such as a DNS or connection error' { InModuleScope psSIA { Mock Invoke-RestMethod { throw [System.Net.Http.HttpRequestException]::new('Name or service not known') } { Invoke-SIARequest -Method GET -Path '/api/connectors' } | Should -Throw '*SIA request failed with HTTP*' Should -Invoke Invoke-RestMethod -Times 1 -Exactly } } It 'honors a Retry-After header instead of the default backoff' { InModuleScope psSIA { $script:attempt = 0 Mock Invoke-RestMethod { $script:attempt++ if ($script:attempt -eq 1) { $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::TooManyRequests) $response.Headers.RetryAfter = [System.Net.Http.Headers.RetryConditionHeaderValue]::new([TimeSpan]::FromSeconds(0)) $exception = [System.Exception]::new('Too many requests') $exception | Add-Member -NotePropertyName Response -NotePropertyValue $response throw $exception } [pscustomobject]@{ ok = $true } } Mock Start-Sleep { param($Seconds) $Seconds | Should -Be 0 } $result = Invoke-SIARequest -Method GET -Path '/api/connectors' $result.ok | Should -Be $true Should -Invoke Start-Sleep -Times 1 -Exactly } } } |