Tests/Invoke-MsecGraphRequest.Tests.ps1

#Requires -Module Pester
#
# Tests for the private Graph caller. It carries three behaviours every command in the
# module depends on and none of them tested directly until now: page concatenation,
# throttle retry, and progress reporting during long paging runs - the last being what
# stops a multi-minute call from looking like a hang.

BeforeAll {
    $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1'
    Import-Module $modulePath -Force -ErrorAction Stop
}

AfterAll {
    Remove-Module Msec -Force -ErrorAction SilentlyContinue
}

Describe 'Invoke-MsecGraphRequest' {
    BeforeEach {
        InModuleScope Msec {
            $script:MsecSession = @{
                TenantId = 'tenant'; ClientId = 'client'
                KeyVaultName = 'kv'; KeyName = 'k'
                ThumbprintBytes = [byte[]](1..20)
                # Pre-seeded so no token call is needed.
                Tokens = @{ 'https://graph.microsoft.com' = @{
                    Token = 'mock'; ExpiresOn = [datetimeoffset]::UtcNow.AddHours(1) } }
            }
        }
    }

    It 'follows @odata.nextLink and concatenates every page' {
        $items = InModuleScope Msec {
            $script:Page = 0
            Mock Invoke-RestMethod -MockWith {
                $script:Page++
                if ($script:Page -lt 3) {
                    [pscustomobject]@{
                        value = @([pscustomobject]@{ n = $script:Page })
                        '@odata.nextLink' = "https://graph.microsoft.com/v1.0/next$($script:Page)"
                    }
                }
                else {
                    [pscustomobject]@{ value = @([pscustomobject]@{ n = $script:Page }) }
                }
            }
            @(Invoke-MsecGraphRequest -Path '/v1.0/things' -All)
        }

        $items.Count | Should -Be 3
        $items.n     | Should -Be @(1, 2, 3)
    }

    It 'reports progress while paging, but stays silent for a single-page response' {
        $calls = InModuleScope Msec {
            Mock Write-Progress -MockWith { }
            Mock Invoke-RestMethod -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ n = 1 }) }
            }
            $null = @(Invoke-MsecGraphRequest -Path '/v1.0/things' -All)
            # An ordinary one-page call must not litter the host with a progress bar.
            Should -Invoke Write-Progress -Times 0 -Exactly
            'quiet'
        }
        $calls | Should -Be 'quiet'

        $result = InModuleScope Msec {
            Mock Write-Progress -MockWith { }
            $script:P = 0
            Mock Invoke-RestMethod -MockWith {
                $script:P++
                if ($script:P -lt 4) {
                    [pscustomobject]@{
                        value = @([pscustomobject]@{ n = $script:P })
                        '@odata.nextLink' = "https://graph.microsoft.com/v1.0/next$($script:P)"
                    }
                }
                else {
                    [pscustomobject]@{ value = @([pscustomobject]@{ n = $script:P }) }
                }
            }
            $rows = @(Invoke-MsecGraphRequest -Path '/v1.0/signIns' -All)

            # Three nextLinks were followed, so three status updates plus one -Completed.
            # Without these a caller blocked here for minutes shows a frozen bar.
            Should -Invoke Write-Progress -Times 3 -Exactly -ParameterFilter { -not $Completed }
            Should -Invoke Write-Progress -Times 1 -Exactly -ParameterFilter { $Completed }
            $rows.Count
        }
        $result | Should -Be 4
    }

    It 'retries a 429 with backoff and returns the eventual result' {
        $result = InModuleScope Msec {
            Mock Start-Sleep -MockWith { }
            $script:Attempt = 0
            Mock Invoke-RestMethod -MockWith {
                $script:Attempt++
                if ($script:Attempt -lt 3) {
                    throw 'Response status code does not indicate success: 429 (Too Many Requests).'
                }
                [pscustomobject]@{ value = @([pscustomobject]@{ ok = $true }) }
            }
            $rows = @(Invoke-MsecGraphRequest -Path '/v1.0/things' -All)
            # Two waits for two throttles - it must actually back off, not hot-loop.
            Should -Invoke Start-Sleep -Times 2 -Exactly
            [pscustomobject]@{ Attempts = $script:Attempt; Rows = $rows }
        }

        $result.Attempts   | Should -Be 3
        $result.Rows.Count | Should -Be 1
    }

    It 'does not retry a 403 - retrying cannot help and would only delay the message' {
        $result = InModuleScope Msec {
            Mock Start-Sleep -MockWith { }
            $script:Tries = 0
            Mock Invoke-RestMethod -MockWith {
                $script:Tries++
                throw 'Response status code does not indicate success: 403 (Forbidden).'
            }
            $threw = $false
            try { Invoke-MsecGraphRequest -Path '/v1.0/things' -All } catch { $threw = $true }
            [pscustomobject]@{ Tries = $script:Tries; Threw = $threw }
        }

        $result.Threw | Should -BeTrue
        $result.Tries | Should -Be 1
    }
}