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.
#
# THERE IS NO REST API. _apis/organizationpolicy/policies 404s on every api-version and on both
# hosts; the only source is the data provider behind the portal's own settings page. The shape
# below was captured from a live organization, not invented, because the first version of this
# command was written against a documented-looking endpoint that does not exist and passed its
# mocked tests regardless.
#
# Two things worth pinning hardest. isValueUndefined is OMITTED for a policy someone set and
# present-and-true for one on its default, so absence means "explicitly configured" - reading a
# missing property as unknown reported every configured policy as blank. And an empty provider
# must warn rather than return nothing, because this route is internal and can change shape.

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' }

            # The provider payload, shaped as a live organization returns it.
            function script:New-PolicyResponse {
                param($Policies, $Inverted = @(), [switch] $Empty)
                $data = if ($Empty) {
                    @{ 'ms.vss-admin-web.organization-policies-data-provider' = $null }
                }
                else {
                    @{ 'ms.vss-admin-web.organization-policies-data-provider' = @{
                        policies         = $Policies
                        invertedPolicies = @($Inverted)
                    } }
                }
                [pscustomobject]@{
                    Content = (@{ fps = @{ dataProviders = @{ data = $data } } } | ConvertTo-Json -Depth 12)
                }
            }
        }
    }

    It 'reads the portal data provider, not the REST route that does not exist' {
        InModuleScope msec {
            Mock Invoke-WebRequest -MockWith {
                New-PolicyResponse -Policies @{ security = @(
                    @{ description = 'Log audit events'; policy = @{ name = 'Policy.LogAuditEvents'; effectiveValue = $true } }) }
            }

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

            # _apis/organizationpolicy/policies 404s. An api-version on this route 404s too.
            Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
                $Uri -match '_settings/organizationPolicy\?__rt=fps&__ver=2' -and
                $Uri -notmatch 'api-version' -and
                $Headers.Authorization -eq 'Bearer ADO.TOKEN'
            }
        }
    }

    It 'asks Azure DevOps for a token, not Graph' {
        InModuleScope msec {
            Mock Invoke-WebRequest -MockWith { New-PolicyResponse -Policies @{ security = @() } }
            Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso' | Out-Null
            Should -Invoke Get-MsecAccessToken -Times 1 -Exactly -ParameterFilter {
                $Resource -eq '499b84ac-1321-427f-aa17-267ca6975798'
            }
        }
    }

    It 'groups by the provider own categories and uses the portal label' {
        $rows = InModuleScope msec {
            Mock Invoke-WebRequest -MockWith {
                New-PolicyResponse -Policies @{
                    applicationConnection = @(
                        @{ description = 'Third-party application access via OAuth'
                           policy = @{ name = 'Policy.DisallowOAuthAuthentication'; effectiveValue = $true; isValueUndefined = $true } })
                    privacy = @(
                        @{ description = 'Allow Microsoft to collect feedback from users'
                           policy = @{ name = 'Policy.AllowFeedbackCollection'; effectiveValue = $true; isValueUndefined = $true } })
                } -Inverted @('Policy.DisallowOAuthAuthentication')
            }
            Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso'
        }

        # The category comes from the payload, so there is no table here to drift out of date.
        ($rows | Where-Object Policy -eq 'DisallowOAuthAuthentication').Category | Should -Be 'Application connection'
        ($rows | Where-Object Policy -eq 'AllowFeedbackCollection').Category     | Should -Be 'Privacy'
        # 'DisallowOAuthAuthentication' means nothing to a reviewer; the portal's label does.
        ($rows | Where-Object Policy -eq 'DisallowOAuthAuthentication').Setting  | Should -Be 'Third-party application access via OAuth'
    }

    It 'flags the policies the settings page renders inverted' {
        $rows = InModuleScope msec {
            Mock Invoke-WebRequest -MockWith {
                New-PolicyResponse -Policies @{ applicationConnection = @(
                    @{ description = 'SSH authentication'; policy = @{ name = 'Policy.DisallowSecureShell'; effectiveValue = $true } }
                    @{ description = 'Validate SSH key expiration'; policy = @{ name = 'Policy.ValidateSshKeyExpiration'; effectiveValue = $true } })
                } -Inverted @('Policy.DisallowSecureShell')
            }
            Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso'
        }

        # Value is raw. Read with the policy NAME it is unambiguous; read against the page's
        # label it is backwards, and IsInverted is what says which.
        ($rows | Where-Object Policy -eq 'DisallowSecureShell').IsInverted      | Should -BeTrue
        ($rows | Where-Object Policy -eq 'ValidateSshKeyExpiration').IsInverted | Should -BeFalse
    }

    It 'treats a MISSING isValueUndefined as explicitly configured' {
        $rows = InModuleScope msec {
            Mock Invoke-WebRequest -MockWith {
                New-PolicyResponse -Policies @{ security = @(
                    # Someone set this one: the provider omits isValueUndefined entirely.
                    @{ description = 'Log audit events'; policy = @{ name = 'Policy.LogAuditEvents'; effectiveValue = $true } }
                    # Still on its default: present, and true.
                    @{ description = 'Restrict PAT creation'; policy = @{ name = 'Policy.DisablePATCreation'; effectiveValue = $false; isValueUndefined = $true } })
                }
            }
            Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso'
        }

        # Reading the absent property as "unknown" reported every configured policy as blank -
        # which is the opposite of what it means.
        ($rows | Where-Object Policy -eq 'LogAuditEvents').IsExplicit    | Should -BeTrue
        ($rows | Where-Object Policy -eq 'DisablePATCreation').IsExplicit | Should -BeFalse
    }

    It 'keeps a category this module has never heard of' {
        $rows = InModuleScope msec {
            Mock Invoke-WebRequest -MockWith {
                New-PolicyResponse -Policies @{ somethingNew = @(
                    @{ description = 'A setting added last week'; policy = @{ name = 'Policy.Whatever'; effectiveValue = $true } }) }
            }
            Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso'
        }

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

    It 'warns rather than returning nothing when the provider is absent' {
        $warnings = @()
        $rows = InModuleScope msec {
            Mock Invoke-WebRequest -MockWith { New-PolicyResponse -Empty }
            Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        # This route is internal to the portal. If it changes shape, an empty list would read as
        # an organization with no policies set.
        @($rows).Count | Should -Be 0
        ($warnings -join ' ') | Should -Match 'UNREAD|internal'
    }

    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).' }
            { 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*'
        }
    }
}