tests/Get-MsecAzureDevOpsAlert.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecAzureDevOpsAlert.
#
# The alert shape below was captured from a live organization, not invented - the field list,
# the nested physicalLocations/tools arrays, and truncatedSecret are all real.
#
# Three things worth pinning hardest:
#
# The repository list comes from ENABLEMENT, not from the git API. _apis/git/repositories
# returns only what the caller can see, with a 200 - an app saw 95 repositories where a person
# saw 220 - so building the work list from it silently skips repositories.
#
# A repository that refuses its alerts is COUNTED AND NAMED. Alerts 403 rather than returning
# an empty list, which is what makes this command trustworthy; measured live, 42 of 87 enabled
# repositories refused.
#
# truncatedSecret is NEVER emitted. It holds a fragment of the credential that was found, and
# this output goes into mailboxes and spreadsheets.

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

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

Describe 'Get-MsecAzureDevOpsAlert' {

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

            function script:New-Alert {
                param([int] $Id, [string] $Type = 'secret', [string] $Sev = 'critical', [string] $State = 'active', [int] $AgeDays = 41)
                [pscustomobject]@{
                    alertId           = $Id
                    alertType         = $Type
                    severity          = $Sev
                    state             = $State
                    confidence        = 'high'
                    title             = 'LaunchDarkly API key'
                    firstSeenDate     = [DateTime]::UtcNow.AddDays(-$AgeDays)
                    lastSeenDate      = [DateTime]::UtcNow.AddDays(-$AgeDays)
                    fixedDate         = $null
                    isAutoFixable     = $false
                    truncatedSecret   = 'sdk-abc123...'
                    tools             = @([pscustomobject]@{ name = 'CredScan'; rules = @() })
                    physicalLocations = @([pscustomobject]@{ filePath = 'src/appsettings.json' })
                }
            }

            # Stands in for the ADO request helper so each route can be answered by path.
            function script:Set-AdoMock {
                param($Enabled, $Repos, $Alerts, [string[]] $Refuse = @())
                Mock Invoke-MsecAzureDevOpsRequest -MockWith {
                    if ($Path -eq '_apis/management/enablement') {
                        return [pscustomobject]@{ reposEnablementStatus = $Enabled }
                    }
                    if ($Path -eq '_apis/git/repositories') { return $Repos }
                    if ($Path -match '/_apis/alert/repositories/([^/]+)/alerts') {
                        if ($Matches[1] -in $Refuse) { throw 'Response status code does not indicate success: 403 (Forbidden).' }
                        return $Alerts
                    }
                    return @()
                }
            }
        }
    }

    It 'builds the work list from enablement, not from the git repository list' {
        InModuleScope msec {
            # Two repositories have scanning on; the git API can only see one of them.
            . Set-AdoMock -Enabled @(
                [pscustomobject]@{ projectId = 'p1'; repositoryId = 'r1'; advSecEnabled = $true }
                [pscustomobject]@{ projectId = 'p1'; repositoryId = 'r2'; advSecEnabled = $true }
            ) -Repos @(
                [pscustomobject]@{ id = 'r1'; name = 'Visible'; project = [pscustomobject]@{ id = 'p1'; name = 'Platform' } }
            ) -Alerts @(New-Alert -Id 1)

            $rows = @(Get-MsecAzureDevOpsAlert -Organization 'contoso' -WarningAction SilentlyContinue)

            # Both repositories are queried. Driving off the git list would have dropped r2
            # entirely - with a 200, so nothing would have said so.
            @($rows).Count | Should -Be 2
            # And the one whose name could not be resolved is reported by id, not skipped.
            @($rows | Where-Object Repository -eq 'r2').Count | Should -Be 1
        }
    }

    It 'skips repositories where scanning is switched off' {
        InModuleScope msec {
            . Set-AdoMock -Enabled @(
                [pscustomobject]@{ projectId = 'p1'; repositoryId = 'r1'; advSecEnabled = $true }
                [pscustomobject]@{ projectId = 'p1'; repositoryId = 'r2'; advSecEnabled = $false }
            ) -Repos @() -Alerts @(New-Alert -Id 1)

            @(Get-MsecAzureDevOpsAlert -Organization 'contoso' -WarningAction SilentlyContinue).Count | Should -Be 1
        }
    }

    It 'never emits the secret fragment' {
        $rows = InModuleScope msec {
            . Set-AdoMock -Enabled @([pscustomobject]@{ projectId = 'p1'; repositoryId = 'r1'; advSecEnabled = $true }) `
                        -Repos @() -Alerts @(New-Alert -Id 1)
            Get-MsecAzureDevOpsAlert -Organization 'contoso' -WarningAction SilentlyContinue
        }

        # The API returns truncatedSecret. This output reaches mailboxes and spreadsheets, so a
        # partial credential in it is a second exposure.
        $rows[0].PSObject.Properties.Name | Should -Not -Contain 'truncatedSecret'
        ($rows[0] | ConvertTo-Json) | Should -Not -Match 'sdk-abc123'
        # The secret TYPE is what triage needs, and that is safe.
        $rows[0].Title | Should -Be 'LaunchDarkly API key'
    }

    It 'counts and names repositories that refuse their alerts' {
        $warnings = @()
        $rows = InModuleScope msec {
            . Set-AdoMock -Enabled @(
                [pscustomobject]@{ projectId = 'p1'; repositoryId = 'r1'; advSecEnabled = $true }
                [pscustomobject]@{ projectId = 'p1'; repositoryId = 'r2'; advSecEnabled = $true }
            ) -Repos @(
                [pscustomobject]@{ id = 'r2'; name = 'Refused'; project = [pscustomobject]@{ id = 'p1'; name = 'Platform' } }
            ) -Alerts @(New-Alert -Id 1) -Refuse @('r2')
            Get-MsecAzureDevOpsAlert -Organization 'contoso'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        # A refused repository must not pass as a repository with no findings.
        @($rows).Count | Should -Be 1
        ($warnings -join ' ') | Should -Match 'Platform/Refused'
        ($warnings -join ' ') | Should -Match 'NOT in this output'
    }

    It 'reports how long a finding has been sitting there' {
        $rows = InModuleScope msec {
            . Set-AdoMock -Enabled @([pscustomobject]@{ projectId = 'p1'; repositoryId = 'r1'; advSecEnabled = $true }) `
                        -Repos @() -Alerts @(New-Alert -Id 1 -AgeDays 41)
            Get-MsecAzureDevOpsAlert -Organization 'contoso' -WarningAction SilentlyContinue
        }

        # For a committed credential this is the number that matters: exposure is cumulative and
        # does not stop at detection.
        $rows[0].AgeDays | Should -BeGreaterOrEqual 40
        $rows[0].FilePath | Should -Be 'src/appsettings.json'
        $rows[0].Tool     | Should -Be 'CredScan'
    }

    It 'filters by alert type when asked' {
        $rows = InModuleScope msec {
            . Set-AdoMock -Enabled @([pscustomobject]@{ projectId = 'p1'; repositoryId = 'r1'; advSecEnabled = $true }) `
                        -Repos @() -Alerts @((New-Alert -Id 1 -Type 'secret'), (New-Alert -Id 2 -Type 'dependency'))
            Get-MsecAzureDevOpsAlert -Organization 'contoso' -AlertType secret -WarningAction SilentlyContinue
        }

        @($rows).Count  | Should -Be 1
        $rows[0].AlertType | Should -Be 'secret'
    }

    It 'warns rather than returning nothing when no repository reports scanning' {
        $warnings = @()
        $rows = InModuleScope msec {
            . Set-AdoMock -Enabled @() -Repos @() -Alerts @()
            Get-MsecAzureDevOpsAlert -Organization 'contoso'
        } -WarningVariable warnings -WarningAction SilentlyContinue

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

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