tests/Get-MsecAzureDevOpsAgentPool.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecAzureDevOpsAgentPool.
#
# The distinction that matters most here is unread versus empty. A hosted pool has no enumerable
# agents and reports 0; a pool whose agents could not be read reports $null. Reporting both as 0
# would say "this pool has no agents" about a pool nobody managed to look inside.
#
# The other is that versions and operating systems are reported as DISTINCT LISTS. A live pool
# held agent versions 2.213.2, 3.244.1 and 4.264.2 at once - an average or a maximum would have
# hidden the 2.x agent, which is the one worth finding.

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

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

Describe 'Get-MsecAzureDevOpsAgentPool' {

    BeforeEach {
        InModuleScope msec {
            $script:MsecSession = @{ TenantId = 't'; ClientId = 'c'; Tokens = @{} }

            function script:New-Pool {
                param([int] $Id = 1, [string] $Name = 'Pool', [switch] $Hosted, [switch] $AutoProvision)
                [pscustomobject]@{ id = $Id; name = $Name; isHosted = [bool]$Hosted
                                   autoProvision = [bool]$AutoProvision; autoUpdate = $true }
            }
            function script:New-Agent {
                param(
                    [string] $Name, [string] $Version, [string] $Status = 'online',
                    [bool] $Enabled = $true, [string] $Os = 'Ubuntu 22.04', [int] $OfflineDays = 0
                )
                [pscustomobject]@{
                    name = $Name; version = $Version; status = $Status; enabled = $Enabled
                    osDescription = $Os
                    statusChangedOn = if ($OfflineDays) { [datetime]::UtcNow.AddDays(-$OfflineDays) } else { [datetime]::UtcNow }
                }
            }
            # The fixtures live in MODULE scope, not in this function's parameters. Pester
            # evaluates a -MockWith body later and in its own scope, where a dot-sourced
            # function's parameters are not reachable - the mock then returned an empty agent
            # list and every count read as 0, which looked like a bug in the command.
            function script:Set-PoolMock {
                param($Pools, $Agents = @(), $Roles = $null, [switch] $AgentsFail, $Projects = @(), $Queues = @(), $OpenQueues = @())
                $script:MockPools      = $Pools
                $script:MockProjects   = $Projects
                $script:MockQueues     = $Queues
                $script:MockOpenQueues = @($OpenQueues)
                $script:MockAgents     = $Agents
                $script:MockRoles      = $Roles
                $script:MockAgentsFail = [bool] $AgentsFail
                Mock Invoke-MsecAzureDevOpsRequest -MockWith {
                    if ($Path -eq '_apis/projects') { return $script:MockProjects }
                    if ($Path -match '/_apis/distributedtask/queues$') {
                        $projectId = ($Path -split '/')[0]
                        return @($script:MockQueues | Where-Object { $_.projectId -eq $projectId })
                    }
                    if ($Path -match '/pipelinePermissions/queue/(\d+)$') {
                        $queueId = [int] $Matches[1]
                        if ($script:MockOpenQueues -contains $queueId) {
                            return [pscustomobject]@{ allPipelines = [pscustomobject]@{ authorized = $true } }
                        }
                        # The field is omitted when the setting is off - absence is 'not open'.
                        return [pscustomobject]@{ pipelines = @() }
                    }
                    if ($Path -eq '_apis/distributedtask/pools') { return $script:MockPools }
                    if ($Path -match '/agents$') {
                        if ($script:MockAgentsFail) { throw 'Response status code does not indicate success: 403 (Forbidden).' }
                        return $script:MockAgents
                    }
                    if ($Path -match 'agentqueuerole') {
                        if ($null -eq $script:MockRoles) { throw 'Response status code does not indicate success: 403 (Forbidden).' }
                        return $script:MockRoles
                    }
                    return @()
                }
            }
        }
    }

    It 'lists agent versions and operating systems distinctly' {
        $rows = InModuleScope msec {
            . Set-PoolMock -Pools @(New-Pool -Name 'Default') -Agents @(
                (New-Agent -Name 'a' -Version '2.213.2' -Os 'Windows 10.0.14393')
                (New-Agent -Name 'b' -Version '4.264.2' -Os 'Windows 10.0.19045')
                (New-Agent -Name 'c' -Version '4.264.2' -Os 'Windows 10.0.19045'))
            Get-MsecAzureDevOpsAgentPool -Organization 'contoso'
        }

        # Both versions present, deduplicated. A maximum would have shown only 4.264.2 and hidden
        # the agent two majors behind.
        $rows.AgentVersions | Should -Be '2.213.2, 4.264.2'
        $rows.AgentOperatingSystems | Should -Match '10.0.14393'
        $rows.AgentCount | Should -Be 3
    }

    It 'separates offline-but-enabled from disabled' {
        $rows = InModuleScope msec {
            . Set-PoolMock -Pools @(New-Pool) -Agents @(
                (New-Agent -Name 'up'      -Version '5.0' -Status 'online')
                (New-Agent -Name 'away'    -Version '5.0' -Status 'offline' -Enabled $true)
                (New-Agent -Name 'retired' -Version '5.0' -Status 'offline' -Enabled $false))
            Get-MsecAzureDevOpsAgentPool -Organization 'contoso'
        }

        # An offline agent that is still enabled will rejoin and take jobs; a disabled one will
        # not. Counting them together would blur a machine that is coming back with one that is
        # gone.
        $rows.AgentsOnline         | Should -Be 1
        $rows.AgentsOfflineEnabled | Should -Be 1
        $rows.AgentsDisabled       | Should -Be 1
    }

    It 'reports how long the most absent enabled agent has been gone' {
        $rows = InModuleScope msec {
            . Set-PoolMock -Pools @(New-Pool) -Agents @(
                (New-Agent -Name 'here'  -Version '5.0' -Status 'online')
                (New-Agent -Name 'gone'  -Version '2.1' -Status 'offline' -Enabled $true  -OfflineDays 309)
                (New-Agent -Name 'older' -Version '2.1' -Status 'offline' -Enabled $true  -OfflineDays 731)
                # Absent even longer, but disabled - it will not come back, so it must not count.
                (New-Agent -Name 'off'   -Version '2.1' -Status 'offline' -Enabled $false -OfflineDays 2000))
            Get-MsecAzureDevOpsAgentPool -Organization 'contoso'
        }

        # Measured live: 731 days on one organization, 2261 on another pool. A count of offline
        # agents does not convey that; the age does.
        $rows.LongestOfflineDays | Should -Be 731
        # And it is a whole number - Measure-Object -Maximum returns a double, which rendered as
        # 731.000 in a table.
        $rows.LongestOfflineDays | Should -BeOfType [int]
    }
    It 'reports $null for agents it could not read, not 0' {
        $warnings = @()
        $rows = InModuleScope msec {
            . Set-PoolMock -Pools @(New-Pool) -AgentsFail
            Get-MsecAzureDevOpsAgentPool -Organization 'contoso'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        $rows.AgentCount    | Should -BeNullOrEmpty
        $rows.AgentVersions | Should -BeNullOrEmpty
        ($warnings -join ' ') | Should -Match 'could not read agents'
    }

    It 'does not ask a hosted pool for agents' {
        $rows = InModuleScope msec {
            . Set-PoolMock -Pools @(New-Pool -Name 'Azure Pipelines' -Hosted)
            Get-MsecAzureDevOpsAgentPool -Organization 'contoso'
            Should -Invoke Invoke-MsecAzureDevOpsRequest -Times 0 -Exactly -ParameterFilter { $Path -match '/agents$' }
        }

        # A hosted pool has no enumerable agents - 0 is the answer, and asking is a wasted call
        # and a confusing 404.
        $rows.AgentCount | Should -Be 0
        $rows.IsHosted   | Should -BeTrue
    }



    It 'maps which projects can queue work on a pool' {
        $rows = InModuleScope msec {
            . Set-PoolMock -Pools @(New-Pool -Id 7 -Name 'Shared') -Agents @() `
                -Projects @(
                    [pscustomobject]@{ id = 'p1'; name = 'Alpha' }
                    [pscustomobject]@{ id = 'p2'; name = 'Beta' }
                    [pscustomobject]@{ id = 'p3'; name = 'Gamma' }) `
                -Queues @(
                    [pscustomobject]@{ id = 11; projectId = 'p1'; pool = [pscustomobject]@{ id = 7 } }
                    [pscustomobject]@{ id = 12; projectId = 'p2'; pool = [pscustomobject]@{ id = 7 } })
            Get-MsecAzureDevOpsAgentPool -Organization 'contoso' -IncludeExposure
        }

        # AutoProvision says future projects will get the pool; this says which have it now -
        # measured live, three self-hosted pools were reachable from all 36 projects.
        $rows.ProjectCount | Should -Be 2
        $rows.Projects     | Should -Be 'Alpha; Beta'
    }

    It 'names only the projects where any pipeline may use the pool' {
        $rows = InModuleScope msec {
            . Set-PoolMock -Pools @(New-Pool -Id 7 -Name 'Shared') -Agents @() `
                -Projects @(
                    [pscustomobject]@{ id = 'p1'; name = 'Alpha' }
                    [pscustomobject]@{ id = 'p2'; name = 'Beta' }) `
                -Queues @(
                    [pscustomobject]@{ id = 11; projectId = 'p1'; pool = [pscustomobject]@{ id = 7 } }
                    [pscustomobject]@{ id = 12; projectId = 'p2'; pool = [pscustomobject]@{ id = 7 } }) `
                -OpenQueues @(12)
            Get-MsecAzureDevOpsAgentPool -Organization 'contoso' -IncludeExposure
        }

        # Reachable from both, but only Beta lets any pipeline use it without approval.
        $rows.ProjectCount       | Should -Be 2
        $rows.OpenInProjects | Should -Be 'Beta'
    }

    It 'leaves the exposure columns $null when not asked for' {
        $rows = InModuleScope msec {
            . Set-PoolMock -Pools @(New-Pool) -Agents @()
            Get-MsecAzureDevOpsAgentPool -Organization 'contoso'
        }

        # $null is "not collected". 0 would claim the pool is reachable from no project at all.
        $rows.ProjectCount | Should -BeNullOrEmpty
        $rows.Projects     | Should -BeNullOrEmpty
    }
    It 'filters to self-hosted pools when asked' {
        $rows = InModuleScope msec {
            . Set-PoolMock -Pools @((New-Pool -Id 1 -Name 'Hosted' -Hosted), (New-Pool -Id 2 -Name 'Ours')) -Agents @()
            Get-MsecAzureDevOpsAgentPool -Organization 'contoso' -SelfHostedOnly
        }

        @($rows).Count | Should -Be 1
        $rows.Pool     | Should -Be 'Ours'
    }

    It 'warns rather than returning nothing when no pool is readable' {
        $warnings = @()
        $rows = InModuleScope msec {
            . Set-PoolMock -Pools @()
            Get-MsecAzureDevOpsAgentPool -Organization 'contoso'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        @($rows).Count | Should -Be 0
        ($warnings -join ' ') | Should -Match 'nothing was read'
    }

    It 'throws a clear error when not connected' {
        InModuleScope msec {
            $script:MsecSession = $null
            { Get-MsecAzureDevOpsAgentPool -Organization 'contoso' } | Should -Throw '*Connect-Msec*'
        }
    }
}