|
| 1 | +# Pester tests for Invoke-ExecResolveAlert |
| 2 | + |
| 3 | +BeforeAll { |
| 4 | + # Resolve by name under Modules/ so the test survives the function moving between modules. |
| 5 | + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) |
| 6 | + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecResolveAlert.ps1' -File -ErrorAction SilentlyContinue | |
| 7 | + Select-Object -First 1 -ExpandProperty FullName |
| 8 | + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecResolveAlert.ps1 under Modules/' } |
| 9 | + |
| 10 | + # Azure Functions binding types do not exist outside the Functions host - fake them. |
| 11 | + class HttpResponseContext { |
| 12 | + [int]$StatusCode |
| 13 | + [object]$Body |
| 14 | + } |
| 15 | + # The Functions host profile provides 'using namespace System.Net'; outside it the |
| 16 | + # bare [HttpStatusCode] the function uses needs a type accelerator. |
| 17 | + $Accelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') |
| 18 | + if (-not $Accelerators::Get.ContainsKey('HttpStatusCode')) { |
| 19 | + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) |
| 20 | + } |
| 21 | + |
| 22 | + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. |
| 23 | + function Add-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) } |
| 24 | + function Get-CIPPAzDataTableEntity { param($TableName, $Filter) } |
| 25 | + function Get-CippException { param($Exception) } |
| 26 | + function Get-CIPPTable { param($tablename) } |
| 27 | + function Remove-AzDataTableEntity { param($TableName, $Entity, [switch]$Force) } |
| 28 | + function Write-LogMessage { param($headers, $API, $message, $Sev, $tenant) } |
| 29 | + |
| 30 | + # Use the real hash and filter-escape helpers so the endpoint's matching and |
| 31 | + # injection hardening stay faithful to production behaviour. |
| 32 | + foreach ($HelperName in @('Get-AlertContentHash.ps1', 'ConvertTo-CIPPODataFilterValue.ps1')) { |
| 33 | + $HelperPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter $HelperName -File -ErrorAction SilentlyContinue | |
| 34 | + Select-Object -First 1 -ExpandProperty FullName |
| 35 | + if (-not $HelperPath) { throw "Could not locate $HelperName under Modules/" } |
| 36 | + . $HelperPath |
| 37 | + } |
| 38 | + |
| 39 | + . $FunctionPath |
| 40 | + |
| 41 | + function New-ResolveRequest { |
| 42 | + param($Body) |
| 43 | + [pscustomobject]@{ |
| 44 | + Params = @{ CIPPEndpoint = 'ExecResolveAlert' } |
| 45 | + Headers = @{ Authorization = 'token' } |
| 46 | + Body = $Body |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + function New-AlertRow { |
| 51 | + param($PartitionKey, $Items) |
| 52 | + [pscustomobject]@{ |
| 53 | + PartitionKey = $PartitionKey |
| 54 | + RowKey = 'contoso.com-Get-CIPPAlertMFAAdmins' |
| 55 | + CmdletName = 'Get-CIPPAlertMFAAdmins' |
| 56 | + Tenant = 'contoso.com' |
| 57 | + LogData = (ConvertTo-Json -InputObject @($Items) -Compress -Depth 10 | Out-String) |
| 58 | + AlertComment = '' |
| 59 | + } |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +Describe 'Invoke-ExecResolveAlert' { |
| 64 | + BeforeEach { |
| 65 | + Mock -CommandName Get-CIPPTable -MockWith { @{ TableName = 'AlertLastRun' } } |
| 66 | + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { } |
| 67 | + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } |
| 68 | + Mock -CommandName Remove-AzDataTableEntity -MockWith { } |
| 69 | + Mock -CommandName Write-LogMessage -MockWith { } |
| 70 | + Mock -CommandName Get-CippException -MockWith { @{ NormalizedError = $Exception.Exception.Message } } |
| 71 | + |
| 72 | + $script:TargetItem = [pscustomobject]@{ UserPrincipalName = 'admin@contoso.com'; Message = 'MFA disabled' } |
| 73 | + $script:OtherItem = [pscustomobject]@{ UserPrincipalName = 'other@contoso.com'; Message = 'MFA disabled' } |
| 74 | + $script:ValidBody = [pscustomobject]@{ |
| 75 | + CmdletName = 'Get-CIPPAlertMFAAdmins' |
| 76 | + TenantFilter = 'contoso.com' |
| 77 | + AlertItem = $script:TargetItem |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + It 'removes the matching item and rewrites the row when other items remain' { |
| 82 | + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { |
| 83 | + New-AlertRow -PartitionKey '20260716' -Items @($script:TargetItem, $script:OtherItem) |
| 84 | + } |
| 85 | + |
| 86 | + $response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null |
| 87 | + |
| 88 | + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) |
| 89 | + $response.Body.Results | Should -Match '^Resolved alert' |
| 90 | + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { |
| 91 | + $Filter -eq "RowKey eq 'contoso.com-Get-CIPPAlertMFAAdmins'" |
| 92 | + } |
| 93 | + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { |
| 94 | + $Entity.LogData -notmatch 'admin@contoso\.com' -and $Entity.LogData -match 'other@contoso\.com' |
| 95 | + } |
| 96 | + Should -Invoke Remove-AzDataTableEntity -Times 0 -Exactly |
| 97 | + } |
| 98 | + |
| 99 | + It 'deletes the row entirely when the last item is resolved' { |
| 100 | + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { |
| 101 | + New-AlertRow -PartitionKey '20260716' -Items @($script:TargetItem) |
| 102 | + } |
| 103 | + |
| 104 | + $response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null |
| 105 | + |
| 106 | + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) |
| 107 | + Should -Invoke Remove-AzDataTableEntity -Times 1 -Exactly |
| 108 | + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly |
| 109 | + } |
| 110 | + |
| 111 | + It 'removes the item from every partition so older rows cannot resurface it' { |
| 112 | + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { |
| 113 | + @( |
| 114 | + New-AlertRow -PartitionKey '20260715' -Items @($script:TargetItem) |
| 115 | + New-AlertRow -PartitionKey '20260716' -Items @($script:TargetItem) |
| 116 | + ) |
| 117 | + } |
| 118 | + |
| 119 | + $response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null |
| 120 | + |
| 121 | + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) |
| 122 | + Should -Invoke Remove-AzDataTableEntity -Times 2 -Exactly |
| 123 | + } |
| 124 | + |
| 125 | + It 'returns OK with an already-resolved message when no stored item matches' { |
| 126 | + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { |
| 127 | + New-AlertRow -PartitionKey '20260716' -Items @($script:OtherItem) |
| 128 | + } |
| 129 | + |
| 130 | + $response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null |
| 131 | + |
| 132 | + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) |
| 133 | + $response.Body.Results | Should -Match 'already resolved' |
| 134 | + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly |
| 135 | + Should -Invoke Remove-AzDataTableEntity -Times 0 -Exactly |
| 136 | + } |
| 137 | + |
| 138 | + It 'preserves the LastSeen stamp when rewriting a row' { |
| 139 | + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { |
| 140 | + $Row = New-AlertRow -PartitionKey '20260716' -Items @($script:TargetItem, $script:OtherItem) |
| 141 | + $Row | Add-Member -NotePropertyName LastSeen -NotePropertyValue '1784216400' -PassThru |
| 142 | + } |
| 143 | + |
| 144 | + $null = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null |
| 145 | + |
| 146 | + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { |
| 147 | + $Entity.LastSeen -eq '1784216400' |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + It 'escapes quotes in caller input so the filter cannot be widened' { |
| 152 | + $Body = [pscustomobject]@{ |
| 153 | + CmdletName = 'Get-CIPPAlertMFAAdmins' |
| 154 | + TenantFilter = "x' or RowKey ge '" |
| 155 | + AlertItem = $script:TargetItem |
| 156 | + } |
| 157 | + |
| 158 | + $response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $Body) -TriggerMetadata $null |
| 159 | + |
| 160 | + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) |
| 161 | + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { |
| 162 | + $Filter -eq "RowKey eq 'x'' or RowKey ge ''-Get-CIPPAlertMFAAdmins'" |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + It 'returns BadRequest when a required field is missing' { |
| 167 | + $Body = [pscustomobject]@{ CmdletName = ''; TenantFilter = 'contoso.com'; AlertItem = $script:TargetItem } |
| 168 | + |
| 169 | + $response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $Body) -TriggerMetadata $null |
| 170 | + |
| 171 | + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) |
| 172 | + $response.Body.Results | Should -Match 'required' |
| 173 | + } |
| 174 | + |
| 175 | + It 'returns InternalServerError and logs when the table query throws' { |
| 176 | + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { throw 'table unavailable' } |
| 177 | + |
| 178 | + $response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null |
| 179 | + |
| 180 | + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) |
| 181 | + $response.Body.Results | Should -Match 'Failed to resolve alert' |
| 182 | + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Error' } |
| 183 | + } |
| 184 | +} |
0 commit comments