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
        }
    }
}