tests/Get-MsecEntraAppCredential.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecEntraAppCredential. # # The behaviour worth pinning down is where the obvious implementation is wrong: # # * one row per CREDENTIAL, not per app - rolling up per app hides the secret that # lapses on Friday behind the two that are good for a year # * an already-expired credential survives ANY -ExpiringWithinDays window, because # expired is strictly worse than expiring and a window that dropped it would report # the opposite of the truth # * IsExpired comes from the timestamps, NOT from the floored day count - a credential # with nine hours left floors to 0, and 0 must not read as expired # * an app with no credentials is reported (usually the good state, e.g. federated # credentials) but a service principal with none is not - a tenant holds hundreds of # Microsoft-owned ones and they would bury everything else 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-MsecEntraAppCredential' { 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 'emits one row per credential, splitting secrets from certificates' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/applications' } -MockWith { $now = [DateTime]::UtcNow [pscustomobject]@{ id = 'obj-1'; appId = 'app-1'; displayName = 'Billing sync' createdDateTime = '2024-01-01T00:00:00Z'; signInAudience = 'AzureADMyOrg' passwordCredentials = @( [pscustomobject]@{ keyId = 'k1'; displayName = 'rotate-me'; startDateTime = $now.AddDays(-100).ToString('o'); endDateTime = $now.AddDays(10).ToString('o') } [pscustomobject]@{ keyId = 'k2'; displayName = 'spare'; startDateTime = $now.AddDays(-10).ToString('o'); endDateTime = $now.AddDays(300).ToString('o') } ) keyCredentials = @( [pscustomobject]@{ keyId = 'k3'; displayName = 'signing'; usage = 'Sign'; startDateTime = $now.AddDays(-5).ToString('o'); endDateTime = $now.AddDays(400).ToString('o') } ) } } Get-MsecEntraAppCredential } # Three credentials on one app is three rows. One row per app would have to pick # a single expiry, and any pick is wrong for the other two. @($rows).Count | Should -Be 3 @($rows | Where-Object CredentialType -eq 'Secret').Count | Should -Be 2 @($rows | Where-Object CredentialType -eq 'Certificate').Count | Should -Be 1 # App-level fields ride along on every row, so a row stands alone. @($rows | Where-Object DisplayName -eq 'Billing sync').Count | Should -Be 3 ($rows | Where-Object KeyId -eq 'k1').AppId | Should -Be 'app-1' ($rows | Where-Object KeyId -eq 'k1').SignInAudience | Should -Be 'AzureADMyOrg' # Usage tells a SAML signing cert from one used to authenticate as the app, and # is meaningless on a secret rather than blank-by-accident. ($rows | Where-Object KeyId -eq 'k3').CredentialUsage | Should -Be 'Sign' ($rows | Where-Object KeyId -eq 'k1').CredentialUsage | Should -BeNullOrEmpty # The security half, not the outage half: a 110-day secret next to a 405-day cert. ($rows | Where-Object KeyId -eq 'k1').LifetimeDays | Should -Be 110 ($rows | Where-Object KeyId -eq 'k3').LifetimeDays | Should -Be 405 } It 'does not call a credential with hours left expired' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/applications' } -MockWith { $now = [DateTime]::UtcNow [pscustomobject]@{ id = 'obj-1'; appId = 'app-1'; displayName = 'Edge case' passwordCredentials = @( # Nine hours left. Floors to 0 - and 0 must not read as expired. [pscustomobject]@{ keyId = 'soon'; startDateTime = $now.AddDays(-30).ToString('o'); endDateTime = $now.AddHours(9).ToString('o') } # Two hours GONE. Floors to -1, and is genuinely expired. [pscustomobject]@{ keyId = 'lapsed'; startDateTime = $now.AddDays(-30).ToString('o'); endDateTime = $now.AddHours(-2).ToString('o') } ) keyCredentials = @() } } Get-MsecEntraAppCredential } $soon = $rows | Where-Object KeyId -eq 'soon' $soon.DaysUntilExpiry | Should -Be 0 $soon.IsExpired | Should -BeFalse # the whole point of this test $lapsed = $rows | Where-Object KeyId -eq 'lapsed' $lapsed.IsExpired | Should -BeTrue $lapsed.DaysUntilExpiry | Should -BeLessThan 0 } It 'keeps expired credentials inside any -ExpiringWithinDays window' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/applications' } -MockWith { $now = [DateTime]::UtcNow [pscustomobject]@{ id = 'obj-1'; appId = 'app-1'; displayName = 'Windowed' passwordCredentials = @( [pscustomobject]@{ keyId = 'long-dead'; startDateTime = $now.AddDays(-800).ToString('o'); endDateTime = $now.AddDays(-400).ToString('o') } [pscustomobject]@{ keyId = 'due-soon'; startDateTime = $now.AddDays(-100).ToString('o'); endDateTime = $now.AddDays(20).ToString('o') } [pscustomobject]@{ keyId = 'healthy'; startDateTime = $now.AddDays(-10).ToString('o'); endDateTime = $now.AddDays(300).ToString('o') } ) keyCredentials = @() } } Get-MsecEntraAppCredential -ExpiringWithinDays 30 } # A credential that lapsed 400 days ago is not less urgent than one lapsing next # week. Filtering it out on the grounds that -400 is outside a 30-day window would # report the opposite of the truth. @($rows | ForEach-Object KeyId) | Should -Contain 'long-dead' @($rows | ForEach-Object KeyId) | Should -Contain 'due-soon' @($rows | ForEach-Object KeyId) | Should -Not -Contain 'healthy' } It 'reports an app with no credentials, because that is usually the good state' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/applications' } -MockWith { [pscustomobject]@{ id = 'obj-fed'; appId = 'app-fed'; displayName = 'Workload identity' passwordCredentials = @(); keyCredentials = @() } } Get-MsecEntraAppCredential } @($rows).Count | Should -Be 1 $rows[0].CredentialType | Should -Be 'None' # Not 0 and not $false - there is no expiry to report, and a zero would sort into # the "expires today" rows and read as an emergency. $rows[0].DaysUntilExpiry | Should -BeNullOrEmpty $rows[0].IsExpired | Should -BeNullOrEmpty $rows[0].EndDateTime | Should -BeNullOrEmpty # ...but it has no expiry, so a window is not about it. $windowed = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/applications' } -MockWith { [pscustomobject]@{ id = 'obj-fed'; appId = 'app-fed'; displayName = 'Workload identity' passwordCredentials = @(); keyCredentials = @() } } Get-MsecEntraAppCredential -ExpiringWithinDays 90 } @($windowed).Count | Should -Be 0 } It 'skips service principals holding no credentials, and only when asked at all' { $default = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/applications' } -MockWith { [pscustomobject]@{ id = 'obj-1'; appId = 'app-1'; displayName = 'App' passwordCredentials = @([pscustomobject]@{ keyId = 'k1'; endDateTime = [DateTime]::UtcNow.AddDays(5).ToString('o') }) keyCredentials = @() } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/servicePrincipals' } -MockWith { throw 'servicePrincipals must not be called without -IncludeServicePrincipal' } Get-MsecEntraAppCredential } @($default).Count | Should -Be 1 $withSp = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/applications' } -MockWith { [pscustomobject]@{ id = 'obj-1'; appId = 'app-1'; displayName = 'App' passwordCredentials = @([pscustomobject]@{ keyId = 'k1'; endDateTime = [DateTime]::UtcNow.AddDays(5).ToString('o') }) keyCredentials = @() } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/servicePrincipals' } -MockWith { # The SAML app whose signing cert is a sign-in outage for every user of it. [pscustomobject]@{ id = 'sp-1'; appId = 'app-saml'; displayName = 'HR portal' passwordCredentials = @() keyCredentials = @([pscustomobject]@{ keyId = 'sign-1'; usage = 'Sign'; endDateTime = [DateTime]::UtcNow.AddDays(45).ToString('o') }) } # Three of the hundreds Microsoft owns. Reporting these would bury the one above. [pscustomobject]@{ id = 'sp-2'; appId = 'ms-1'; displayName = 'Microsoft Graph'; passwordCredentials = @(); keyCredentials = @() } [pscustomobject]@{ id = 'sp-3'; appId = 'ms-2'; displayName = 'Office 365 Exchange'; passwordCredentials = @(); keyCredentials = @() } [pscustomobject]@{ id = 'sp-4'; appId = 'ms-3'; displayName = 'Windows Azure Service'; passwordCredentials = @(); keyCredentials = @() } } Get-MsecEntraAppCredential -IncludeServicePrincipal } @($withSp).Count | Should -Be 2 # the app registration and the SAML cert, not the three empties $saml = $withSp | Where-Object ObjectType -eq 'ServicePrincipal' $saml.DisplayName | Should -Be 'HR portal' $saml.CredentialUsage | Should -Be 'Sign' # signInAudience belongs to the registration, not the tenant-local object, so it is # left out of the $select rather than asked for and returned null. $saml.SignInAudience | Should -BeNullOrEmpty } It 'tells a managed identity from an app registration' { # A managed identity's credential is rotated by Azure and is not yours to renew; # listing it beside secrets that need a human is noise. An app registration's # certificate IS yours, and missing it is an outage. Conflating them makes an expiry # report read wrongly in both directions. $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/applications' } -MockWith { [pscustomobject]@{ id = 'a1'; appId = 'app-1'; displayName = 'Billing sync' passwordCredentials = @([pscustomobject]@{ keyId = 'k1'; endDateTime = [DateTime]::UtcNow.AddDays(30).ToString('o') }) keyCredentials = @() } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/servicePrincipals' } -MockWith { # Graph answers directly where it can. [pscustomobject]@{ id = 'sp1'; appId = 'mi-1'; displayName = 'aks-kubelet' servicePrincipalType = 'ManagedIdentity' passwordCredentials = @() keyCredentials = @([pscustomobject]@{ keyId = 'k2'; endDateTime = [DateTime]::UtcNow.AddDays(60).ToString('o') }) } # No servicePrincipalType: the App Service tag is the fallback, which is how # the Az-based inventories told them apart before that property existed. [pscustomobject]@{ id = 'sp2'; appId = 'mi-2'; displayName = 'web-app-identity' tags = @('AppServiceIntegratedApp') passwordCredentials = @() keyCredentials = @([pscustomobject]@{ keyId = 'k3'; endDateTime = [DateTime]::UtcNow.AddDays(90).ToString('o') }) } # A real SAML app: neither, so it must not be guessed at. [pscustomobject]@{ id = 'sp3'; appId = 'saml-1'; displayName = 'HR portal' passwordCredentials = @() keyCredentials = @([pscustomobject]@{ keyId = 'k4'; usage = 'Sign'; endDateTime = [DateTime]::UtcNow.AddDays(45).ToString('o') }) } } Get-MsecEntraAppCredential -IncludeServicePrincipal } ($rows | Where-Object DisplayName -eq 'Billing sync').PrincipalType | Should -Be 'Application' ($rows | Where-Object DisplayName -eq 'aks-kubelet').PrincipalType | Should -Be 'ManagedIdentity' ($rows | Where-Object DisplayName -eq 'web-app-identity').PrincipalType | Should -Be 'ManagedIdentity' # Not invented as 'Application' - an unknown kind is unknown. ($rows | Where-Object DisplayName -eq 'HR portal').PrincipalType | Should -Be 'Unknown' # The rotation list a human actually owns. @($rows | Where-Object PrincipalType -eq 'Application').Count | Should -Be 1 } It 'names the missing permission on a 403 rather than passing the raw error up' { InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } { Get-MsecEntraAppCredential } | Should -Throw '*Application.Read.All*' } } It 'throws a clear error when not connected' { InModuleScope Msec { $script:MsecSession = $null { Get-MsecEntraAppCredential } | Should -Throw '*Connect-Msec*' } } } |