tests/Get-MsecAzureDevOpsRepository.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecAzureDevOpsRepository.
#
# The policy shape below was captured from a live organization: settings.scope carries a
# repositoryId (null meaning every repository in the project), a refName (empty meaning the whole
# repository) and a matchKind of Exact or Prefix.
#
# Three things worth pinning:
#
# isBlocking decides whether a policy is protection. Azure DevOps allows enabled-but-advisory
# policies that show in a pull request and stop nothing; counting those as protection
# overstates the posture.
#
# A project-wide policy protects a repository as surely as a per-repository one, so a null
# repositoryId must match. Measured live, a single project-wide secrets-scanning rule applied
# to every repository - which is why -Unprotected means "no reviewer requirement" rather than
# "no blocking policy", a measure under which nothing was ever unprotected.
#
# Policies that could not be read report $null, never 0. A project whose policies 403 must not
# make its repositories look unprotected.

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

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

Describe 'Get-MsecAzureDevOpsRepository' {

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

            function script:New-Repo {
                param([string] $Id = 'r1', [string] $Name = 'Repo', [string] $Branch = 'refs/heads/main', [switch] $Disabled)
                [pscustomobject]@{
                    id = $Id; name = $Name; defaultBranch = $Branch; size = 5MB; isDisabled = [bool]$Disabled
                    project = [pscustomobject]@{ id = 'p1'; name = 'Platform' }
                }
            }

            function script:New-Policy {
                param(
                    [string] $Type, [hashtable] $Settings = @{}, [string] $RepoId = 'r1',
                    [string] $Ref = 'refs/heads/main', [string] $Match = 'Exact',
                    [bool] $Enabled = $true, [bool] $Blocking = $true
                )
                $Settings['scope'] = @([pscustomobject]@{ repositoryId = $RepoId; refName = $Ref; matchKind = $Match })
                [pscustomobject]@{
                    isEnabled = $Enabled; isBlocking = $Blocking
                    type = [pscustomobject]@{ displayName = $Type }
                    settings = [pscustomobject]$Settings
                }
            }

            function script:Set-RepoMock {
                param($Repos, $Policies, $Enablement = @(), [switch] $PolicyFails)
                Mock Invoke-MsecAzureDevOpsRequest -MockWith {
                    if ($Path -eq '_apis/git/repositories') { return $Repos }
                    if ($Path -eq '_apis/management/enablement') { return [pscustomobject]@{ reposEnablementStatus = $Enablement } }
                    if ($Path -match '/_apis/policy/configurations') {
                        if ($PolicyFails) { throw 'Response status code does not indicate success: 403 (Forbidden).' }
                        return $Policies
                    }
                    return @()
                }
            }
        }
    }

    It 'counts a blocking policy as protection and an advisory one as not' {
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @(New-Repo) -Policies @(
                (New-Policy -Type 'Minimum number of reviewers' -Settings @{ minimumApproverCount = 2; creatorVoteCounts = $false })
                (New-Policy -Type 'Build' -Blocking $false)
            )
            Get-MsecAzureDevOpsRepository -Organization 'contoso'
        }

        $rows.MinimumReviewers | Should -Be 2
        # Enabled but not blocking: it shows in the pull request and stops nothing.
        $rows.RequireBuildValidation | Should -BeFalse
        $rows.PolicyCount         | Should -Be 2
        $rows.BlockingPolicyCount | Should -Be 1
    }

    It 'applies a project-wide policy to every repository' {
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @((New-Repo -Id 'r1' -Name 'One'), (New-Repo -Id 'r2' -Name 'Two')) `
                           -Policies @(New-Policy -Type 'Secrets scanning restriction' -RepoId $null -Ref '')
            Get-MsecAzureDevOpsRepository -Organization 'contoso'
        }

        # A null repositoryId means the whole project; an empty ref means the whole repository.
        @($rows | Where-Object BlockSecretPush).Count | Should -Be 2
    }

    It 'matches a prefix-scoped policy against the default branch' {
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @(New-Repo -Branch 'refs/heads/main') `
                           -Policies @(New-Policy -Type 'Minimum number of reviewers' -Settings @{ minimumApproverCount = 1 } -Ref 'refs/heads/' -Match 'Prefix')
            Get-MsecAzureDevOpsRepository -Organization 'contoso'
        }

        $rows.MinimumReviewers | Should -Be 1
    }

    It 'ignores a policy scoped to another branch' {
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @(New-Repo -Branch 'refs/heads/main') `
                           -Policies @(New-Policy -Type 'Minimum number of reviewers' -Settings @{ minimumApproverCount = 3 } -Ref 'refs/heads/release')
            Get-MsecAzureDevOpsRepository -Organization 'contoso'
        }

        # Protecting release says nothing about main.
        $rows.MinimumReviewers | Should -Be 0
    }

    It 'flags a reviewer policy the author can satisfy alone' {
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @(New-Repo) `
                           -Policies @(New-Policy -Type 'Minimum number of reviewers' -Settings @{ minimumApproverCount = 1; creatorVoteCounts = $true })
            Get-MsecAzureDevOpsRepository -Organization 'contoso'
        }

        # One reviewer, and the author's own vote counts: a self-approved pull request is a
        # direct push with more steps.
        $rows.MinimumReviewers    | Should -Be 1
        $rows.SelfApprovalAllowed | Should -BeTrue
    }

    It 'names blocking policy types it has no column for' {
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @(New-Repo) -Policies @(
                (New-Policy -Type 'Reserved names restriction')
                (New-Policy -Type 'Path Length restriction')
                (New-Policy -Type 'Build'))
            Get-MsecAzureDevOpsRepository -Organization 'contoso'
        }

        # Azure DevOps adds policy types and organizations write custom ones. A report with a
        # column per known type silently drops the rest - and these two turned up on a live
        # organization that a twelve-project sample had not shown.
        $rows.OtherPolicies | Should -Be 'Path Length restriction; Reserved names restriction'
        # Something with a column of its own must not also appear here.
        $rows.OtherPolicies | Should -Not -Match 'Build'
    }

    It 'reports every reviewer setting, not only the count' {
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @(New-Repo) -Policies @(
                New-Policy -Type 'Minimum number of reviewers' -Settings @{
                    minimumApproverCount = 2; creatorVoteCounts = $false; blockLastPusherVote = $true
                    resetOnSourcePush = $true; requireVoteOnLastIteration = $true
                    resetRejectionsOnSourcePush = $false; allowDownvotes = $false
                    requireVoteOnEachIteration = $false })
            Get-MsecAzureDevOpsRepository -Organization 'contoso'
        }

        # Each of these is a way a nominally-reviewed change reaches main unreviewed, and a
        # tenant that happens to have them all set is not a reason to stop reporting them.
        $rows.BlockLastPusherVote        | Should -BeTrue
        $rows.ResetVotesOnPush           | Should -BeTrue
        $rows.RequireVoteOnLastIteration | Should -BeTrue
        $rows.ResetRejectionsOnPush      | Should -BeFalse
        $rows.AllowDownvotes             | Should -BeFalse
    }

    It 'counts named required reviewers separately from the minimum' {
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @(New-Repo) -Policies @(
                (New-Policy -Type 'Minimum number of reviewers' -Settings @{ minimumApproverCount = 1 })
                (New-Policy -Type 'Required reviewers' -Settings @{ requiredReviewerIds = @('a', 'b') }))
            Get-MsecAzureDevOpsRepository -Organization 'contoso'
        }

        # "one approver" and "these two people must approve" are different controls.
        $rows.MinimumReviewers  | Should -Be 1
        $rows.RequiredReviewers | Should -Be 2
    }
    It 'reports $null, not 0, when policies could not be read' {
        $warnings = @()
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @(New-Repo) -Policies @() -PolicyFails
            Get-MsecAzureDevOpsRepository -Organization 'contoso'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        # 0 would mean "no reviewers required", which is a claim about the repository. $null
        # means we did not find out.
        $rows.MinimumReviewers    | Should -BeNullOrEmpty
        $rows.BlockingPolicyCount | Should -BeNullOrEmpty
        ($warnings -join ' ')     | Should -Match 'rather than none'
    }

    It 'excludes unread repositories from -Unprotected' {
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @(New-Repo) -Policies @() -PolicyFails
            Get-MsecAzureDevOpsRepository -Organization 'contoso' -Unprotected
        } -WarningAction SilentlyContinue

        # A repository whose policies could not be read is not evidence of being unprotected.
        @($rows).Count | Should -Be 0
    }

    It 'selects only repositories with no reviewer requirement under -Unprotected' {
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @((New-Repo -Id 'r1' -Name 'Guarded'), (New-Repo -Id 'r2' -Name 'Open')) `
                           -Policies @(
                                (New-Policy -Type 'Minimum number of reviewers' -Settings @{ minimumApproverCount = 1 } -RepoId 'r1')
                                (New-Policy -Type 'Secrets scanning restriction' -RepoId $null -Ref ''))
            Get-MsecAzureDevOpsRepository -Organization 'contoso' -Unprotected
        }

        # 'Open' has a blocking policy - the project-wide secret rule - and still requires no
        # reviewer. Measuring protection as "any blocking policy" would have hidden it.
        @($rows).Count   | Should -Be 1
        $rows.Repository | Should -Be 'Open'
    }

    It 'warns rather than returning nothing when no repository is readable' {
        $warnings = @()
        $rows = InModuleScope msec {
            . Set-RepoMock -Repos @() -Policies @()
            Get-MsecAzureDevOpsRepository -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-MsecAzureDevOpsRepository -Organization 'contoso' } | Should -Throw '*Connect-Msec*'
        }
    }
}