tests/Get-MsecEntraGuest.Tests.ps1
|
BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psd1') -Force # Defined here, not in the Describe body: a variable set at discovery time is gone by the # time an It runs, which reads as the command returning nothing rather than as a test bug. # # The shapes that matter are the sign-in ones. 'active-nonint' is the account that an # interactive-only reading calls dormant while it signs in every day. $script:Guests = @( @{ id = 'g1'; displayName = 'Pending Pat'; mail = 'pat@partner.com' userPrincipalName = 'pat_partner.com#EXT#@t.onmicrosoft.com' accountEnabled = $true; createdDateTime = '2024-01-01T00:00:00Z' externalUserState = 'PendingAcceptance'; creationType = 'Invitation' } @{ id = 'g2'; displayName = 'Active Alex'; mail = 'alex@partner.com' userPrincipalName = 'alex_partner.com#EXT#@t.onmicrosoft.com' accountEnabled = $true; createdDateTime = '2024-01-01T00:00:00Z' externalUserState = 'Accepted'; creationType = 'Invitation' signInActivity = @{ lastSignInDateTime = (Get-Date).AddDays(-2).ToString('o') } } @{ id = 'g3'; displayName = 'Nonint Nina'; mail = 'nina@other.com' userPrincipalName = 'nina_other.com#EXT#@t.onmicrosoft.com' accountEnabled = $true; createdDateTime = '2024-01-01T00:00:00Z' externalUserState = 'Accepted'; creationType = 'Invitation' # Interactive EMPTY, non-interactive yesterday - in daily use. signInActivity = @{ lastSignInDateTime = $null lastNonInteractiveSignInDateTime = (Get-Date).AddDays(-1).ToString('o') } } @{ id = 'g4'; displayName = 'Stale Sam'; mail = 'sam@other.com' userPrincipalName = 'sam_other.com#EXT#@t.onmicrosoft.com' accountEnabled = $false; createdDateTime = '2023-01-01T00:00:00Z' externalUserState = 'Accepted'; creationType = 'Invitation' signInActivity = @{ lastSignInDateTime = (Get-Date).AddDays(-400).ToString('o') } } ) } Describe 'Get-MsecEntraGuest' { BeforeEach { InModuleScope msec { Mock Assert-MsecSession {} } } It 'counts a non-interactive sign-in as a sign-in' { # THE DEFECT THIS COMMAND EXISTS TO AVOID. lastSignInDateTime is interactive only, so # an account used daily through refresh tokens leaves it empty. Reading that field # alone puts a live account on the deletion list. InModuleScope msec -Parameters @{ G = $script:Guests } { param($G) Mock Invoke-MsecGraphRequest { return $G } $nina = Get-MsecEntraGuest | Where-Object Mail -eq 'nina@other.com' $nina.LastSignInUtc | Should -Not -BeNullOrEmpty $nina.LastSignInKind | Should -Be 'NonInteractive' (Get-MsecEntraGuest -NeverSignedIn).Mail | Should -Not -Contain 'nina@other.com' } } It 'reports the latest of the three timestamps, not whichever is present' { InModuleScope msec { $newest = (Get-Date).AddDays(-1) Mock Invoke-MsecGraphRequest { @(@{ id = 'x'; mail = 'x@y.com'; externalUserState = 'Accepted'; accountEnabled = $true signInActivity = @{ lastSignInDateTime = (Get-Date).AddDays(-30).ToString('o') lastNonInteractiveSignInDateTime = (Get-Date).AddDays(-1).ToString('o') lastSuccessfulSignInDateTime = (Get-Date).AddDays(-10).ToString('o') } }) } $row = Get-MsecEntraGuest [int]($newest - $row.LastSignInUtc).TotalDays | Should -Be 0 $row.LastSignInKind | Should -Be 'NonInteractive' } } It 'warns and flags the rows when NO guest carries sign-in data' { # Graph omits signInActivity silently, without error, when AuditLog.Read.All is # missing. Every guest then looks never-used - a list that reads as safe to delete and # is nothing of the kind. It must be impossible to mistake for a measurement. InModuleScope msec { Mock Invoke-MsecGraphRequest { @(@{ id = 'a'; mail = 'a@b.com'; externalUserState = 'Accepted'; accountEnabled = $true } @{ id = 'b'; mail = 'b@b.com'; externalUserState = 'Accepted'; accountEnabled = $true }) } $warnings = @() $rows = Get-MsecEntraGuest -WarningVariable warnings -WarningAction SilentlyContinue ($warnings -join ' ') | Should -Match 'AuditLog.Read.All' ($warnings -join ' ') | Should -Match 'NOT evidence' $rows.SignInDataAvailable | Should -Not -Contain $true } } It 'does not warn when at least one guest has sign-in data' { # A genuinely-never-signed-in guest alongside an active one is a measurement, not a # permission problem, and warning there would train people to ignore the warning. InModuleScope msec -Parameters @{ G = $script:Guests } { param($G) Mock Invoke-MsecGraphRequest { return $G } $warnings = @() $rows = Get-MsecEntraGuest -WarningVariable warnings -WarningAction SilentlyContinue ($warnings -join ' ') | Should -Not -Match 'AuditLog.Read.All' $rows.SignInDataAvailable | Should -Not -Contain $false } } It '-PendingOnly returns the unredeemed invitations' { InModuleScope msec -Parameters @{ G = $script:Guests } { param($G) Mock Invoke-MsecGraphRequest { return $G } (Get-MsecEntraGuest -PendingOnly).Mail | Should -Be @('pat@partner.com') } } It '-InactiveDays excludes guests that never signed in at all' { # Never-signed-in and inactive-for-N-days are different findings. Merging them makes a # row ambiguous about which it is, and -NeverSignedIn already answers the other one. InModuleScope msec -Parameters @{ G = $script:Guests } { param($G) Mock Invoke-MsecGraphRequest { return $G } $stale = Get-MsecEntraGuest -InactiveDays 180 $stale.Mail | Should -Be @('sam@other.com') $stale.Mail | Should -Not -Contain 'pat@partner.com' } } It '-NeverSignedIn returns only accounts with no signal of any kind' { InModuleScope msec -Parameters @{ G = $script:Guests } { param($G) Mock Invoke-MsecGraphRequest { return $G } (Get-MsecEntraGuest -NeverSignedIn).Mail | Should -Be @('pat@partner.com') } } It 'takes the domain from mail rather than the mangled guest UPN' { # A guest UPN is alex_partner.com#EXT#@tenant.onmicrosoft.com - splitting it on @ gives # the HOST tenant, which would group every guest under one domain. InModuleScope msec -Parameters @{ G = $script:Guests } { param($G) Mock Invoke-MsecGraphRequest { return $G } $rows = Get-MsecEntraGuest $rows.Domain | Should -Not -Contain 't.onmicrosoft.com' ($rows | Where-Object Mail -eq 'alex@partner.com').Domain | Should -Be 'partner.com' } } It '-Domain filters on the guest home domain' { InModuleScope msec -Parameters @{ G = $script:Guests } { param($G) Mock Invoke-MsecGraphRequest { return $G } (Get-MsecEntraGuest -Domain 'other.com').Mail | Should -Be @('nina@other.com', 'sam@other.com') } } It 'falls back to the UPN when a guest has no mail address' { InModuleScope msec { Mock Invoke-MsecGraphRequest { @(@{ id = 'n'; mail = $null userPrincipalName = 'nomail_partner.com#EXT#@t.onmicrosoft.com' externalUserState = 'Accepted'; accountEnabled = $true }) } (Get-MsecEntraGuest).Mail | Should -Be 'nomail_partner.com#EXT#@t.onmicrosoft.com' } } It 'names the missing permission when Graph refuses' { InModuleScope msec { Mock Invoke-MsecGraphRequest { throw 'Response status code does not indicate success: 403 (Forbidden).' } { Get-MsecEntraGuest } | Should -Throw '*User.Read.All*' } } It 'emits the documented type so the table view applies' { InModuleScope msec -Parameters @{ G = $script:Guests } { param($G) Mock Invoke-MsecGraphRequest { return $G } (Get-MsecEntraGuest)[0].PSObject.TypeNames | Should -Contain 'MsecEntraGuest' } } } |