tests/Get-MsecEntraAppConsent.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecEntraAppConsent. # # The behaviour worth pinning hardest is the one that made this command cost 30 seconds instead # of 6: app role assignments are read from the RESOURCE side, because expanding them on each # service principal silently truncates at one page. A test cannot catch Graph truncating, but it # can catch someone "optimising" the command back to $expand - the resource-side mock is the # only shape that satisfies these tests. # # After that: a permission that could not be resolved must be null rather than false, and a # resource that could not be read must produce a row rather than vanish. An app whose # permissions nobody could enumerate must never read as an app with none. BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Get-MsecEntraAppConsent' { BeforeEach { InModuleScope Msec { $script:MsecSession = @{ TenantId = 't'; ClientId = 'c'; Tokens = @{} } } } It 'splits a scope string into one row per permission' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { if ($Path -like '*servicePrincipals?$select*') { @( [pscustomobject]@{ id = 'client-1'; appId = 'app-1'; displayName = 'Contoso App'; appRoles = @() } [pscustomobject]@{ id = 'res-1'; appId = 'app-r'; displayName = 'Microsoft Graph'; appRoles = @() } ) } elseif ($Path -like '*oauth2PermissionGrants*') { @([pscustomobject]@{ id = 'g1'; clientId = 'client-1'; resourceId = 'res-1' consentType = 'AllPrincipals'; principalId = $null # Leading whitespace is how Graph actually returns these. scope = ' Mail.Read User.Read Files.Read.All' }) } else { @() } } Get-MsecEntraAppConsent -PermissionType Delegated } $rows.Count | Should -Be 3 @($rows.Permission) | Should -Contain 'Mail.Read' @($rows.Permission) | Should -Contain 'Files.Read.All' ($rows | Where-Object Permission -eq 'User.Read').ConsentType | Should -Be 'AllPrincipals' # Curated list: two of these three are sensitive, User.Read is not. @($rows | Where-Object IsHighRisk).Count | Should -Be 2 } It 'reads app role assignments from the resource, and ignores user and group assignments' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { if ($Path -like '*servicePrincipals?$select*') { @( [pscustomobject]@{ id = 'client-1'; appId = 'app-1'; displayName = 'Contoso App'; appRoles = @() } [pscustomobject]@{ id = 'res-1'; appId = 'app-r'; displayName = 'Microsoft Graph' appRoles = @([pscustomobject]@{ id = 'role-1'; value = 'Mail.Read' }) } ) } elseif ($Path -like '*appRoleAssignedTo*') { @( [pscustomobject]@{ id = 'a1'; appRoleId = 'role-1'; principalId = 'client-1' principalType = 'ServicePrincipal'; principalDisplayName = 'Contoso App' createdDateTime = '2026-01-01T00:00:00Z' } # Assigning an app role to a USER says who may use the app. Not consent. [pscustomobject]@{ id = 'a2'; appRoleId = 'role-1'; principalId = 'user-1' principalType = 'User'; principalDisplayName = 'Someone' } [pscustomobject]@{ id = 'a3'; appRoleId = 'role-1'; principalId = 'group-1' principalType = 'Group'; principalDisplayName = 'A group' } ) } else { @() } } Get-MsecEntraAppConsent -PermissionType Application } $rows.Count | Should -Be 1 $rows[0].Permission | Should -Be 'Mail.Read' $rows[0].PermissionType | Should -Be 'Application' $rows[0].ConsentType | Should -Be 'Application' $rows[0].ClientDisplayName | Should -Be 'Contoso App' $rows[0].IsHighRisk | Should -BeTrue } It 'only sweeps resources that publish app roles' { InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { if ($Path -like '*servicePrincipals?$select*') { @( [pscustomobject]@{ id = 'plain-1'; displayName = 'No roles'; appRoles = @() } [pscustomobject]@{ id = 'res-1'; displayName = 'Publisher' appRoles = @([pscustomobject]@{ id = 'role-1'; value = 'Mail.Read' }) } ) } else { @() } } Get-MsecEntraAppConsent -PermissionType Application | Out-Null # One call for the publisher, none for the principal that cannot be a resource. Should -Invoke Invoke-MsecGraphRequest -Times 1 -Exactly -ParameterFilter { $Path -like '*/servicePrincipals/res-1/appRoleAssignedTo*' } Should -Invoke Invoke-MsecGraphRequest -Times 0 -Exactly -ParameterFilter { $Path -like '*/servicePrincipals/plain-1/appRoleAssignedTo*' } } } It 'reports an unresolvable app role as null, not as a non-risky permission' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { if ($Path -like '*servicePrincipals?$select*') { @( [pscustomobject]@{ id = 'client-1'; displayName = 'Contoso App'; appRoles = @() } [pscustomobject]@{ id = 'res-1'; displayName = 'Microsoft Graph' appRoles = @([pscustomobject]@{ id = 'role-1'; value = 'Mail.Read' }) } ) } elseif ($Path -like '*appRoleAssignedTo*') { # A grant naming a role the resource no longer publishes - a real state. @([pscustomobject]@{ id = 'a1'; appRoleId = 'role-GONE'; principalId = 'client-1' principalType = 'ServicePrincipal'; principalDisplayName = 'Contoso App' }) } else { @() } } Get-MsecEntraAppConsent -PermissionType Application } $rows.Count | Should -Be 1 $rows[0].Permission | Should -BeNullOrEmpty # $false would assert it was checked against the list and found safe. $rows[0].IsHighRisk | Should -BeNullOrEmpty } It 'reports a resource whose assignments cannot be read instead of dropping it' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { if ($Path -like '*servicePrincipals?$select*') { @([pscustomobject]@{ id = 'res-1'; appId = 'app-r'; displayName = 'Locked API' appRoles = @([pscustomobject]@{ id = 'role-1'; value = 'Mail.Read' }) }) } elseif ($Path -like '*appRoleAssignedTo*') { throw 'Insufficient privileges' } else { @() } } Get-MsecEntraAppConsent -PermissionType Application -WarningAction SilentlyContinue } $rows.Count | Should -Be 1 $rows[0].ClientDisplayName | Should -Be 'Unreadable' $rows[0].ResourceDisplayName | Should -Be 'Locked API' $rows[0].IsHighRisk | Should -BeNullOrEmpty } It 'keeps an app of unknown ownership under -ThirdPartyOnly' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { if ($Path -like '*servicePrincipals?$select*') { @( [pscustomobject]@{ id = 'ms-1'; displayName = 'A Microsoft app'; appRoles = @() appOwnerOrganizationId = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a' } [pscustomobject]@{ id = 'unknown-1'; displayName = 'Unknown owner'; appRoles = @() } [pscustomobject]@{ id = 'res-1'; displayName = 'Microsoft Graph'; appRoles = @() } ) } elseif ($Path -like '*oauth2PermissionGrants*') { @( [pscustomobject]@{ id='g1'; clientId='ms-1'; resourceId='res-1'; consentType='AllPrincipals'; scope='Mail.Read' } [pscustomobject]@{ id='g2'; clientId='unknown-1'; resourceId='res-1'; consentType='AllPrincipals'; scope='Mail.Read' } ) } else { @() } } Get-MsecEntraAppConsent -PermissionType Delegated -ThirdPartyOnly } # An unresolved owner is not evidence of a Microsoft app, so it must survive the filter. $rows.Count | Should -Be 1 $rows[0].ClientDisplayName | Should -Be 'Unknown owner' $rows[0].ClientIsMicrosoft | Should -BeNullOrEmpty } It 'warns rather than returning silence when delegated grants cannot be read' { $warnings = @() $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { if ($Path -like '*servicePrincipals?$select*') { @([pscustomobject]@{ id = 'res-1'; displayName = 'x'; appRoles = @() }) } elseif ($Path -like '*oauth2PermissionGrants*') { throw 'Insufficient privileges to complete the operation.' } else { @() } } Get-MsecEntraAppConsent -PermissionType Delegated -WarningVariable w -WarningAction SilentlyContinue $script:capturedWarnings = $w } $captured = InModuleScope Msec { $script:capturedWarnings } # An empty result with no warning would read as a tenant with no delegated consent. $rows | Should -BeNullOrEmpty "$captured" | Should -Match 'not the same as a tenant with none' } } |