tests/Get-MsecAzureDevOpsEnvironment.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecAzureDevOpsEnvironment.
#
# The distinction to protect is 0 versus $null. An environment with no checks configured returns
# an empty list; an environment whose checks could not be read returns nothing either. Collapsing
# those would report a deployment target nobody LOOKED at as one nobody guards - and -Unchecked
# would then hand back a list padded with environments that might be perfectly protected.
#
# Measured on a live organization: 71 environments, 50 with no checks, and three of those open to
# every pipeline in their project - one of them named CN-PROD.

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

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

Describe 'Get-MsecAzureDevOpsEnvironment' {

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

            function script:Set-EnvMock {
                param($Environments, $Checks = @{}, $Open = @(), [switch] $ChecksFail)
                $script:MockEnvs   = $Environments
                $script:MockChecks = $Checks
                $script:MockOpen   = @($Open)
                $script:MockChecksFail = [bool] $ChecksFail
                Mock Invoke-MsecAzureDevOpsRequest -MockWith {
                    if ($Path -eq '_apis/projects') { return @([pscustomobject]@{ id = 'p1'; name = 'Viedoc4' }) }
                    if ($Path -match '/distributedtask/environments$') { return $script:MockEnvs }
                    if ($Path -match 'resourceId=(\d+)') {
                        if ($script:MockChecksFail) { throw 'Response status code does not indicate success: 403 (Forbidden).' }
                        $id = [int] $Matches[1]
                        return @($script:MockChecks[$id])
                    }
                    if ($Path -match '/pipelinePermissions/environment/(\d+)$') {
                        if ($script:MockOpen -contains [int] $Matches[1]) {
                            return [pscustomobject]@{
                                allPipelines = [pscustomobject]@{ authorized = $true; authorizedBy = [pscustomobject]@{ displayName = 'Ada' } }
                                pipelines = @() }
                        }
                        return [pscustomobject]@{ pipelines = @() }
                    }
                    return @()
                }
            }
            function script:New-Env { param([int] $Id, [string] $Name)
                [pscustomobject]@{ id = $Id; name = $Name
                                   createdBy = [pscustomobject]@{ displayName = 'Ada' }
                                   lastModifiedBy = [pscustomobject]@{ displayName = 'Ada' } } }
            function script:New-Approval { param([string[]] $Approvers)
                [pscustomobject]@{ type = [pscustomobject]@{ name = 'Approval' }
                                   settings = [pscustomobject]@{ approvers = @($Approvers | ForEach-Object { [pscustomobject]@{ displayName = $_ } }) } } }
        }
    }

    It 'separates no checks configured from checks not read' {
        $configured = InModuleScope msec {
            . Set-EnvMock -Environments @(New-Env -Id 1 -Name 'bare') -Checks @{ 1 = @() }
            Get-MsecAzureDevOpsEnvironment -Organization 'contoso'
        }
        $unread = InModuleScope msec {
            . Set-EnvMock -Environments @(New-Env -Id 1 -Name 'bare') -ChecksFail
            Get-MsecAzureDevOpsEnvironment -Organization 'contoso'
        }

        # 0 is a claim about the environment; $null is a claim about the read.
        $configured.CheckCount  | Should -Be 0
        $configured.HasApproval | Should -BeFalse
        $unread.CheckCount      | Should -BeNullOrEmpty
        $unread.HasApproval     | Should -BeNullOrEmpty
    }

    It 'excludes unread environments from -Unchecked' {
        $rows = InModuleScope msec {
            . Set-EnvMock -Environments @(New-Env -Id 1 -Name 'unknown') -ChecksFail
            Get-MsecAzureDevOpsEnvironment -Organization 'contoso' -Unchecked
        }

        # Otherwise the list of unguarded targets fills up with ones that may be fine.
        @($rows).Count | Should -Be 0
    }

    It 'names the approvers, including when they are a group' {
        $rows = InModuleScope msec {
            . Set-EnvMock -Environments @(New-Env -Id 5 -Name 'prod') `
                -Checks @{ 5 = @(New-Approval -Approvers @('ado-platform-env-approvers')) }
            Get-MsecAzureDevOpsEnvironment -Organization 'contoso'
        }

        # A group is the honest answer - who is in it is a separate question.
        $rows.HasApproval   | Should -BeTrue
        $rows.ApproverCount | Should -Be 1
        $rows.Approvers     | Should -Be 'ado-platform-env-approvers'
    }

    It 'flags an approval check with nobody on it' {
        $rows = InModuleScope msec {
            . Set-EnvMock -Environments @(New-Env -Id 6 -Name 'ghost') -Checks @{ 6 = @(New-Approval -Approvers @()) }
            Get-MsecAzureDevOpsEnvironment -Organization 'contoso'
        }

        # A gate with no approver named on it is not the protection the check count implies.
        $rows.HasApproval   | Should -BeTrue
        $rows.ApproverCount | Should -Be 0
    }

    It 'reports an environment any pipeline may deploy to' {
        $rows = InModuleScope msec {
            . Set-EnvMock -Environments @((New-Env -Id 7 -Name 'CN-PROD'), (New-Env -Id 8 -Name 'guarded')) `
                -Checks @{ 7 = @(); 8 = @(New-Approval -Approvers @('Ada')) } -Open @(7)
            Get-MsecAzureDevOpsEnvironment -Organization 'contoso'
        }

        # No check AND reachable by anything - a production target a pipeline written this
        # afternoon can deploy to.
        ($rows | Where-Object Environment -eq 'CN-PROD').OpenToAllPipelines | Should -BeTrue
        ($rows | Where-Object Environment -eq 'CN-PROD').OpenedBy           | Should -Be 'Ada'
        ($rows | Where-Object Environment -eq 'guarded').OpenToAllPipelines | Should -BeFalse
    }

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