tests/Get-MsecEntraDisabledUser.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecEntraDisabledUser. # # The list of disabled users is the easy half. The half worth testing is "how long", because # Entra stores no disabledDateTime and the answer has three distinct shapes: # # * the disable event is inside the audit window -> exact DisabledSince / DisabledDays # * it has aged out -> a bracket, DisabledAtLeastDays and # DisabledAtMostDays (from last sign-in) # * the audit log itself is unreadable -> the user list still comes back, with # the timing columns blank and a warning # # Also covered: an account disabled, re-enabled and disabled again must report the LAST # disable, and a tenant without Entra ID P1 must degrade rather than fail. 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-MsecEntraDisabledUser' { BeforeEach { InModuleScope Msec -Parameters @{ Thumb = $script:TestThumbBytes } { param($Thumb) $script:MsecSession = @{ TenantId = 'tenant'; ClientId = 'client'; KeyVaultName = 'kv' KeyName = 'msec-app'; ThumbprintBytes = $Thumb; Tokens = @{} } } } It 'filters on accountEnabled eq false and projects the flat row' { $result = InModuleScope Msec { $captured = @{} Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { $captured['UserPath'] = $Path [pscustomobject]@{ id = 'u1'; displayName = 'Ex Employee'; userPrincipalName = 'ex@contoso.com' userType = 'Member'; accountEnabled = $false; department = 'Sales' jobTitle = 'Rep'; onPremisesSyncEnabled = $false createdDateTime = '2020-01-01T00:00:00Z' assignedLicenses = @([pscustomobject]@{ skuId = 'a' }, [pscustomobject]@{ skuId = 'b' }) } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { } [pscustomobject]@{ Rows = @(Get-MsecEntraDisabledUser); Captured = $captured } } [uri]::UnescapeDataString($result.Captured['UserPath']) | Should -BeLike '*accountEnabled eq false*' $row = $result.Rows[0] $row.UserPrincipalName | Should -Be 'ex@contoso.com' $row.Department | Should -Be 'Sales' # A disabled account still holding licences is the actionable finding. $row.LicenseCount | Should -Be 2 $row.PSObject.TypeNames | Should -Contain 'MsecEntraDisabledUser' } It 'reports an exact date when the disable event is inside the audit window' { $row = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { [pscustomobject]@{ id = 'u1'; userPrincipalName = 'ex@contoso.com'; accountEnabled = $false } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { [pscustomobject]@{ activityDateTime = ([DateTime]::UtcNow.AddDays(-5)).ToString('o') initiatedBy = [pscustomobject]@{ user = [pscustomobject]@{ userPrincipalName = 'admin@contoso.com' } } targetResources = @( [pscustomobject]@{ id = 'u1' # Graph sends these as JSON-ish text, not booleans. modifiedProperties = @([pscustomobject]@{ displayName = 'AccountEnabled'; oldValue = '[true]'; newValue = '[false]' }) } ) } } @(Get-MsecEntraDisabledUser)[0] } $row.DisabledDays | Should -Be 5 $row.DisabledBy | Should -Be 'admin@contoso.com' $row.DisabledSource | Should -Be 'AuditLog' $row.DisabledSince | Should -Not -BeNullOrEmpty # Exact answer, so no bracket. $row.DisabledAtLeastDays | Should -BeNullOrEmpty $row.DisabledAtMostDays | Should -BeNullOrEmpty } It 'brackets the answer when the event has aged out of the log' { $row = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { [pscustomobject]@{ id = 'u1'; userPrincipalName = 'old@contoso.com'; accountEnabled = $false signInActivity = [pscustomobject]@{ lastSuccessfulSignInDateTime = ([DateTime]::UtcNow.AddDays(-400)).ToString('o') } } } # Nothing in the window. Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { } @(Get-MsecEntraDisabledUser -Days 30)[0] } $row.DisabledSince | Should -BeNullOrEmpty $row.DisabledDays | Should -BeNullOrEmpty # Nothing found in 30 days, so it happened before that... $row.DisabledAtLeastDays | Should -Be 30 # ...and it cannot predate the last successful sign-in, because a disabled account # cannot sign in. $row.DisabledAtMostDays | Should -Be 400 $row.DisabledSource | Should -BeLike '*Beyond audit retention*' } It 'reports three distinct last-touched signals, since Graph has no lastModifiedDateTime' { $row = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { [pscustomobject]@{ id = 'u1'; userPrincipalName = 'ex@contoso.com'; accountEnabled = $false lastPasswordChangeDateTime = '2024-03-01T09:00:00Z' onPremisesLastSyncDateTime = '2026-08-20T02:00:00Z' onPremisesSyncEnabled = $true } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { # A department change - NOT an AccountEnabled change - so it must count as a # directory update while leaving the disable columns alone. [pscustomobject]@{ activityDateTime = ([DateTime]::UtcNow.AddDays(-2)).ToString('o') targetResources = @([pscustomobject]@{ id = 'u1' modifiedProperties = @( [pscustomobject]@{ displayName = 'Department'; newValue = '"Ops"' } [pscustomobject]@{ displayName = 'JobTitle'; newValue = '"Lead"' } ) }) } } @(Get-MsecEntraDisabledUser)[0] } $row.LastDirectoryChange | Should -Not -BeNullOrEmpty $row.LastDirectoryChangeWhat | Should -Be 'Department, JobTitle' $row.LastPasswordChange | Should -Be ([datetime]'2024-03-01T09:00:00Z') $row.OnPremisesLastSync | Should -Be ([datetime]'2026-08-20T02:00:00Z') # A non-AccountEnabled edit is an update, not a disable. $row.DisabledSince | Should -BeNullOrEmpty } It 'leaves LastDirectoryChange null when nothing touched the object in the window' { $row = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { [pscustomobject]@{ id = 'u1'; userPrincipalName = 'old@contoso.com'; accountEnabled = $false lastPasswordChangeDateTime = '2019-01-01T00:00:00Z' } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { } @(Get-MsecEntraDisabledUser)[0] } # Null means "not in the audit window", not "never" - which is why the unbounded # LastPasswordChange is carried alongside it. $row.LastDirectoryChange | Should -BeNullOrEmpty $row.LastPasswordChange | Should -Be ([datetime]'2019-01-01T00:00:00Z') } It 'bounds only on a SUCCESSFUL sign-in, never on a failed attempt' { $row = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { [pscustomobject]@{ id = 'u1'; userPrincipalName = 'sprayed@contoso.com'; accountEnabled = $false signInActivity = [pscustomobject]@{ # Somebody tried the account last week - an ex-employee, a stale # client, a password spray. It is disabled, so it did not work. lastSignInDateTime = ([DateTime]::UtcNow.AddDays(-7)).ToString('o') # The last time it actually worked was three years ago. lastSuccessfulSignInDateTime = ([DateTime]::UtcNow.AddDays(-1100)).ToString('o') } } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { } @(Get-MsecEntraDisabledUser -Days 30)[0] } # Bounding on the attempt would claim "disabled at most 7 days" for an account that # has been off for years - wrong, and confidently so. $row.DisabledAtMostDays | Should -Be 1100 $row.DisabledAtLeastDays | Should -Be 30 # Both timestamps are still surfaced; only the BOUND is restricted. $row.LastInteractiveSignIn | Should -Not -BeNullOrEmpty $row.LastSignIn | Should -Be $row.LastInteractiveSignIn # newest of any kind } It 'leaves the upper bound blank when only an unsuccessful sign-in is recorded' { $row = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { [pscustomobject]@{ id = 'u1'; userPrincipalName = 'ex@contoso.com'; accountEnabled = $false signInActivity = [pscustomobject]@{ lastSignInDateTime = ([DateTime]::UtcNow.AddDays(-9)).ToString('o') } } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { } @(Get-MsecEntraDisabledUser)[0] } # No proof the account ever worked, so no bound - blank rather than guessed. $row.DisabledAtMostDays | Should -BeNullOrEmpty $row.DisabledAtLeastDays | Should -Be 30 } It 'surfaces non-interactive sign-in, which is how a service account looks alive' { $row = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { [pscustomobject]@{ id = 'u1'; userPrincipalName = 'svc@contoso.com'; accountEnabled = $false signInActivity = [pscustomobject]@{ lastSignInDateTime = ([DateTime]::UtcNow.AddDays(-900)).ToString('o') lastNonInteractiveSignInDateTime = ([DateTime]::UtcNow.AddDays(-3)).ToString('o') } } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { } @(Get-MsecEntraDisabledUser)[0] } # Interactively it looks dead for years; non-interactively it was busy three days ago. $row.LastNonInteractiveSignIn | Should -Not -BeNullOrEmpty $row.LastSignIn | Should -Be $row.LastNonInteractiveSignIn } It 'reports the MOST RECENT disable when an account was toggled off, on, and off again' { $row = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { [pscustomobject]@{ id = 'u1'; userPrincipalName = 'toggled@contoso.com'; accountEnabled = $false } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { foreach ($daysAgo in 20, 3, 12) { [pscustomobject]@{ activityDateTime = ([DateTime]::UtcNow.AddDays(-$daysAgo)).ToString('o') initiatedBy = [pscustomobject]@{ user = [pscustomobject]@{ userPrincipalName = "admin$daysAgo@contoso.com" } } targetResources = @([pscustomobject]@{ id = 'u1' modifiedProperties = @([pscustomobject]@{ displayName = 'AccountEnabled'; newValue = '[false]' }) }) } } } @(Get-MsecEntraDisabledUser)[0] } # The disable currently in force is the newest one, not the first seen in the page. $row.DisabledDays | Should -Be 3 $row.DisabledBy | Should -Be 'admin3@contoso.com' } It 'ignores an Update user event that changed something other than AccountEnabled' { $row = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { [pscustomobject]@{ id = 'u1'; userPrincipalName = 'ex@contoso.com'; accountEnabled = $false } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { [pscustomobject]@{ activityDateTime = ([DateTime]::UtcNow.AddDays(-2)).ToString('o') targetResources = @([pscustomobject]@{ id = 'u1' modifiedProperties = @([pscustomobject]@{ displayName = 'Department'; newValue = '"Ops"' }) }) } # ...and an AccountEnabled event that turned the account ON, which is not a # disable and must not be read as one. [pscustomobject]@{ activityDateTime = ([DateTime]::UtcNow.AddDays(-1)).ToString('o') targetResources = @([pscustomobject]@{ id = 'u1' modifiedProperties = @([pscustomobject]@{ displayName = 'AccountEnabled'; oldValue = '[false]'; newValue = '[true]' }) }) } } @(Get-MsecEntraDisabledUser)[0] } $row.DisabledSince | Should -BeNullOrEmpty $row.DisabledSource | Should -BeLike '*Beyond audit retention*' } It 'still returns the user list when the audit log cannot be read' { $result = InModuleScope Msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { [pscustomobject]@{ id = 'u1'; userPrincipalName = 'ex@contoso.com'; accountEnabled = $false } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } $rows = @(Get-MsecEntraDisabledUser -WarningVariable w -WarningAction SilentlyContinue) [pscustomobject]@{ Rows = $rows; Warnings = $w } } # The list is the point; the dates are the bonus. $result.Rows.Count | Should -Be 1 $result.Rows[0].UserPrincipalName | Should -Be 'ex@contoso.com' $result.Rows[0].DisabledSince | Should -BeNullOrEmpty ($result.Warnings -join ' ') | Should -Match 'audit log' } It 'retries without signInActivity when the tenant has no premium licence' { $result = InModuleScope Msec { $paths = [System.Collections.Generic.List[string]]::new() Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { $paths.Add($Path) if ($Path -match 'signInActivity') { throw 'Neither tenant is B2C or tenant doesn''t have premium license' } [pscustomobject]@{ id = 'u1'; userPrincipalName = 'ex@contoso.com'; accountEnabled = $false } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { } $rows = @(Get-MsecEntraDisabledUser -WarningVariable w -WarningAction SilentlyContinue) [pscustomobject]@{ Rows = $rows; Paths = $paths; Warnings = $w } } @($result.Paths).Count | Should -Be 2 # premium attempt, then without $result.Paths[0] | Should -BeLike '*signInActivity*' $result.Paths[1] | Should -Not -BeLike '*signInActivity*' $result.Rows.Count | Should -Be 1 # and the list still arrives ($result.Warnings -join ' ') | Should -Match 'signInActivity' } It 'scopes to guests when asked' { $path = InModuleScope Msec { $captured = @{} Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*/users*' } -MockWith { $captured['Path'] = $Path [pscustomobject]@{ id = 'g1'; userPrincipalName = 'guest@partner.com'; accountEnabled = $false; userType = 'Guest' } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -like '*directoryAudits*' } -MockWith { } Get-MsecEntraDisabledUser -UserType Guest | Out-Null $captured['Path'] } [uri]::UnescapeDataString($path) | Should -BeLike "*userType eq 'Guest'*" } It 'throws a clear error when not connected' { InModuleScope Msec { $script:MsecSession = $null { Get-MsecEntraDisabledUser } | Should -Throw '*Connect-Msec*' } } } |