|
| 1 | +Describe 'Test-Assessment-41116' { |
| 2 | + BeforeAll { |
| 3 | + $srcRoot = Join-Path $PSScriptRoot '../../src/powershell' |
| 4 | + |
| 5 | + if (-not (Get-Command Write-PSFMessage -ErrorAction SilentlyContinue)) { |
| 6 | + function global:Write-PSFMessage {} |
| 7 | + } |
| 8 | + if (-not (Get-Command Write-ZtProgress -ErrorAction SilentlyContinue)) { |
| 9 | + function global:Write-ZtProgress {} |
| 10 | + } |
| 11 | + if (-not (Get-Command Invoke-ZtGraphRequest -ErrorAction SilentlyContinue)) { |
| 12 | + function global:Invoke-ZtGraphRequest { |
| 13 | + [CmdletBinding()] |
| 14 | + param($RelativeUri, $ApiVersion, $Method, $Body) |
| 15 | + } |
| 16 | + } |
| 17 | + if (-not (Get-Command Get-ZtHttpStatusCode -ErrorAction SilentlyContinue)) { |
| 18 | + function global:Get-ZtHttpStatusCode { param($ErrorRecord) return $null } |
| 19 | + } |
| 20 | + if (-not (Get-Command Get-SafeMarkdown -ErrorAction SilentlyContinue)) { |
| 21 | + function global:Get-SafeMarkdown { param($Text) return $Text } |
| 22 | + } |
| 23 | + if (-not (Get-Command Add-ZtTestResultDetail -ErrorAction SilentlyContinue)) { |
| 24 | + function global:Add-ZtTestResultDetail { |
| 25 | + param( |
| 26 | + [string] $Description, [bool] $Status, [string] $Result, |
| 27 | + [Object[]] $GraphObjects,[string] $GraphObjectType, |
| 28 | + [string] $TestId, [string] $Title, [string] $SkippedBecause, |
| 29 | + [string] $UserImpact, [string] $Risk, [string] $ImplementationCost, |
| 30 | + [string[]] $AppliesTo, [string[]] $Tag, [string] $CustomStatus, |
| 31 | + [string[]] $NotConnectedService, [string] $Pillar, [string] $Category |
| 32 | + ) |
| 33 | + } |
| 34 | + } |
| 35 | + |
| 36 | + $classPath = Join-Path $srcRoot 'classes/ZtTest.ps1' |
| 37 | + if (-not ('ZtTest' -as [type])) { |
| 38 | + . $classPath |
| 39 | + } |
| 40 | + |
| 41 | + . (Join-Path $srcRoot 'tests/Test-Assessment.41116.ps1') |
| 42 | + |
| 43 | + function global:New-TestGraphError { |
| 44 | + param( |
| 45 | + [Parameter(Mandatory)] |
| 46 | + [string] $Message, |
| 47 | + |
| 48 | + [string] $ResponseBody |
| 49 | + ) |
| 50 | + |
| 51 | + $errorRecord = [System.Management.Automation.ErrorRecord]::new( |
| 52 | + [System.Exception]::new($Message), |
| 53 | + 'GraphRequestFailed', |
| 54 | + [System.Management.Automation.ErrorCategory]::InvalidOperation, |
| 55 | + $null |
| 56 | + ) |
| 57 | + if ($ResponseBody) { |
| 58 | + $errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($ResponseBody) |
| 59 | + } |
| 60 | + $errorRecord |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + BeforeEach { |
| 65 | + Mock Write-PSFMessage {} |
| 66 | + Mock Write-ZtProgress {} |
| 67 | + Mock Get-SafeMarkdown { param($Text) return $Text } |
| 68 | + Mock Get-ZtHttpStatusCode { return $null } |
| 69 | + |
| 70 | + $script:capturedRequestBody = $null |
| 71 | + $script:capturedStatus = $null |
| 72 | + $script:capturedResult = $null |
| 73 | + $script:capturedCustomStatus = $null |
| 74 | + $script:capturedSkippedBecause = $null |
| 75 | + |
| 76 | + Mock Add-ZtTestResultDetail { |
| 77 | + param($TestId, $Title, $Status, $Result, $CustomStatus, $SkippedBecause) |
| 78 | + $script:capturedStatus = $Status |
| 79 | + $script:capturedResult = $Result |
| 80 | + $script:capturedCustomStatus = $CustomStatus |
| 81 | + $script:capturedSkippedBecause = $SkippedBecause |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + Context 'When the advanced hunting query succeeds' { |
| 86 | + It 'Should send the expected Graph POST request and pass for a positive count' { |
| 87 | + Mock Invoke-ZtGraphRequest { |
| 88 | + param($Body) |
| 89 | + $script:capturedRequestBody = $Body |
| 90 | + [PSCustomObject]@{ |
| 91 | + results = @([PSCustomObject]@{ Count = 42L }) |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + Test-Assessment-41116 |
| 96 | + |
| 97 | + Should -Invoke Invoke-ZtGraphRequest -Times 1 -Exactly -ParameterFilter { |
| 98 | + $RelativeUri -eq 'security/runHuntingQuery' -and |
| 99 | + $ApiVersion -eq 'beta' -and |
| 100 | + $Method -eq 'POST' |
| 101 | + } |
| 102 | + $requestBody = $script:capturedRequestBody | ConvertFrom-Json |
| 103 | + $requestBody.Query | Should -Be 'EmailEvents | where Timestamp > ago(1d) | summarize Count=count()' |
| 104 | + $requestBody.Timespan | Should -Be 'P1D' |
| 105 | + $script:capturedStatus | Should -BeTrue |
| 106 | + $script:capturedCustomStatus | Should -BeNullOrEmpty |
| 107 | + $script:capturedSkippedBecause | Should -BeNullOrEmpty |
| 108 | + $script:capturedResult | Should -Match 'data is queryable from automation' |
| 109 | + $script:capturedResult | Should -Match '\| /beta/security/runHuntingQuery \| 200 \|' |
| 110 | + $script:capturedResult | Should -Match '\| 42 \| Pass \|' |
| 111 | + } |
| 112 | + |
| 113 | + It 'Should investigate when the count is zero' { |
| 114 | + Mock Invoke-ZtGraphRequest { |
| 115 | + [PSCustomObject]@{ |
| 116 | + results = @([PSCustomObject]@{ Count = 0L }) |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + Test-Assessment-41116 |
| 121 | + |
| 122 | + $script:capturedStatus | Should -BeFalse |
| 123 | + $script:capturedCustomStatus | Should -Be 'Investigate' |
| 124 | + $script:capturedSkippedBecause | Should -BeNullOrEmpty |
| 125 | + $script:capturedResult | Should -Match 'zero recent EmailEvents' |
| 126 | + $script:capturedResult | Should -Match '\| 0 \| Investigate \|' |
| 127 | + } |
| 128 | + |
| 129 | + It 'Should preserve a count above the 32-bit integer limit' { |
| 130 | + Mock Invoke-ZtGraphRequest { |
| 131 | + [PSCustomObject]@{ |
| 132 | + results = @([PSCustomObject]@{ Count = 3000000000L }) |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + Test-Assessment-41116 |
| 137 | + |
| 138 | + $script:capturedStatus | Should -BeTrue |
| 139 | + $script:capturedCustomStatus | Should -BeNullOrEmpty |
| 140 | + $script:capturedSkippedBecause | Should -BeNullOrEmpty |
| 141 | + $script:capturedResult | Should -Match '\| 3000000000 \| Pass \|' |
| 142 | + } |
| 143 | + |
| 144 | + It 'Should investigate when the results array is empty' { |
| 145 | + Mock Invoke-ZtGraphRequest { |
| 146 | + [PSCustomObject]@{ results = @() } |
| 147 | + } |
| 148 | + |
| 149 | + Test-Assessment-41116 |
| 150 | + |
| 151 | + $script:capturedStatus | Should -BeFalse |
| 152 | + $script:capturedCustomStatus | Should -Be 'Investigate' |
| 153 | + $script:capturedResult | Should -Match 'No results were returned' |
| 154 | + } |
| 155 | + |
| 156 | + It 'Should investigate when the count cannot be parsed' { |
| 157 | + Mock Invoke-ZtGraphRequest { |
| 158 | + [PSCustomObject]@{ |
| 159 | + results = @([PSCustomObject]@{ Count = 'not-a-number' }) |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + { Test-Assessment-41116 } | Should -Not -Throw |
| 164 | + $script:capturedStatus | Should -BeFalse |
| 165 | + $script:capturedCustomStatus | Should -Be 'Investigate' |
| 166 | + $script:capturedResult | Should -Match 'could not be parsed' |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + Context 'When Microsoft Graph returns an error' { |
| 171 | + It 'Should skip a license-related HTTP 403 response' { |
| 172 | + $script:queryError = New-TestGraphError ` |
| 173 | + -Message 'Forbidden' ` |
| 174 | + -ResponseBody '{"error":{"code":"Forbidden","message":"The tenant is not licensed for Defender for Office 365 Plan 2."}}' |
| 175 | + Mock Get-ZtHttpStatusCode { return 403 } |
| 176 | + Mock Invoke-ZtGraphRequest { |
| 177 | + $PSCmdlet.ThrowTerminatingError($script:queryError) |
| 178 | + } |
| 179 | + |
| 180 | + Test-Assessment-41116 |
| 181 | + |
| 182 | + $script:capturedStatus | Should -BeFalse |
| 183 | + $script:capturedCustomStatus | Should -BeNullOrEmpty |
| 184 | + $script:capturedSkippedBecause | Should -Be 'NotApplicable' |
| 185 | + $script:capturedResult | Should -Match 'does not have the required Microsoft Defender for Office 365 Plan 2' |
| 186 | + $script:capturedResult | Should -Match '\| 403 \| Forbidden \|' |
| 187 | + } |
| 188 | + |
| 189 | + It 'Should investigate a permission-related HTTP 403 response' { |
| 190 | + $script:queryError = New-TestGraphError ` |
| 191 | + -Message 'Forbidden' ` |
| 192 | + -ResponseBody '{"error":{"code":"Forbidden","message":"Access denied."}}' |
| 193 | + Mock Get-ZtHttpStatusCode { return 403 } |
| 194 | + Mock Invoke-ZtGraphRequest { |
| 195 | + $PSCmdlet.ThrowTerminatingError($script:queryError) |
| 196 | + } |
| 197 | + |
| 198 | + Test-Assessment-41116 |
| 199 | + |
| 200 | + $script:capturedStatus | Should -BeFalse |
| 201 | + $script:capturedCustomStatus | Should -Be 'Investigate' |
| 202 | + $script:capturedSkippedBecause | Should -BeNullOrEmpty |
| 203 | + $script:capturedResult | Should -Match 'ThreatHunting.Read.All' |
| 204 | + $script:capturedResult | Should -Match 'Global Reader' |
| 205 | + $script:capturedResult | Should -Match 'Security Administrator' |
| 206 | + $script:capturedResult | Should -Match 'Defender XDR Unified RBAC' |
| 207 | + } |
| 208 | + |
| 209 | + It 'Should fail when HTTP 400 reports an unknown EmailEvents table' { |
| 210 | + $script:queryError = New-TestGraphError ` |
| 211 | + -Message 'Bad Request' ` |
| 212 | + -ResponseBody '{"error":{"code":"BadRequest","message":"Failed to resolve table expression named EmailEvents."}}' |
| 213 | + Mock Get-ZtHttpStatusCode { return 400 } |
| 214 | + Mock Invoke-ZtGraphRequest { |
| 215 | + $PSCmdlet.ThrowTerminatingError($script:queryError) |
| 216 | + } |
| 217 | + |
| 218 | + Test-Assessment-41116 |
| 219 | + |
| 220 | + $script:capturedStatus | Should -BeFalse |
| 221 | + $script:capturedCustomStatus | Should -BeNullOrEmpty |
| 222 | + $script:capturedSkippedBecause | Should -BeNullOrEmpty |
| 223 | + $script:capturedResult | Should -Match 'schema is unavailable' |
| 224 | + $script:capturedResult | Should -Match '\| Fail \|' |
| 225 | + } |
| 226 | + |
| 227 | + It 'Should investigate an HTTP 429 throttling response' { |
| 228 | + $script:queryError = New-TestGraphError -Message 'Too Many Requests' |
| 229 | + Mock Get-ZtHttpStatusCode { return 429 } |
| 230 | + Mock Invoke-ZtGraphRequest { |
| 231 | + $PSCmdlet.ThrowTerminatingError($script:queryError) |
| 232 | + } |
| 233 | + |
| 234 | + Test-Assessment-41116 |
| 235 | + |
| 236 | + $script:capturedStatus | Should -BeFalse |
| 237 | + $script:capturedCustomStatus | Should -Be 'Investigate' |
| 238 | + $script:capturedResult | Should -Match 'quota was exceeded' |
| 239 | + } |
| 240 | + |
| 241 | + It 'Should investigate an unexpected Graph failure' { |
| 242 | + $script:queryError = New-TestGraphError -Message 'Service Unavailable' |
| 243 | + Mock Get-ZtHttpStatusCode { return 503 } |
| 244 | + Mock Invoke-ZtGraphRequest { |
| 245 | + $PSCmdlet.ThrowTerminatingError($script:queryError) |
| 246 | + } |
| 247 | + |
| 248 | + Test-Assessment-41116 |
| 249 | + |
| 250 | + $script:capturedStatus | Should -BeFalse |
| 251 | + $script:capturedCustomStatus | Should -Be 'Investigate' |
| 252 | + $script:capturedSkippedBecause | Should -BeNullOrEmpty |
| 253 | + $script:capturedResult | Should -Match 'unexpected error' |
| 254 | + } |
| 255 | + |
| 256 | + It 'Should investigate an unparseable error response' { |
| 257 | + $script:queryError = New-TestGraphError -Message 'Forbidden' -ResponseBody 'not-json' |
| 258 | + Mock Get-ZtHttpStatusCode { return 403 } |
| 259 | + Mock Invoke-ZtGraphRequest { |
| 260 | + $PSCmdlet.ThrowTerminatingError($script:queryError) |
| 261 | + } |
| 262 | + |
| 263 | + { Test-Assessment-41116 } | Should -Not -Throw |
| 264 | + $script:capturedStatus | Should -BeFalse |
| 265 | + $script:capturedCustomStatus | Should -Be 'Investigate' |
| 266 | + $script:capturedSkippedBecause | Should -BeNullOrEmpty |
| 267 | + $script:capturedResult | Should -Match 'ThreatHunting.Read.All' |
| 268 | + } |
| 269 | + } |
| 270 | +} |
0 commit comments