tests/Get-MsecPurviewActivity.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecPurviewActivity. # # Export-ActivityExplorerData has three ways of returning nothing without erroring, and every # one of them was hit while this command was being written. The tests exist to keep each from # reading as "there was no activity": # # A WINDOW OF 30 DAYS RETURNS A WHOLLY EMPTY RESPONSE - no rows, no total, no result code and # no error. 29 days returns the lot. Measured: 29d = 143,952 events, 30d = silence. # # THE FILTER TAKES THE ActivityId TOKEN. 'DLPRuleMatch' matches; 'DLP rule matched' - what the # portal and the Activity column both display - returns an empty result rather than an error. # # ONE CALL IS ONE PAGE, NOT THE RESULT SET. Summarising page one of a 29,000-row week gives a # confident answer drawn from 17% of it. # # Plus two shape traps: PolicyName lives inside PolicyMatchInfo, so grouping by it on the raw # output buckets everything under one blank key; and SensitivityLabel is a bare GUID. BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop # Compliance cmdlets do not exist without a live session; stubbed so Pester can mock them. function global:Export-ActivityExplorerData { [CmdletBinding()] param($StartTime, $EndTime, $OutputFormat, $PageSize, $PageCookie, $Filter1) } function global:Get-Label { } } AfterAll { Remove-Module msec -Force -ErrorAction SilentlyContinue Remove-Item 'function:global:Export-ActivityExplorerData' -ErrorAction SilentlyContinue Remove-Item 'function:global:Get-Label' -ErrorAction SilentlyContinue } Describe 'Get-MsecPurviewActivity' { It 'refuses a window of 30 days or more, where the API returns silence rather than an error' { # 30 and 31 are refused by ValidateRange before any call is made - the API would answer # them with silence, which is worse than an error. { Get-MsecPurviewActivity -Days 30 } | Should -Throw { Get-MsecPurviewActivity -Days 31 } | Should -Throw # 29 is the measured ceiling and must be accepted. InModuleScope msec { Mock Get-Label -MockWith { @() } Mock Export-ActivityExplorerData -MockWith { [pscustomobject]@{ ResultCode = 'Success'; TotalResultCount = 0 ResultData = $null; WaterMark = $null; LastPage = $true } } { Get-MsecPurviewActivity -Days 29 } | Should -Not -Throw } } It 'throws on the empty-response failure mode instead of returning no rows' { InModuleScope msec { # Exactly what a 30-day window hands back: an object with every field unset. Row # count alone cannot tell this from a quiet window, so ResultCode is what is tested. Mock Export-ActivityExplorerData -MockWith { [pscustomobject]@{ ResultCode = $null; TotalResultCount = $null; ResultData = $null WaterMark = $null; LastPage = $null } } Mock Get-Label -MockWith { @() } { Get-MsecPurviewActivity -Days 7 } | Should -Throw '*empty response*' } } It 'refuses the displayed activity name and names the token to use instead' { # The expensive one: passing this to the API returns an empty result, not an error. { Get-MsecPurviewActivity -Activity 'DLP rule matched' } | Should -Throw "*Use 'DLPRuleMatch' instead*" { Get-MsecPurviewActivity -Activity 'Label applied' } | Should -Throw "*Use 'LabelApplied' instead*" } It 'pages to the end rather than returning the first page' { $rows = InModuleScope msec { $script:Call = 0 Mock Get-Label -MockWith { @() } Mock Export-ActivityExplorerData -MockWith { $script:Call++ switch ($script:Call) { 1 { [pscustomobject]@{ ResultCode = 'Success'; TotalResultCount = 3 ResultData = (@([pscustomobject]@{ ActivityId = 'FileRead'; Happened = '2026-10-08T10:00:00Z' }) | ConvertTo-Json -AsArray) WaterMark = 'wm-1'; LastPage = $false } } 2 { [pscustomobject]@{ ResultCode = 'Success'; TotalResultCount = 3 ResultData = (@([pscustomobject]@{ ActivityId = 'FileRead'; Happened = '2026-10-08T11:00:00Z' }) | ConvertTo-Json -AsArray) WaterMark = 'wm-2'; LastPage = $false } } default { [pscustomobject]@{ ResultCode = 'Success'; TotalResultCount = 3 ResultData = (@([pscustomobject]@{ ActivityId = 'FileRead'; Happened = '2026-10-08T12:00:00Z' }) | ConvertTo-Json -AsArray) WaterMark = $null; LastPage = $true } } } } @(Get-MsecPurviewActivity -Days 7 -WarningAction SilentlyContinue) } # Three pages, one row each. Stopping at page one would give 1 and look complete. $rows.Count | Should -Be 3 Should -Invoke Export-ActivityExplorerData -ModuleName msec -Times 3 -Exactly } It 'warns when the pull falls materially short of the API total' { $w = InModuleScope msec { Mock Get-Label -MockWith { @() } Mock Export-ActivityExplorerData -MockWith { [pscustomobject]@{ ResultCode = 'Success'; TotalResultCount = 29000 ResultData = (@([pscustomobject]@{ ActivityId = 'FileRead' }) | ConvertTo-Json -AsArray) WaterMark = $null; LastPage = $true } } Get-MsecPurviewActivity -Days 7 -WarningVariable wv -WarningAction SilentlyContinue | Out-Null $wv } ($w | Where-Object { "$_" -match 'sample' }) | Should -Not -BeNullOrEmpty ($w | Where-Object { "$_" -match '29000' }) | Should -Not -BeNullOrEmpty } It 'does not warn when a small shortfall reflects events landing mid-pull' { $w = InModuleScope msec { Mock Get-Label -MockWith { @() } Mock Export-ActivityExplorerData -MockWith { # 2 short of 100 - ordinary drift, not a truncated pull. [pscustomobject]@{ ResultCode = 'Success'; TotalResultCount = 100 ResultData = ((1..98 | ForEach-Object { [pscustomobject]@{ ActivityId = 'FileRead' } }) | ConvertTo-Json -AsArray) WaterMark = $null; LastPage = $true } } Get-MsecPurviewActivity -Days 7 -WarningVariable wv -WarningAction SilentlyContinue | Out-Null $wv } $w | Should -BeNullOrEmpty } It 'flattens PolicyName and RuleName out of PolicyMatchInfo' { $row = InModuleScope msec { Mock Get-Label -MockWith { @() } Mock Export-ActivityExplorerData -MockWith { [pscustomobject]@{ ResultCode = 'Success'; TotalResultCount = 1 ResultData = (@([pscustomobject]@{ ActivityId = 'DLPRuleMatch'; Activity = 'DLP rule matched' Happened = '2026-10-08T21:51:59Z'; User = 'a@contoso.com'; Workload = 'OneDrive' PolicyMatchInfo = [pscustomobject]@{ PolicyName = 'DLP - GDPR' RuleName = 'Content detected GDPR - Block external sharing' PolicyMode = 'Enable' } }) | ConvertTo-Json -Depth 6 -AsArray) WaterMark = $null; LastPage = $true } } Get-MsecPurviewActivity -Days 7 -WarningAction SilentlyContinue } # Grouping by PolicyName on the raw API output buckets every row under one blank key, # which reads as "no policy matched". $row.PolicyName | Should -Be 'DLP - GDPR' $row.RuleName | Should -Be 'Content detected GDPR - Block external sharing' $row.PolicyMode | Should -Be 'Enable' $row.Happened | Should -BeOfType [datetime] $row.PSObject.TypeNames | Should -Contain 'MsecPurviewActivity' } It 'resolves a label GUID to its name, and surfaces an unresolvable one rather than blanking it' { $rows = InModuleScope msec { Mock Get-Label -MockWith { @([pscustomobject]@{ Guid = '0746a447-2be0-405e-8efb-ecff871d0a53'; DisplayName = 'Confidential' }) } Mock Export-ActivityExplorerData -MockWith { [pscustomobject]@{ ResultCode = 'Success'; TotalResultCount = 3 ResultData = (@( [pscustomobject]@{ ActivityId = 'LabelApplied'; SensitivityLabel = '0746a447-2be0-405e-8efb-ecff871d0a53' } [pscustomobject]@{ ActivityId = 'LabelApplied'; SensitivityLabel = 'd145b07c-1216-434b-9d03-d664eda1f6a5' } [pscustomobject]@{ ActivityId = 'FileRead'; SensitivityLabel = '' } ) | ConvertTo-Json -Depth 6 -AsArray) WaterMark = $null; LastPage = $true } } @(Get-MsecPurviewActivity -Days 7 -WarningAction SilentlyContinue) } $rows[0].SensitivityLabelName | Should -Be 'Confidential' # A label deleted since the event still appears in history. Blank would read as # "unlabelled", which is the opposite of what happened. $rows[1].SensitivityLabelName | Should -Match 'deleted or unknown' $rows[1].SensitivityLabelName | Should -Match 'd145b07c' # Genuinely unlabelled stays null. $rows[2].SensitivityLabelName | Should -BeNullOrEmpty } It 'passes the activity token through to the API filter' { $captured = InModuleScope msec { $script:Filter = $null Mock Get-Label -MockWith { @() } Mock Export-ActivityExplorerData -MockWith { $script:Filter = $Filter1 [pscustomobject]@{ ResultCode = 'Success'; TotalResultCount = 0 ResultData = $null; WaterMark = $null; LastPage = $true } } Get-MsecPurviewActivity -Days 7 -Activity DLPRuleMatch, LabelApplied -WarningAction SilentlyContinue | Out-Null $script:Filter } @($captured)[0] | Should -Be 'Activity' $captured | Should -Contain 'DLPRuleMatch' $captured | Should -Contain 'LabelApplied' } It 'gives a clear error when the compliance session exposes no Activity Explorer cmdlet' { InModuleScope msec { Mock Get-Command -ParameterFilter { $Name -eq 'Export-ActivityExplorerData' } -MockWith { $null } { Get-MsecPurviewActivity } | Should -Throw '*Connect-MsecPurview*' } } It 'exposes BOTH sensitive-info shapes, because an activity type carries one or the other' { $rows = InModuleScope msec { Mock Get-Label -MockWith { @() } Mock Export-ActivityExplorerData -MockWith { [pscustomobject]@{ ResultCode = 'Success'; TotalResultCount = 2 ResultData = (@( # A rule match: per-detection data, with confidence. [pscustomobject]@{ ActivityId = 'DLPRuleMatch' SensitiveInfoTypeData = @([pscustomobject]@{ SensitiveInfoTypeId = 'e09c07d3-66e5-4783-989d-49ac62748f5f' Count = 8; Confidence = 93 }) SensitiveInfoTypeBucketsData = @([pscustomobject]@{ Id = 'e09c07d3-66e5-4783-989d-49ac62748f5f'; Low = 8; Medium = 8; High = 0 }) } # A classification: buckets ONLY. Reading SensitiveInfoTypeData here # finds nothing, which reads as "no sensitive information detected". [pscustomobject]@{ ActivityId = 'DlpClassification' SensitiveInfoTypeBucketsData = @([pscustomobject]@{ Id = '50b8b56b-4ef8-44c2-a924-03374f5831ce'; Low = 1; Medium = 1 }) } ) | ConvertTo-Json -Depth 8 -AsArray) WaterMark = $null; LastPage = $true } } @(Get-MsecPurviewActivity -Days 7 -WarningAction SilentlyContinue) } $rows[0].RawSensitiveInfo.SensitiveInfoTypeId | Should -Be 'e09c07d3-66e5-4783-989d-49ac62748f5f' $rows[0].RawSensitiveInfo.Confidence | Should -Be 93 # The classification event has no per-detection data at all - only buckets. If the # command exposed just RawSensitiveInfo, this event's detections would be invisible. $rows[1].RawSensitiveInfo | Should -BeNullOrEmpty $rows[1].RawSensitiveBuckets | Should -Not -BeNullOrEmpty $rows[1].RawSensitiveBuckets.Id | Should -Be '50b8b56b-4ef8-44c2-a924-03374f5831ce' } } |