tests/Get-MsecAzureDevOpsOrganization.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecAzureDevOpsOrganization.
#
# The endpoint returns CSV, not JSON, and its headers contain spaces - 'Organization Id',
# 'Organization Name'. That is unusual enough to pin: a rename to camelCase would silently
# produce rows of nulls rather than an error.
#
# It is also an INTERNAL route with no documented equivalent, so an empty result must warn. A
# tenant with no Azure DevOps organizations is possible; a tenant whose enumeration route changed
# shape is far likelier, and reporting the second as the first would end an investigation that
# should have started.

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

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

Describe 'Get-MsecAzureDevOpsOrganization' {

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

    It 'parses the CSV the tenant endpoint returns' {
        $rows = InModuleScope msec {
            Mock Invoke-WebRequest -MockWith {
                [pscustomobject]@{ Content = @'
Organization Id, Organization Name, Url, Owner
c76c60d9-b74d-45ad-9cd8-7d94ea892f98, alpha, https://dev.azure.com/alpha/, ada@contoso.com
bffbbe1b-9b1e-4d3a-9efd-4b62a38df5c1, beta, https://dev.azure.com/beta/, bob@contoso.com
'@
 }
            }
            Get-MsecAzureDevOpsOrganization
        }

        # Headers carry spaces. A well-meaning rename to camelCase would produce rows of nulls
        # rather than an error.
        @($rows).Count       | Should -Be 2
        $rows[0].Organization | Should -Be 'alpha'
        $rows[0].Owner        | Should -Be 'ada@contoso.com'
        $rows[0].Id           | Should -Be 'c76c60d9-b74d-45ad-9cd8-7d94ea892f98'
    }

    It 'queries the tenant of the session by default' {
        InModuleScope msec {
            Mock Invoke-WebRequest -MockWith {
                [pscustomobject]@{ Content = "Organization Id, Organization Name, Url, Owner`nid, o, u, e@x.com" }
            }

            Get-MsecAzureDevOpsOrganization | Out-Null

            # This is a TENANT query, not an organization one - the whole point is finding
            # organizations nobody told you about, so it cannot require naming one.
            Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
                $Uri -match 'EnterpriseCatalog/Organizations\?tenantId=tenant-1'
            }
        }
    }

    It 'asks Azure DevOps for a token, not Graph' {
        InModuleScope msec {
            Mock Invoke-WebRequest -MockWith {
                [pscustomobject]@{ Content = "Organization Id, Organization Name, Url, Owner`nid, o, u, e@x.com" }
            }
            Get-MsecAzureDevOpsOrganization | Out-Null
            Should -Invoke Get-MsecAccessToken -Times 1 -Exactly -ParameterFilter {
                $Resource -eq '499b84ac-1321-427f-aa17-267ca6975798'
            }
        }
    }

    It 'warns rather than reporting a tenant with no organizations' {
        $warnings = @()
        $rows = InModuleScope msec {
            Mock Invoke-WebRequest -MockWith { [pscustomobject]@{ Content = 'Organization Id, Organization Name, Url, Owner' } }
            Get-MsecAzureDevOpsOrganization
        } -WarningVariable warnings -WarningAction SilentlyContinue

        # The route is internal and undocumented. A changed shape must not read as an empty
        # tenant, because that ends an investigation instead of starting one.
        @($rows).Count | Should -Be 0
        ($warnings -join ' ') | Should -Match 'UNREAD'
    }

    It 'explains a 403 as a tenant-level permission, not organization membership' {
        InModuleScope msec {
            Mock Invoke-WebRequest -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' }
            { Get-MsecAzureDevOpsOrganization } | Should -Throw '*tenant-level query*'
        }
    }

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