Tests/Get-MsecAdoServiceConnection.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecAdoServiceConnection. The function makes two kinds of REST # calls to dev.azure.com: # 1. /_apis/projects -> list projects # 2. /{project}/_apis/serviceendpoint/endpoints -> list connections per project # We mock the token + both endpoints. Coverage: # - Dedupe across projects (shared service connections appear in multiple # projects but should produce one row) # - Projects column lists every project the endpoint is exposed to # - Auth-scheme + type + IsShared flow through # - PSTypeName tagged for default Format-Table display # - 401/403 on the projects call rewrites to a helpful org-membership hint BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop $script:TestThumbBytes = [byte[]](1..20) } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Get-MsecAdoServiceConnection' { BeforeEach { InModuleScope Msec -Parameters @{ Thumb = $script:TestThumbBytes } { param($Thumb) $script:MsecSession = @{ TenantId = 'tenant' ClientId = 'client' KeyVaultName = 'kv-test' KeyName = 'msec-app' ThumbprintBytes = $Thumb Tokens = @{} } } } It 'walks all projects, dedupes shared endpoints, and projects flat rows with Projects[] populated' { $rows = InModuleScope Msec { Mock Get-MsecAccessToken -MockWith { 'mock-ado-token' } # /_apis/projects - org has two projects Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/_apis/projects' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ name = 'Proj-A' } [pscustomobject]@{ name = 'Proj-B' } ) } } # Proj-A's endpoints: 2 connections, one project-scoped, one shared. Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/Proj-A/_apis/serviceendpoint/endpoints' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = 'ep-1'; name = 'AzureRM-Prod'; type = 'azurerm' url = 'https://management.azure.com/'; description = 'Prod sub' isShared = $false; isReady = $true authorization = [pscustomobject]@{ scheme = 'ServicePrincipal' } createdBy = [pscustomobject]@{ displayName = 'admin' } serviceEndpointProjectReferences = @( [pscustomobject]@{ projectReference = [pscustomobject]@{ name = 'Proj-A' } } ) data = [pscustomobject]@{ subscriptionId = 'sub-prod-guid' } } [pscustomobject]@{ id = 'ep-2'; name = 'Docker-Registry'; type = 'docker' url = 'https://docker.io'; description = 'Shared docker' isShared = $true; isReady = $true authorization = [pscustomobject]@{ scheme = 'UsernamePassword' } createdBy = [pscustomobject]@{ displayName = 'admin' } # Shared to BOTH projects serviceEndpointProjectReferences = @( [pscustomobject]@{ projectReference = [pscustomobject]@{ name = 'Proj-A' } } [pscustomobject]@{ projectReference = [pscustomobject]@{ name = 'Proj-B' } } ) } ) } } # Proj-B's endpoints: the SAME shared docker connection (should dedupe) + # one new GitHub connection that Proj-A doesn't see. Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/Proj-B/_apis/serviceendpoint/endpoints' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = 'ep-2'; name = 'Docker-Registry'; type = 'docker' url = 'https://docker.io'; description = 'Shared docker' isShared = $true; isReady = $true authorization = [pscustomobject]@{ scheme = 'UsernamePassword' } createdBy = [pscustomobject]@{ displayName = 'admin' } serviceEndpointProjectReferences = @( [pscustomobject]@{ projectReference = [pscustomobject]@{ name = 'Proj-A' } } [pscustomobject]@{ projectReference = [pscustomobject]@{ name = 'Proj-B' } } ) } [pscustomobject]@{ id = 'ep-3'; name = 'GitHub-OAuth'; type = 'github' url = 'https://github.com'; description = $null isShared = $false; isReady = $true authorization = [pscustomobject]@{ scheme = 'OAuth' } createdBy = [pscustomobject]@{ displayName = 'admin' } serviceEndpointProjectReferences = @( [pscustomobject]@{ projectReference = [pscustomobject]@{ name = 'Proj-B' } } ) } ) } } Get-MsecAdoServiceConnection -Organization 'contoso' } # 3 unique endpoints (Docker-Registry dedup'd to one row even though it # appeared in two projects' responses). $rows.Count | Should -Be 3 # AzureRM $azurerm = $rows | Where-Object Id -eq 'ep-1' $azurerm.Name | Should -Be 'AzureRM-Prod' $azurerm.Type | Should -Be 'azurerm' $azurerm.AuthScheme | Should -Be 'ServicePrincipal' $azurerm.IsShared | Should -BeFalse $azurerm.Projects | Should -Be @('Proj-A') # Raw retains everything Graph returned - including subscription id buried # in .data, which downstream callers can extract for auditing. $azurerm.Raw.data.subscriptionId | Should -Be 'sub-prod-guid' # Docker - shared, dedup'd, with both projects listed $docker = $rows | Where-Object Id -eq 'ep-2' $docker.IsShared | Should -BeTrue $docker.Projects | Should -Contain 'Proj-A' $docker.Projects | Should -Contain 'Proj-B' $docker.Projects.Count | Should -Be 2 # GitHub - only seen in Proj-B walk $gh = $rows | Where-Object Id -eq 'ep-3' $gh.Type | Should -Be 'github' $gh.AuthScheme | Should -Be 'OAuth' # PSTypeName tag set so the .psm1 DefaultDisplayPropertySet works. $azurerm.PSObject.TypeNames | Should -Contain 'MsecAdoServiceConnection' } It 'passes the bare ADO resource ID to Get-MsecAccessToken (no /.default suffix)' { # Regression guard: Get-MsecAccessToken appends /.default itself. Passing # '<resource>/.default' to it produces a malformed scope and Entra 400s. # This test fails if anyone re-introduces the bug by hardcoding the suffix. InModuleScope Msec { $script:CapturedResource = $null Mock Get-MsecAccessToken -MockWith { $script:CapturedResource = $Resource 'mock-token' } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/_apis/projects\?' } -MockWith { [pscustomobject]@{ value = @() } } Get-MsecAdoServiceConnection -Organization 'contoso' | Out-Null $script:CapturedResource | Should -Not -BeNullOrEmpty $script:CapturedResource | Should -Be '499b84ac-1321-427f-aa17-267ca6975798' $script:CapturedResource | Should -Not -Match '/\.default' } } It '-Project restricts to a single project (no /projects call needed)' { InModuleScope Msec { Mock Get-MsecAccessToken -MockWith { 'mock' } $script:ProjectsListCalls = 0 Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/_apis/projects\?' } -MockWith { $script:ProjectsListCalls++ [pscustomobject]@{ value = @() } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/MyProject/_apis/serviceendpoint/endpoints' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = 'ep-1'; name = 'X'; type = 'azurerm' authorization = [pscustomobject]@{ scheme = 'ServicePrincipal' } createdBy = [pscustomobject]@{ displayName = 'x' } serviceEndpointProjectReferences = @( [pscustomobject]@{ projectReference = [pscustomobject]@{ name = 'MyProject' } } ) } ) } } $r = Get-MsecAdoServiceConnection -Organization 'contoso' -Project 'MyProject' $r.Count | Should -Be 1 # The /_apis/projects list endpoint should NOT have been called. $script:ProjectsListCalls | Should -Be 0 } } It 'rewrites a 401/403 on the projects-list call to mention ADO org membership' { InModuleScope Msec { Mock Get-MsecAccessToken -MockWith { 'mock' } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/_apis/projects\?' } -MockWith { throw 'Response status code does not indicate success: 401 (Unauthorized).' } { Get-MsecAdoServiceConnection -Organization 'contoso' } | Should -Throw -ExpectedMessage '*added as a member of the ADO organization*' } } } |