Tests/Unit/Private/Invoke-SCARequest.Tests.ps1

Import-Module (Join-Path $PSScriptRoot '../../../psSCA.psd1') -Force

Describe 'Invoke-SCARequest' {
    InModuleScope psSCA {
        BeforeEach {
            $script:SCASessions = [ordered]@{
                Default = [pscustomobject]@{
                    PSTypeName      = 'psSCA.Session'
                    Name            = 'Default'
                    TenantSubdomain = 'contoso'
                    # PSScriptAnalyzer disable-next-line PSAvoidUsingConvertToSecureStringWithPlainText
                    AccessToken     = (ConvertTo-SecureString -String 'the-real-token' -AsPlainText -Force)
                    ExpiresAt       = [DateTime]::UtcNow.AddMinutes(10)
                }
            }
            $script:SCACurrentSessionName = 'Default'
            Mock Start-Sleep { }
        }

        It 'calls the resolved URI with a Bearer authorization header and returns the parsed body' {
            Mock Invoke-WebRequest {
                [pscustomobject]@{ Content = '{"id":"1"}'; StatusCode = 200 }
            }

            $result = Invoke-SCARequest -Service 'SCA' -Method GET -Path '/access/sessions' -Operation 'Test'

            $result.id | Should -Be '1'
            Should -Invoke Invoke-WebRequest -ParameterFilter {
                $Uri -eq 'https://contoso.sca.cyberark.cloud/api/access/sessions' -and
                $Headers.Authorization -eq 'Bearer the-real-token' -and
                $Method -eq 'GET'
            }
        }

        It 'serializes -Body to JSON and sets Content-Type' {
            Mock Invoke-WebRequest {
                [pscustomobject]@{ Content = '{"ok":true}'; StatusCode = 200 }
            }

            Invoke-SCARequest -Service 'UAR' -Method POST -Path '/workflows/requests' -Body @{ reason = 'test' } -Operation 'Test' | Out-Null

            Should -Invoke Invoke-WebRequest -ParameterFilter {
                $Body -eq '{"reason":"test"}' -and $Headers.'Content-Type' -eq 'application/json'
            }
        }

        It 'retries a GET request on a 503 and succeeds on the next attempt' {
            $script:callCount = 0
            Mock Invoke-WebRequest {
                $script:callCount++
                if ($script:callCount -eq 1) {
                    $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new(
                        'Service Unavailable',
                        [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::ServiceUnavailable))
                    throw $exception
                }
                [pscustomobject]@{ Content = '{"id":"1"}'; StatusCode = 200 }
            }

            $result = Invoke-SCARequest -Service 'SCA' -Method GET -Path '/access/sessions' -Operation 'Test'

            $result.id | Should -Be '1'
            Should -Invoke Invoke-WebRequest -Times 2 -Exactly
            Should -Invoke Start-Sleep -Times 1 -Exactly
        }

        It 'does not retry a non-idempotent POST on a 503' {
            Mock Invoke-WebRequest {
                $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new(
                    'Service Unavailable',
                    [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::ServiceUnavailable))
                throw $exception
            }

            { Invoke-SCARequest -Service 'UAR' -Method POST -Path '/workflows/requests' -Body @{ x = 1 } -Operation 'Test' } | Should -Throw

            Should -Invoke Invoke-WebRequest -Times 1 -Exactly
            Should -Invoke Start-Sleep -Times 0 -Exactly
        }

        It 'retries an -Idempotent POST on a 429' {
            $script:callCount = 0
            Mock Invoke-WebRequest {
                $script:callCount++
                if ($script:callCount -eq 1) {
                    $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new(
                        'Too Many Requests',
                        [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::TooManyRequests))
                    throw $exception
                }
                [pscustomobject]@{ Content = '{"items":[]}'; StatusCode = 200 }
            }

            Invoke-SCARequest -Service 'CDS' -Method POST -Path '/detections/cloud-service-entitlements/list' -Body @{} -Operation 'Test' -Idempotent | Out-Null

            Should -Invoke Invoke-WebRequest -Times 2 -Exactly
        }

        It 'never writes the plaintext access token to the verbose stream' {
            Mock Invoke-WebRequest {
                [pscustomobject]@{ Content = '{"id":"1"}'; StatusCode = 200 }
            }

            $verboseOutput = Invoke-SCARequest -Service 'SCA' -Method GET -Path '/access/sessions' -Operation 'Test' -Verbose 4>&1 |
                Where-Object { $_ -is [System.Management.Automation.VerboseRecord] }

            ($verboseOutput -join "`n") | Should -Not -Match 'the-real-token'
        }

        It 'warns when the response indicates additional pages are available' {
            Mock Invoke-WebRequest {
                [pscustomobject]@{ Content = '{"items":[],"nextPageToken":"abc"}'; StatusCode = 200 }
            }

            Invoke-SCARequest -Service 'CDS' -Method GET -Path '/detections/cloud-service-entitlements/list' -Operation 'Test' -WarningVariable warnOut -WarningAction SilentlyContinue | Out-Null
            $warnOut | Should -Not -BeNullOrEmpty
        }
    }
}