tests/Get-MsecAzureDevOpsOrganizationPolicy.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecAzureDevOpsOrganizationPolicy.
#
# These policies are the ORGANIZATION's ceiling - the same role the SharePoint tenant settings
# and the Teams Global policy play. A well-run project inside an org that allows third-party
# OAuth apps is still exposed, and reviewing projects one at a time never shows it.
#
# Three things worth pinning. The API has shipped more than one response shape, and guessing
# wrong produces empty rows that read as "no policies set". A policy nobody ever configured
# reports a DEFAULT, which is not a decision anyone made. And an empty response means the
# account cannot see organization settings, which must not read as a clean org.

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

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

Describe 'Get-MsecAzureDevOpsOrganizationPolicy' {

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

    It 'asks Azure DevOps for a token, not Graph, and presents it as a bearer' {
        InModuleScope Msec {
            Mock Invoke-RestMethod -MockWith { [pscustomobject]@{ value = @(
                [pscustomobject]@{ policy = [pscustomobject]@{ name = 'Policy.DisallowOAuthAuthentication'; effectiveValue = $false; isValueUndefined = $false } }) } }

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

            # ADO is its own Entra resource; a Graph token is rejected outright.
            Should -Invoke Get-MsecAccessToken -Times 1 -Exactly -ParameterFilter {
                $Resource -eq '499b84ac-1321-427f-aa17-267ca6975798'
            }
            Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter {
                $Uri -match 'dev\.azure\.com/contoso/_apis/organizationpolicy/policies' -and
                $Headers.Authorization -eq 'Bearer ADO.TOKEN'
            }
        }
    }

    It 'reads the nested policy shape, and trims the prefix every name carries' {
        $rows = InModuleScope Msec {
            Mock Invoke-RestMethod -MockWith { [pscustomobject]@{ value = @(
                [pscustomobject]@{ policy = [pscustomobject]@{ name = 'Policy.DisallowAadGuestUserAccess'; effectiveValue = $true; isValueUndefined = $false } }) } }
            Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso'
        }

        $rows.Setting    | Should -Be 'DisallowAadGuestUserAccess'
        $rows.Value      | Should -Be 'True'
        $rows.Category   | Should -Be 'Access'
        $rows.IsExplicit | Should -BeTrue
    }

    It 'reads the flat shape too, because the API has shipped both' {
        $rows = InModuleScope Msec {
            # Guessing one shape and getting the other yields rows of nulls, which read as an
            # organization with nothing configured.
            Mock Invoke-RestMethod -MockWith { [pscustomobject]@{ value = @(
                [pscustomobject]@{ name = 'Policy.LogAuditEvents'; effectiveValue = $true; isValueUndefined = $false }) } }
            Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso'
        }

        $rows.Setting  | Should -Be 'LogAuditEvents'
        $rows.Category | Should -Be 'Auditing'
    }

    It 'marks a policy nobody ever set, because a safe default is not a decision' {
        $rows = InModuleScope Msec {
            Mock Invoke-RestMethod -MockWith { [pscustomobject]@{ value = @(
                [pscustomobject]@{ policy = [pscustomobject]@{ name = 'Policy.AllowAnonymousAccess'; effectiveValue = $false; isValueUndefined = $true } }) } }
            Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso'
        }

        # The value is safe today and nobody chose it, so nothing stops it changing.
        $rows.IsExplicit | Should -BeFalse
        $rows.Value      | Should -Be 'False'
    }

    It 'keeps a policy this table has never heard of' {
        $rows = InModuleScope Msec {
            Mock Invoke-RestMethod -MockWith { [pscustomobject]@{ value = @(
                [pscustomobject]@{ policy = [pscustomobject]@{ name = 'Policy.SomethingMicrosoftAddedLastWeek'; effectiveValue = $true; isValueUndefined = $false } }) } }
            Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso'
        }

        # Dropping it would hide exactly the new setting nobody has reviewed yet.
        @($rows).Count | Should -Be 1
        $rows.Category | Should -Be 'Other'
    }

    It 'warns rather than returning nothing when the response is empty' {
        $warnings = @()
        $rows = InModuleScope Msec {
            Mock Invoke-RestMethod -MockWith { [pscustomobject]@{ value = @() } }
            Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        # An account that authenticates but cannot see organization settings gets an empty list,
        # which would otherwise read as an organization with no policies to worry about.
        @($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-RestMethod -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-MsecAzureDevOpsOrganizationPolicy -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-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso' } | Should -Throw '*Entra-side*'
        }
    }

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