tests/Get-MsecAzureDevOpsUser.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecAzureDevOpsUser and the Invoke-MsecAzureDevOpsRequest helper behind it.
#
# The bug worth guarding hardest is pagination. The ADO graph APIs put the cursor in the
# X-MS-ContinuationToken RESPONSE HEADER, not in the body. Code that reads $response.value gets
# page one, no error, and no sign there was more - and in an access review the users that go
# missing look exactly like users who do not exist. The Reporting repo's version had this.
#
# The other two: a user in no group must still appear, and a user whose memberships could not be
# read must be distinguishable from one who genuinely has none.

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

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

Describe 'Get-MsecAzureDevOpsUser' {

    BeforeEach {
        InModuleScope Msec {
            $script:MsecSession = @{ TenantId = 't'; ClientId = 'c'; Tokens = @{} }
            Mock Get-MsecAccessToken -MockWith { 'ADO.TOKEN' }

            # Shapes a response the way Azure DevOps returns one: a JSON body, and the paging
            # cursor in a HEADER rather than in the body. Defined inside the module scope so the
            # mock bodies - which also run there - can see it.
            function script:New-AdoResponse {
                param($Value, [string] $ContinuationToken)
                $headers = @{}
                if ($ContinuationToken) { $headers['X-MS-ContinuationToken'] = @($ContinuationToken) }
                [pscustomobject]@{
                    Content = (@{ value = @($Value) } | ConvertTo-Json -Depth 6)
                    Headers = $headers
                }
            }

            function script:New-AdoUser {
                param([string] $Descriptor, [string] $Name, [string] $Principal, [string] $Origin = 'aad')
                [pscustomobject]@{
                    descriptor = $Descriptor; displayName = $Name; principalName = $Principal
                    origin = $Origin; subjectKind = 'user'
                }
            }
        }
    }

    It 'follows the continuation header instead of stopping at page one' {
        $rows = InModuleScope Msec {
            Mock Invoke-WebRequest -MockWith {
                if ($Uri -match '/graph/groups') {
                    return New-AdoResponse -Value @([pscustomobject]@{ descriptor = 'g1'; displayName = 'Contributors'; principalName = '[org]\Contributors' })
                }
                if ($Uri -match '/graph/users') {
                    if ($Uri -match 'continuationToken=') {
                        return New-AdoResponse -Value @(New-AdoUser -Descriptor 'u2' -Name 'Page Two' -Principal 'two@contoso.com')
                    }
                    return New-AdoResponse -Value @(New-AdoUser -Descriptor 'u1' -Name 'Page One' -Principal 'one@contoso.com') -ContinuationToken 'CURSOR'
                }
                return New-AdoResponse -Value @([pscustomobject]@{ containerDescriptor = 'g1' })
            }
            Get-MsecAzureDevOpsUser -Organization 'contoso'
        }

        # Two users across two pages. Reading page one only returns one of them, with no error
        # and nothing to indicate the other exists.
        @($rows | ForEach-Object { $_.DisplayName }) | Should -Contain 'Page One'
        @($rows | ForEach-Object { $_.DisplayName }) | Should -Contain 'Page Two'
        ($rows | Where-Object DisplayName -eq 'Page Two').Group | Should -Be 'Contributors'
    }

    It 'resolves group names from one fetch, not one call per membership' {
        InModuleScope Msec {
            Mock Invoke-WebRequest -MockWith {
                if ($Uri -match '/graph/groups') {
                    return New-AdoResponse -Value @(
                        [pscustomobject]@{ descriptor = 'g1'; displayName = 'Project Collection Administrators'; principalName = '[org]\PCA' }
                        [pscustomobject]@{ descriptor = 'g2'; displayName = 'Contributors'; principalName = '[org]\Contributors' })
                }
                if ($Uri -match '/graph/users') {
                    return New-AdoResponse -Value @(
                        (New-AdoUser -Descriptor 'u1' -Name 'A' -Principal 'a@contoso.com'),
                        (New-AdoUser -Descriptor 'u2' -Name 'B' -Principal 'b@contoso.com'))
                }
                return New-AdoResponse -Value @(
                    [pscustomobject]@{ containerDescriptor = 'g1' }
                    [pscustomobject]@{ containerDescriptor = 'g2' })
            }

            Get-MsecAzureDevOpsUser -Organization 'contoso' | Out-Null

            # One groups call, one users call, one memberships call per user. Fetching the group
            # per membership - the direct translation - would add four more here, and thousands
            # on a real organization for a few dozen distinct groups.
            Should -Invoke Invoke-WebRequest -Times 4 -Exactly
        }
    }

    It 'does not filter by subjectTypes unless asked, because the obvious value returns nothing' {
        InModuleScope Msec {
            Mock Invoke-WebRequest -MockWith {
                if ($Uri -match '/graph/groups') { return New-AdoResponse -Value @() }
                if ($Uri -match '/graph/users')  { return New-AdoResponse -Value @(New-AdoUser -Descriptor 'u1' -Name 'A' -Principal 'a@contoso.com') }
                return New-AdoResponse -Value @()
            }

            Get-MsecAzureDevOpsUser -Organization 'contoso' | Out-Null

            # subjectTypes takes SUBTYPE codes - aad, msa, svc, imp - not subject kinds.
            # 'subjectTypes=user' is not rejected: it matches no subtype and returns an empty
            # list, indistinguishable from an organization the app cannot read.
            Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
                $Uri -match '/graph/users' -and $Uri -notmatch 'subjectTypes'
            }
        }
    }

    It 'passes the subtype codes through when one is asked for' {
        InModuleScope Msec {
            Mock Invoke-WebRequest -MockWith {
                if ($Uri -match '/graph/groups') { return New-AdoResponse -Value @() }
                if ($Uri -match '/graph/users')  { return New-AdoResponse -Value @(New-AdoUser -Descriptor 'u1' -Name 'Svc' -Principal 'svc' -Origin 'vsts') }
                return New-AdoResponse -Value @()
            }

            Get-MsecAzureDevOpsUser -Organization 'contoso' -SubjectType svc | Out-Null

            Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
                $Uri -match 'subjectTypes=svc'
            }
        }
    }
    It 'puts the descriptor in the memberships URL, where PowerShell nearly ate it' {
        InModuleScope Msec {
            Mock Invoke-WebRequest -MockWith {
                if ($Uri -match '/graph/groups') { return New-AdoResponse -Value @() }
                if ($Uri -match '/graph/users')  { return New-AdoResponse -Value @(New-AdoUser -Descriptor 'aad.ABC123' -Name 'A' -Principal 'a@contoso.com') }
                return New-AdoResponse -Value @()
            }

            Get-MsecAzureDevOpsUser -Organization 'contoso' | Out-Null

            # '?' is a legal PowerShell variable-name character, so "$descriptor?direction=up"
            # parses as the variable 'descriptor?direction' - undefined, therefore empty - and
            # the URL becomes '_apis/graph/memberships/=up'. Every user then 404s, which reads
            # like a missing identity rather than a mangled request.
            Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
                $Uri -match '/graph/memberships/aad\.ABC123\?direction=up'
            }
        }
    }
    It 'names the group, and says so when a container is not in the group list' {
        $rows = InModuleScope Msec {
            Mock Invoke-WebRequest -MockWith {
                if ($Uri -match '/graph/groups') { return New-AdoResponse -Value @() }
                if ($Uri -match '/graph/users')  { return New-AdoResponse -Value @(New-AdoUser -Descriptor 'u1' -Name 'A' -Principal 'a@contoso.com') }
                return New-AdoResponse -Value @([pscustomobject]@{ containerDescriptor = 'mystery' })
            }
            Get-MsecAzureDevOpsUser -Organization 'contoso'
        }

        # The membership is real whether or not the container resolved, so it must not read as
        # a user with no group.
        $rows.Group | Should -Be '(unresolved: mystery)'
    }

    It 'keeps a user who belongs to no group at all' {
        $rows = InModuleScope Msec {
            Mock Invoke-WebRequest -MockWith {
                if ($Uri -match '/graph/groups') { return New-AdoResponse -Value @() }
                if ($Uri -match '/graph/users')  { return New-AdoResponse -Value @(New-AdoUser -Descriptor 'u1' -Name 'Lonely' -Principal 'l@contoso.com') }
                return New-AdoResponse -Value @()
            }
            Get-MsecAzureDevOpsUser -Organization 'contoso'
        }

        # Emitting nothing would drop the account from the review entirely.
        @($rows).Count | Should -Be 1
        $rows.Group    | Should -Be '(none)'
    }

    It 'distinguishes memberships it could not read from memberships that are absent' {
        $warnings = @()
        $rows = InModuleScope Msec {
            Mock Invoke-WebRequest -MockWith {
                if ($Uri -match '/graph/groups') { return New-AdoResponse -Value @() }
                if ($Uri -match '/graph/users')  { return New-AdoResponse -Value @(New-AdoUser -Descriptor 'u1' -Name 'A' -Principal 'a@contoso.com') }
                throw 'boom'
            }
            Get-MsecAzureDevOpsUser -Organization 'contoso'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        # '(none)' would claim this account has no access. That is a claim we did not verify.
        $rows.Group | Should -Be '(unreadable)'
        ($warnings -join ' ') | Should -Match 'a@contoso.com'
    }

    It 'surfaces accounts that exist only inside Azure DevOps' {
        $rows = InModuleScope Msec {
            Mock Invoke-WebRequest -MockWith {
                if ($Uri -match '/graph/groups') { return New-AdoResponse -Value @() }
                if ($Uri -match '/graph/users')  { return New-AdoResponse -Value @(New-AdoUser -Descriptor 'u1' -Name 'Local' -Principal 'local' -Origin 'vsts') }
                return New-AdoResponse -Value @()
            }
            Get-MsecAzureDevOpsUser -Organization 'contoso'
        }

        # A 'vsts' account has no Conditional Access and no leaver process behind it.
        $rows.Origin | Should -Be 'vsts'
    }

    It 'warns rather than returning nothing when the identity graph is empty' {
        $warnings = @()
        $rows = InModuleScope Msec {
            Mock Invoke-WebRequest -MockWith { New-AdoResponse -Value @() }
            Get-MsecAzureDevOpsUser -Organization 'contoso'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        # An app that authenticates but cannot read the identity graph gets an empty list, which
        # would otherwise read as an organization with nobody in it.
        @($rows).Count | Should -Be 0
        ($warnings -join ' ') | Should -Match 'unread'
    }

    It 'explains a 401 as ADO membership, not as an Entra permission' {
        InModuleScope Msec {
            Mock Invoke-WebRequest -MockWith { throw 'Response status code does not indicate success: 401 (Unauthorized).' }
            # New-MsecApp cannot fix this one - the access is granted inside Azure DevOps - so an
            # error pointing at Entra permissions sends the reader somewhere useless.
            { Get-MsecAzureDevOpsUser -Organization 'contoso' } | Should -Throw '*Organization Settings*'
        }
    }

    It 'distinguishes a token failure from a membership failure' {
        InModuleScope Msec {
            Mock Get-MsecAccessToken -MockWith { throw 'certificate expired' }
            { Get-MsecAzureDevOpsUser -Organization 'contoso' } | Should -Throw '*Entra-side*'
        }
    }

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