diff --git a/.github/linters/.textlintrc b/.github/linters/.textlintrc index db48de8..e146896 100644 --- a/.github/linters/.textlintrc +++ b/.github/linters/.textlintrc @@ -158,11 +158,6 @@ "ZIP", "McKenzie", "McConnell", - "ID", - [ - "id['’]?s", - "IDs" - ], [ "backwards compatible", "backward compatible" diff --git a/README.md b/README.md index 6f9d704..0a587f8 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Here is a list of example that are typical use cases for the module. ### Example 1: Get-IntuneDeviceLogin -As for March 2026 there is one cmdlet: `Get-IntuneDeviceLogin`. +As of March 2026, the module has three cmdlets: `Get-IntuneDeviceLogin`, `Get-IntuneRemediationSummary` and `Get-IntuneRemediationDeviceStatus`. ```powershell Get-IntuneDeviceLogin -DeviceName PC-001 @@ -32,16 +32,11 @@ Get-IntuneDeviceLogin -DeviceName PC-001 ```text DeviceName : PC-001 +OperatingSystem : Windows UserPrincipalName : john.doe@contoso.com DeviceId : c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a UserId : a5b6c7d8-e9f0-1a2b-3c4d-5e6f7a8b9c0d LastLogonDateTime : 3/9/2026 8:14:00 AM - -DeviceName : PC-001 -UserPrincipalName : jane.smith@contoso.com -DeviceId : c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a -UserId : b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e -LastLogonDateTime : 3/7/2026 2:45:00 PM ``` ```powershell @@ -50,18 +45,88 @@ Get-IntuneDeviceLogin -UserPrincipalName john.doe@contoso.com ```text DeviceName : PC-001 +OperatingSystem : Windows UserPrincipalName : john.doe@contoso.com DeviceId : c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a UserId : a5b6c7d8-e9f0-1a2b-3c4d-5e6f7a8b9c0d LastLogonDateTime : 3/9/2026 8:14:00 AM DeviceName : PC-042 +OperatingSystem : Windows UserPrincipalName : john.doe@contoso.com DeviceId : f7e6d5c4-b3a2-1f0e-9d8c-7b6a5f4e3d2c UserId : a5b6c7d8-e9f0-1a2b-3c4d-5e6f7a8b9c0d LastLogonDateTime : 3/5/2026 9:33:00 AM ``` +### Example 2: Get-IntuneRemediationSummary + +```powershell +Get-IntuneRemediationSummary +``` + +```text +Name : Fix BitLocker +Status : Completed +WithoutIssues : 214 +WithIssues : 3 +IssueFixed : 47 +IssueRecurred : 1 +TotalRemediated : 47 + +Name : Disable NetBIOS +Status : Completed +WithoutIssues : 217 +WithIssues : 0 +IssueFixed : 0 +IssueRecurred : 0 +TotalRemediated : 0 +``` + +### Example 3: Get-IntuneRemediationDeviceStatus + +```powershell +Get-IntuneRemediationDeviceStatus -Name 'BitLocker detection and remediation' +``` + +```text +RemediationName : BitLocker detection and remediation +RemediationId : b2bf3efa-b16d-4936-866c-560592e4d35a +DeviceId : c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a +DeviceName : PC-001 +UserPrincipalName : john.doe@contoso.com +LastStateUpdate : 3/11/2026 6:00:00 AM +DetectionState : success +RemediationState : success +PreRemediationOutput : BitLocker status: Off +PostRemediationOutput : BitLocker status: On +DetectionOutput : +PreRemediationError : +RemediationError : +DetectionError : + +RemediationName : BitLocker detection and remediation +RemediationId : b2bf3efa-b16d-4936-866c-560592e4d35a +DeviceId : f7e6d5c4-b3a2-1f0e-9d8c-7b6a5f4e3d2c +DeviceName : PC-042 +UserPrincipalName : jane.smith@contoso.com +LastStateUpdate : 3/11/2026 6:05:00 AM +DetectionState : fail +RemediationState : remediationFailed +PreRemediationOutput : BitLocker status: Off +PostRemediationOutput : BitLocker status: Off +DetectionOutput : +PreRemediationError : +RemediationError : Exit code: 1 - Access denied +DetectionError : +``` + +You can also pipe from `Get-IntuneRemediationSummary` to only inspect remediations that have devices with issues: + +```powershell +Get-IntuneRemediationSummary | Where-Object WithIssues -gt 0 | Get-IntuneRemediationDeviceStatus +``` + ## Acknowledgements - [Process-Module](https://github.com/PSModule/Process-PSModule) by [Marius Storhaug](https://github.com/MariusStorhaug). Contains the entire build pipeline. This is greatly beneficial and helps me just concentrating on building the cmdlets. \ No newline at end of file diff --git a/src/functions/private/Get-FirstPropertyValue.ps1 b/src/functions/private/Get-FirstPropertyValue.ps1 new file mode 100644 index 0000000..9c53f31 --- /dev/null +++ b/src/functions/private/Get-FirstPropertyValue.ps1 @@ -0,0 +1,85 @@ +function Get-FirstPropertyValue { + <# + .SYNOPSIS + Returns the first non-empty property value from an object or dictionary. + + .DESCRIPTION + Checks each property name in order against the input object, dictionary keys, + and an AdditionalProperties dictionary when present. Returns the first value + that is not null and not empty/whitespace when converted to string. + + .PARAMETER InputObject + The object or dictionary to inspect. + + .PARAMETER PropertyNames + The ordered list of property names to evaluate. + + .PARAMETER DefaultValue + The value to return when none of the requested properties contain a value. + + .EXAMPLE + Get-FirstPropertyValue -InputObject $Object -PropertyNames @('displayName', 'name') + + Returns the first populated value found in `displayName` or `name`. + + .INPUTS + System.Object + + .OUTPUTS + System.Object + #> + + [OutputType([object])] + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object]$InputObject, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string[]]$PropertyNames, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [object]$DefaultValue = $null + ) + + process { + if ($null -eq $InputObject) { + return $DefaultValue + } + + $additionalProperties = $InputObject.PSObject.Properties['AdditionalProperties'] + $additionalPropertiesValue = $null + if ($null -ne $additionalProperties -and $additionalProperties.Value -is [System.Collections.IDictionary]) { + $additionalPropertiesValue = $additionalProperties.Value + } + + foreach ($propertyName in $PropertyNames) { + if ($InputObject -is [System.Collections.IDictionary] -and $InputObject.Contains($propertyName)) { + $dictionaryValue = $InputObject[$propertyName] + if ($null -ne $dictionaryValue -and -not [string]::IsNullOrWhiteSpace([string]$dictionaryValue)) { + return $dictionaryValue + } + } + + $property = $InputObject.PSObject.Properties[$propertyName] + if ($null -ne $property) { + $value = $property.Value + if ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) { + return $value + } + } + + if ($null -ne $additionalPropertiesValue -and $additionalPropertiesValue.Contains($propertyName)) { + $apValue = $additionalPropertiesValue[$propertyName] + if ($null -ne $apValue -and -not [string]::IsNullOrWhiteSpace([string]$apValue)) { + return $apValue + } + } + } + + return $DefaultValue + } +} diff --git a/src/functions/public/Remediation/Get-IntuneRemediationDeviceStatus.ps1 b/src/functions/public/Remediation/Get-IntuneRemediationDeviceStatus.ps1 new file mode 100644 index 0000000..73b8ef0 --- /dev/null +++ b/src/functions/public/Remediation/Get-IntuneRemediationDeviceStatus.ps1 @@ -0,0 +1,247 @@ +function Get-IntuneRemediationDeviceStatus { + <# + .SYNOPSIS + Retrieves per-device run state and pre/post remediation output for a specific Intune proactive remediation. + + .DESCRIPTION + Queries Microsoft Graph (beta) for the device run states of a proactive remediation script + (device health script). Returns one row per device, including the detection and remediation + states and the captured pre/post-remediation detection script output. + + Requires an authenticated Graph session with appropriate scopes. + + Scopes (minimum): + - DeviceManagementConfiguration.Read.All + + .PARAMETER Name + The display name of the remediation script. Supports wildcards (*). + When multiple scripts match, all are processed. + Parameter set: ByName. + + .PARAMETER Id + The ID (GUID) of the device health script (remediation) to query. + Parameter set: ById. + + .EXAMPLE + Connect-MgGraph -Scopes "DeviceManagementConfiguration.Read.All" + Get-IntuneRemediationDeviceStatus -Name "BitLocker*" + + Returns per-device run states for all remediations whose name starts with "BitLocker". + + .EXAMPLE + Get-IntuneRemediationDeviceStatus -Id "f1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a" + + Returns per-device run states for the remediation with the specified ID. + + .EXAMPLE + Get-IntuneRemediationSummary | Where-Object WithIssues -gt 0 | Get-IntuneRemediationDeviceStatus + + Pipes remediations that have devices with issues into this cmdlet to get device-level detail. + + .INPUTS + System.String (Name or Id via pipeline by property name) + + .OUTPUTS + PSCustomObject with the following properties + - RemediationName (string) : Display name of the remediation script + - RemediationId (string) : GUID of the device health script + - DeviceId (string) : Managed device ID + - DeviceName (string) : Managed device display name + - UserPrincipalName (string) : Primary user UPN of the device + - LastStateUpdate (datetime / null): When the run state was last updated + - DetectionState (string) : Outcome of the last detection script run + - RemediationState (string) : Outcome of the last remediation script run + - PreRemediationOutput (string) : stdout captured before remediation ran + - PostRemediationOutput (string) : stdout captured after remediation ran + - DetectionOutput (string) : Detection-only script stdout + - PreRemediationError (string) : stderr captured before remediation ran + - RemediationError (string) : stderr from the remediation script + - DetectionError (string) : stderr from the detection script + + .NOTES + Author: FHN & GitHub Copilot + - Uses /beta Graph endpoints. + - Expands managedDevice to include DeviceName and UserPrincipalName inline. + #> + + [OutputType([PSCustomObject])] + [CmdletBinding(DefaultParameterSetName = 'ByName', SupportsShouldProcess = $false)] + param( + [Parameter( + ParameterSetName = 'ByName', + Mandatory = $true, + ValueFromPipelineByPropertyName = $true, + HelpMessage = 'Display name (supports wildcards) of the remediation script' + )] + [ValidateNotNullOrEmpty()] + [Alias('RemediationName')] + [string]$Name, + + [Parameter( + ParameterSetName = 'ById', + Mandatory = $true, + ValueFromPipelineByPropertyName = $true, + HelpMessage = 'GUID of the device health / remediation script' + )] + [ValidatePattern('^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$')] + [Alias('RemediationId')] + [string]$Id + ) + + begin { + $expandQuery = '$expand=managedDevice($select=id,deviceName,userPrincipalName)' + } + + process { + # ------------------------------------------------------------------ # + # 1. Resolve the target script(s) when called by name # + # ------------------------------------------------------------------ # + $targetScripts = @() + + if ($PSCmdlet.ParameterSetName -eq 'ById') { + $targetScripts += [PSCustomObject]@{ + id = $Id + displayName = $Id # will be overwritten once we fetch the real name + } + + # Fetch the actual display name so output is readable + try { + $scriptDetailUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$($Id)?`$select=id,displayName" + $scriptDetail = Invoke-GraphGet -Uri $scriptDetailUri + if ($null -ne $scriptDetail -and -not [string]::IsNullOrWhiteSpace($scriptDetail.displayName)) { + $targetScripts[0] = [PSCustomObject]@{ + id = $Id + displayName = [string]$scriptDetail.displayName + } + } + } catch { + Write-Warning -Message "Could not fetch display name for remediation '$Id': $($_.Exception.Message)" + } + } else { + # ByName – list all scripts then filter + $listUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?$select=id,displayName' + try { + $listResponse = Invoke-GraphGet -Uri $listUri + } catch { + if ($_.FullyQualifiedErrorId -match 'GraphRequestFailed' -and $_.Exception.Message -match 'Forbidden|403') { + $Exception = [Exception]::new("Failed to list proactive remediations: access denied. Ensure your account has an Intune role with permission to read Device configurations, then reconnect and try again. Original error: $($_.Exception.Message)", $_.Exception) + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'RemediationListAccessDenied', + [System.Management.Automation.ErrorCategory]::PermissionDenied, + $listUri + ) + $PSCmdlet.ThrowTerminatingError($ErrorRecord) + } + + $Exception = [Exception]::new("Failed to list proactive remediations: $($_.Exception.Message)", $_.Exception) + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'RemediationListFailed', + [System.Management.Automation.ErrorCategory]::NotSpecified, + $listUri + ) + $PSCmdlet.ThrowTerminatingError($ErrorRecord) + } + + $allScripts = @() + if ($null -ne $listResponse) { + if ($null -ne $listResponse.value) { + $allScripts = @($listResponse.value) + } else { + $allScripts = @($listResponse) + } + } + + $targetScripts = @($allScripts | Where-Object { $_.displayName -like $Name }) + + if ($targetScripts.Count -eq 0) { + $Exception = [Exception]::new("No remediation script found matching name '$Name'.") + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'RemediationNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $Name + ) + $PSCmdlet.WriteError($ErrorRecord) + return + } + } + + # ------------------------------------------------------------------ # + # 2. For each matched script, retrieve per-device run states # + # ------------------------------------------------------------------ # + foreach ($script in $targetScripts) { + $remediationName = [string]$script.displayName + $remediationId = [string]$script.id + + Write-Verbose -Message "Retrieving device run states for remediation '$remediationName' ($remediationId)" + + $runStatesUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$remediationId/deviceRunStates?$expandQuery" + + try { + $runStatesResponse = Invoke-GraphGet -Uri $runStatesUri + } catch { + Write-Warning -Message "Failed to retrieve device run states for '$remediationName' ($remediationId): $($_.Exception.Message)" + continue + } + + $runStates = @() + if ($null -ne $runStatesResponse) { + if ($null -ne $runStatesResponse.value) { + $runStates = @($runStatesResponse.value) + } else { + $runStates = @($runStatesResponse) + } + } + + if ($runStates.Count -eq 0) { + Write-Verbose -Message "No device run states found for '$remediationName'." + continue + } + + foreach ($state in $runStates) { + # Resolve device fields from the expanded managedDevice object + $device = $null + $deviceId = $null + $deviceName = $null + $upn = $null + + if ($null -ne $state.managedDevice) { + $device = $state.managedDevice + $deviceId = [string]$device.id + $deviceName = [string]$device.deviceName + $upn = [string]$device.userPrincipalName + } + + # Resolve last-state timestamp + $lastUpdate = $null + $rawDate = $state.lastStateUpdateDateTime + if ($null -ne $rawDate -and -not [string]::IsNullOrWhiteSpace([string]$rawDate)) { + try { + $lastUpdate = [datetime]$rawDate + } catch { + $lastUpdate = $null + } + } + + [PSCustomObject]@{ + RemediationName = $remediationName + RemediationId = $remediationId + DeviceId = $deviceId + DeviceName = $deviceName + UserPrincipalName = $upn + LastStateUpdate = $lastUpdate + DetectionState = [string]$state.detectionState + RemediationState = [string]$state.remediationState + PreRemediationOutput = [string]$state.preRemediationDetectionScriptOutput + PostRemediationOutput = [string]$state.postRemediationDetectionScriptOutput + DetectionOutput = [string]$state.detectionScriptOutput + PreRemediationError = [string]$state.preRemediationDetectionScriptError + RemediationError = [string]$state.remediationScriptError + DetectionError = [string]$state.detectionScriptError + } + } + } + } # Process +} # Cmdlet diff --git a/src/functions/public/Remediation/Get-IntuneRemediationSummary.ps1 b/src/functions/public/Remediation/Get-IntuneRemediationSummary.ps1 new file mode 100644 index 0000000..b9d035e --- /dev/null +++ b/src/functions/public/Remediation/Get-IntuneRemediationSummary.ps1 @@ -0,0 +1,289 @@ +function Get-IntuneRemediationSummary { + <# + .SYNOPSIS + Retrieves remediation summary statistics for all Intune proactive remediations. + + .DESCRIPTION + Queries Microsoft Graph (beta) for all device health scripts (proactive remediations) + and returns one summary row per remediation, including status and key issue counters. + + Requires an authenticated Graph session with appropriate scopes. + + Scopes (minimum): + - DeviceManagementConfiguration.Read.All + + .EXAMPLE + Connect-MgGraph -Scopes "DeviceManagementConfiguration.Read.All" + Get-IntuneRemediationSummary + + Returns one row per remediation with status and issue/remediation counters. + + .OUTPUTS + PSCustomObject with the following properties + - Name (string) + - Status (string) + - WithoutIssues (int) + - WithIssues (int) + - IssueFixed (int) + - IssueRecurred (int) + - TotalRemediated (int) + + .NOTES + Author: FHN & GitHub Copilot + - Uses /beta Graph endpoints. + #> + + [OutputType([PSCustomObject])] + [CmdletBinding(SupportsShouldProcess = $false)] + param() + + begin { + $listUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?$select=id,displayName' + } + + process { + Write-Verbose -Message 'Retrieving proactive remediations from Microsoft Graph' + + try { + $scriptsResponse = Invoke-GraphGet -Uri $listUri + } catch { + if ($_.FullyQualifiedErrorId -match 'GraphRequestFailed' -and $_.Exception.Message -match 'Forbidden|403') { + $Exception = [Exception]::new("Failed to retrieve proactive remediations: access denied by Intune RBAC or tenant policy. Ensure your account has an Intune role with permission to read Device configurations (Endpoint Analytics/Remediations), then reconnect and try again. Original error: $($_.Exception.Message)", $_.Exception) + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'RemediationListAccessDenied', + [System.Management.Automation.ErrorCategory]::PermissionDenied, + $listUri + ) + $PSCmdlet.ThrowTerminatingError($ErrorRecord) + } + + $Exception = [Exception]::new("Failed to retrieve proactive remediations: $($_.Exception.Message)", $_.Exception) + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'RemediationListFailed', + [System.Management.Automation.ErrorCategory]::NotSpecified, + $listUri + ) + $PSCmdlet.ThrowTerminatingError($ErrorRecord) + } + + $scripts = @() + if ($null -ne $scriptsResponse) { + if ($null -ne $scriptsResponse.value) { + $scripts = @($scriptsResponse.value) + } else { + $scripts = @($scriptsResponse) + } + } + + foreach ($script in $scripts) { + if ($null -eq $script.id) { + continue + } + + $scriptName = [string]$script.displayName + if ([string]::IsNullOrWhiteSpace($scriptName)) { + $scriptName = [string](Get-FirstPropertyValue -InputObject $script -PropertyNames @('displayName', 'name') -DefaultValue $script.id) + } + + Write-Verbose -Message "Processing remediation summary for '$scriptName'" + + $summaryUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$($script.id)/runSummary" + try { + $summaryResponse = Invoke-GraphGet -Uri $summaryUri + } catch { + Write-Warning -Message "Failed to retrieve run summary for '$scriptName' ($($script.id)): $($_.Exception.Message)" + continue + } + + $summary = $summaryResponse + if ($null -ne $summaryResponse.value) { + if ($summaryResponse.value -is [array]) { + $summary = $summaryResponse.value | Select-Object -First 1 + } else { + $summary = $summaryResponse.value + } + } + + if ($null -eq $summary) { + $summary = $summaryResponse + } + + # Some tenants return nested shapes for run summary payloads. + # Resolve to the first object that actually contains known summary counters. + $summaryCandidates = @() + if ($null -ne $summary) { + $summaryCandidates += $summary + if ($null -ne $summary.runSummary) { + $summaryCandidates += $summary.runSummary + } + } + if ($null -ne $summaryResponse) { + $summaryCandidates += $summaryResponse + if ($null -ne $summaryResponse.runSummary) { + $summaryCandidates += $summaryResponse.runSummary + } + if ($null -ne $summaryResponse.value -and $null -ne $summaryResponse.value.runSummary) { + $summaryCandidates += $summaryResponse.value.runSummary + } + } + if ($null -ne $summary.value) { + $summaryCandidates += $summary.value + if ($null -ne $summary.value.runSummary) { + $summaryCandidates += $summary.value.runSummary + } + } + + $resolvedSummary = $null + foreach ($candidate in $summaryCandidates) { + if ($null -eq $candidate) { + continue + } + + $candidateNoIssue = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @( + 'noIssueDetectedDeviceCount', 'withoutIssues', 'noIssueCount', 'noIssueDeviceCount', 'devicesWithoutIssues' + ) -DefaultValue $null + $candidateWithIssue = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @( + 'issueDetectedDeviceCount', 'withIssues', 'issueCount', 'issueDeviceCount', 'devicesWithIssues' + ) -DefaultValue $null + $candidateFixed = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @( + 'issueRemediatedDeviceCount', 'issueFixed', 'issueFixedCount', 'issuesFixed', 'issueRemediatedCount' + ) -DefaultValue $null + $candidateRecurred = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @( + 'issueReoccurredDeviceCount', 'issueRecurred', 'issueRecurredCount', 'recurredIssueCount', 'issueRecurredDevicesCount' + ) -DefaultValue $null + $candidateTotalRemediated = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @( + 'issueRemediatedCumulativeDeviceCount', 'totalRemediated', 'remediatedCount', 'remediatedDeviceCount', 'devicesRemediated' + ) -DefaultValue $null + + if ($null -ne $candidateNoIssue -or $null -ne $candidateWithIssue -or $null -ne $candidateFixed -or $null -ne $candidateRecurred -or $null -ne $candidateTotalRemediated) { + $resolvedSummary = $candidate + break + } + } + + if ($null -ne $resolvedSummary) { + $summary = $resolvedSummary + } + + $status = Get-FirstPropertyValue -InputObject $script -PropertyNames @( + 'status' + ) -DefaultValue $null + + if ([string]::IsNullOrWhiteSpace([string]$status)) { + $status = Get-FirstPropertyValue -InputObject $summary -PropertyNames @( + 'status', + 'remediationStatus', + 'scriptExecutionStatus' + ) -DefaultValue $null + } + + $withoutIssues = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( + 'noIssueDetectedDeviceCount', 'withoutIssues', 'noIssueCount', 'noIssueDeviceCount', 'devicesWithoutIssues' + ) -DefaultValue 0) + $withIssues = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( + 'issueDetectedDeviceCount', 'withIssues', 'issueCount', 'issueDeviceCount', 'devicesWithIssues' + ) -DefaultValue 0) + $issueFixed = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( + 'issueRemediatedDeviceCount', 'issueFixed', 'issueFixedCount', 'issuesFixed', 'issueRemediatedCount' + ) -DefaultValue 0) + $issueRecurred = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( + 'issueReoccurredDeviceCount', 'issueRecurred', 'issueRecurredCount', 'recurredIssueCount', 'issueRecurredDevicesCount' + ) -DefaultValue 0) + $totalRemediated = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( + 'issueRemediatedCumulativeDeviceCount', 'totalRemediated', 'remediatedCount', 'remediatedDeviceCount', 'devicesRemediated' + ) -DefaultValue 0) + + if ($withoutIssues -eq 0 -and $withIssues -eq 0 -and $issueFixed -eq 0 -and $issueRecurred -eq 0 -and $totalRemediated -eq 0) { + $historyUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$($script.id)/getRemediationHistory" + try { + $historyResponse = Invoke-GraphGet -Uri $historyUri + + $historyRoot = $historyResponse + if ($null -ne $historyResponse.value) { + $historyRoot = $historyResponse.value + } + + $historyData = @() + if ($null -ne $historyRoot.historyData) { + $historyData = @($historyRoot.historyData) + } elseif ($historyRoot -is [array]) { + $historyData = @($historyRoot) + } elseif ($null -ne $historyRoot.date) { + $historyData = @($historyRoot) + } + + $latestHistory = $null + if ($historyData.Count -gt 0) { + $latestHistory = $historyData | + Sort-Object -Property date -Descending | + Select-Object -First 1 + } + + if ($null -ne $latestHistory) { + $historyNoIssue = [int](Get-FirstPropertyValue -InputObject $latestHistory -PropertyNames @('noIssueDeviceCount', 'noIssueCount', 'withoutIssues') -DefaultValue 0) + $historyDetectFailed = [int](Get-FirstPropertyValue -InputObject $latestHistory -PropertyNames @('detectFailedDeviceCount', 'issueDetectedDeviceCount', 'issueCount', 'withIssues') -DefaultValue 0) + $historyRemediated = [int](Get-FirstPropertyValue -InputObject $latestHistory -PropertyNames @('remediatedDeviceCount', 'issueRemediatedDeviceCount', 'issueFixedCount', 'issueFixed') -DefaultValue 0) + + if ($historyNoIssue -eq 0 -and $historyDetectFailed -eq 0 -and $historyRemediated -eq 0 -and $historyData.Count -gt 0) { + $historyNoIssue = ($historyData | ForEach-Object { + [int](Get-FirstPropertyValue -InputObject $_ -PropertyNames @('noIssueDeviceCount', 'noIssueCount', 'withoutIssues') -DefaultValue 0) + } | Measure-Object -Maximum).Maximum + + $historyDetectFailed = ($historyData | ForEach-Object { + [int](Get-FirstPropertyValue -InputObject $_ -PropertyNames @('detectFailedDeviceCount', 'issueDetectedDeviceCount', 'issueCount', 'withIssues') -DefaultValue 0) + } | Measure-Object -Maximum).Maximum + + $historyRemediated = ($historyData | ForEach-Object { + [int](Get-FirstPropertyValue -InputObject $_ -PropertyNames @('remediatedDeviceCount', 'issueRemediatedDeviceCount', 'issueFixedCount', 'issueFixed') -DefaultValue 0) + } | Measure-Object -Sum).Sum + } + + $withoutIssues = $historyNoIssue + $issueFixed = $historyRemediated + if ($totalRemediated -eq 0) { + $totalRemediated = $historyRemediated + } + if ($withIssues -eq 0) { + $withIssues = $historyRemediated + $historyDetectFailed + } + } + + if ($null -ne $historyRoot.lastModifiedDateTime -and [string]::IsNullOrWhiteSpace([string]$status)) { + $status = 'Completed' + } + } catch { + Write-Verbose -Message "No remediation history fallback available for '$scriptName' ($($script.id)): $($_.Exception.Message)" + } + } + + if ([string]::IsNullOrWhiteSpace([string]$status)) { + $pendingCount = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( + 'detectionScriptPendingDeviceCount' + ) -DefaultValue 0) + $lastRun = Get-FirstPropertyValue -InputObject $summary -PropertyNames @( + 'lastScriptRunDateTime' + ) -DefaultValue $null + + if ($pendingCount -gt 0) { + $status = 'Pending' + } elseif ($null -ne $lastRun -and -not [string]::IsNullOrWhiteSpace([string]$lastRun)) { + $status = 'Completed' + } else { + $status = 'Unknown' + } + } + + [PSCustomObject]@{ + Name = [string]$scriptName + Status = [string]$status + WithoutIssues = $withoutIssues + WithIssues = $withIssues + IssueFixed = $issueFixed + IssueRecurred = $issueRecurred + TotalRemediated = $totalRemediated + } + } + } # Process +} # Cmdlet diff --git a/tests/private/Get-FirstPropertyValue.Tests.ps1 b/tests/private/Get-FirstPropertyValue.Tests.ps1 new file mode 100644 index 0000000..d37557e --- /dev/null +++ b/tests/private/Get-FirstPropertyValue.Tests.ps1 @@ -0,0 +1,78 @@ +BeforeAll { + # Import the module functions + $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $PrivateFunctionsPath = Join-Path -Path $ModuleRoot -ChildPath 'src\functions\private' + + # Dot-source the function we're testing + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Get-FirstPropertyValue.ps1') +} + +Describe 'Get-FirstPropertyValue' { + It 'Should return the first populated direct property value' { + # Arrange + $inputObject = [PSCustomObject]@{ + displayName = '' + name = 'Device remediation' + } + + # Act + $result = Get-FirstPropertyValue -InputObject $inputObject -PropertyNames @('displayName', 'name') + + # Assert + $result | Should -Be 'Device remediation' + } + + It 'Should return a dictionary value when present' { + # Arrange + $inputObject = @{ + noIssueDetectedDeviceCount = 12 + } + + # Act + $result = Get-FirstPropertyValue -InputObject $inputObject -PropertyNames @('noIssueDetectedDeviceCount') + + # Assert + $result | Should -Be 12 + } + + It 'Should resolve values from AdditionalProperties' { + # Arrange + $inputObject = [PSCustomObject]@{ + AdditionalProperties = @{ + issueDetectedDeviceCount = 5 + } + } + + # Act + $result = Get-FirstPropertyValue -InputObject $inputObject -PropertyNames @('issueDetectedDeviceCount') + + # Assert + $result | Should -Be 5 + } + + It 'Should return zero when zero is a valid value' { + # Arrange + $inputObject = [PSCustomObject]@{ + issueRemediatedDeviceCount = 0 + } + + # Act + $result = Get-FirstPropertyValue -InputObject $inputObject -PropertyNames @('issueRemediatedDeviceCount') -DefaultValue 99 + + # Assert + $result | Should -Be 0 + } + + It 'Should return the default value when nothing is populated' { + # Arrange + $inputObject = [PSCustomObject]@{ + displayName = ' ' + } + + # Act + $result = Get-FirstPropertyValue -InputObject $inputObject -PropertyNames @('displayName', 'name') -DefaultValue 'fallback' + + # Assert + $result | Should -Be 'fallback' + } +} diff --git a/tests/public/Remediation/Get-IntuneRemediationDeviceStatus.Tests.ps1 b/tests/public/Remediation/Get-IntuneRemediationDeviceStatus.Tests.ps1 new file mode 100644 index 0000000..a43a28d --- /dev/null +++ b/tests/public/Remediation/Get-IntuneRemediationDeviceStatus.Tests.ps1 @@ -0,0 +1,397 @@ +BeforeAll { + $ModuleRoot = Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent + $PublicFunctionPath = Join-Path -Path $ModuleRoot -ChildPath 'src\functions\public\Remediation\Get-IntuneRemediationDeviceStatus.ps1' + $PrivateFunctionsPath = Join-Path -Path $ModuleRoot -ChildPath 'src\functions\private' + + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Invoke-GraphGet.ps1') + . $PublicFunctionPath +} + +Describe 'Get-IntuneRemediationDeviceStatus' { + + Context 'Parameter set ByName — matching name' { + It 'Should return one row per device with mapped fields' { + # Arrange + $mockScriptId = 'f1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockScriptName = 'BitLocker detection and remediation' + + $mockListResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = $mockScriptId; displayName = $mockScriptName } + ) + } + + $mockRunStatesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = 'state-001' + detectionState = 'success' + remediationState = 'success' + lastStateUpdateDateTime = '2026-03-12T08:00:00Z' + preRemediationDetectionScriptOutput = 'BitLocker OFF' + postRemediationDetectionScriptOutput = 'BitLocker ON' + detectionScriptOutput = $null + preRemediationDetectionScriptError = $null + remediationScriptError = $null + detectionScriptError = $null + managedDevice = [PSCustomObject]@{ + id = 'dev-001' + deviceName = 'LAPTOP-001' + userPrincipalName = 'alice@contoso.com' + } + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'deviceHealthScripts\?\$select=id,displayName$') { + return $mockListResponse + } + if ($Uri -match '/deviceHealthScripts/.+/deviceRunStates') { + return $mockRunStatesResponse + } + throw "Unexpected URI: $Uri" + } + + # Act + $result = @(Get-IntuneRemediationDeviceStatus -Name 'BitLocker*') + + # Assert + $result.Count | Should -Be 1 + $result[0].RemediationName | Should -Be $mockScriptName + $result[0].RemediationId | Should -Be $mockScriptId + $result[0].DeviceId | Should -Be 'dev-001' + $result[0].DeviceName | Should -Be 'LAPTOP-001' + $result[0].UserPrincipalName | Should -Be 'alice@contoso.com' + $result[0].DetectionState | Should -Be 'success' + $result[0].RemediationState | Should -Be 'success' + $result[0].PreRemediationOutput | Should -Be 'BitLocker OFF' + $result[0].PostRemediationOutput | Should -Be 'BitLocker ON' + $result[0].LastStateUpdate | Should -BeOfType [datetime] + } + + It 'Should return multiple rows when multiple devices have run states' { + # Arrange + $mockScriptId = 'a1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockScriptName = 'Windows Update remediation' + + $mockListResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = $mockScriptId; displayName = $mockScriptName } + ) + } + + $mockRunStatesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = 'state-A' + detectionState = 'success' + remediationState = 'skipped' + lastStateUpdateDateTime = '2026-03-12T07:00:00Z' + preRemediationDetectionScriptOutput = 'WU enabled' + postRemediationDetectionScriptOutput = $null + detectionScriptOutput = $null + preRemediationDetectionScriptError = $null + remediationScriptError = $null + detectionScriptError = $null + managedDevice = [PSCustomObject]@{ + id = 'dev-A' + deviceName = 'PC-A' + userPrincipalName = 'bob@contoso.com' + } + }, + [PSCustomObject]@{ + id = 'state-B' + detectionState = 'fail' + remediationState = 'remediationFailed' + lastStateUpdateDateTime = '2026-03-11T20:00:00Z' + preRemediationDetectionScriptOutput = 'WU disabled' + postRemediationDetectionScriptOutput = 'WU still disabled' + detectionScriptOutput = $null + preRemediationDetectionScriptError = $null + remediationScriptError = 'Exit code 1' + detectionScriptError = $null + managedDevice = [PSCustomObject]@{ + id = 'dev-B' + deviceName = 'PC-B' + userPrincipalName = 'carol@contoso.com' + } + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'deviceHealthScripts\?\$select=id,displayName$') { + return $mockListResponse + } + if ($Uri -match '/deviceHealthScripts/.+/deviceRunStates') { + return $mockRunStatesResponse + } + throw "Unexpected URI: $Uri" + } + + # Act + $result = @(Get-IntuneRemediationDeviceStatus -Name $mockScriptName) + + # Assert + $result.Count | Should -Be 2 + $result[0].DeviceName | Should -Be 'PC-A' + $result[0].RemediationState | Should -Be 'skipped' + $result[1].DeviceName | Should -Be 'PC-B' + $result[1].RemediationError | Should -Be 'Exit code 1' + } + + It 'Should write a non-terminating error and return nothing when no script matches the name' { + # Arrange + $mockListResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'some-id'; displayName = 'Other remediation' } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'deviceHealthScripts\?\$select=id,displayName$') { + return $mockListResponse + } + throw "Unexpected URI: $Uri" + } + + # Act + $result = @(Get-IntuneRemediationDeviceStatus -Name 'NonExistent*' -ErrorAction SilentlyContinue -ErrorVariable notFoundError) + + # Assert + $result.Count | Should -Be 0 + $notFoundError | Should -Not -BeNullOrEmpty + $notFoundError[0].FullyQualifiedErrorId | Should -Match 'RemediationNotFound' + } + } + + Context 'Parameter set ById' { + It 'Should query by ID and resolve the display name' { + # Arrange + $mockScriptId = 'b1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockScriptName = 'Defender remediation' + + $mockDetailResponse = [PSCustomObject]@{ + id = $mockScriptId + displayName = $mockScriptName + } + + $mockRunStatesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = 'state-X' + detectionState = 'success' + remediationState = 'success' + lastStateUpdateDateTime = '2026-03-12T09:00:00Z' + preRemediationDetectionScriptOutput = 'Defender off' + postRemediationDetectionScriptOutput = 'Defender on' + detectionScriptOutput = $null + preRemediationDetectionScriptError = $null + remediationScriptError = $null + detectionScriptError = $null + managedDevice = [PSCustomObject]@{ + id = 'dev-X' + deviceName = 'WKSTN-001' + userPrincipalName = 'dave@contoso.com' + } + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match "/deviceHealthScripts/$mockScriptId\?\`$select=id,displayName$") { + return $mockDetailResponse + } + if ($Uri -match '/deviceHealthScripts/.+/deviceRunStates') { + return $mockRunStatesResponse + } + throw "Unexpected URI: $Uri" + } + + # Act + $result = @(Get-IntuneRemediationDeviceStatus -Id $mockScriptId) + + # Assert + $result.Count | Should -Be 1 + $result[0].RemediationName | Should -Be $mockScriptName + $result[0].RemediationId | Should -Be $mockScriptId + $result[0].DeviceName | Should -Be 'WKSTN-001' + $result[0].PreRemediationOutput | Should -Be 'Defender off' + $result[0].PostRemediationOutput | Should -Be 'Defender on' + } + + It 'Should still return results if fetching display name fails' { + # Arrange + $mockScriptId = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + + $mockRunStatesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = 'state-Y' + detectionState = 'fail' + remediationState = 'noScriptContent' + lastStateUpdateDateTime = '2026-03-10T12:00:00Z' + preRemediationDetectionScriptOutput = $null + postRemediationDetectionScriptOutput = $null + detectionScriptOutput = $null + preRemediationDetectionScriptError = 'Script error' + remediationScriptError = $null + detectionScriptError = $null + managedDevice = [PSCustomObject]@{ + id = 'dev-Y' + deviceName = 'SRV-001' + userPrincipalName = $null + } + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match "\?\`$select=id,displayName$") { + throw 'Name lookup failed' + } + if ($Uri -match '/deviceHealthScripts/.+/deviceRunStates') { + return $mockRunStatesResponse + } + throw "Unexpected URI: $Uri" + } + + # Act / Assert — should not throw, falls back to ID as RemediationName + $result = @(Get-IntuneRemediationDeviceStatus -Id $mockScriptId -WarningAction SilentlyContinue) + + $result.Count | Should -Be 1 + $result[0].RemediationId | Should -Be $mockScriptId + $result[0].PreRemediationError | Should -Be 'Script error' + } + } + + Context 'When Graph request fails' { + It 'Should throw with RemediationListFailed when the list request fails generically' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { + throw 'Graph list failure' + } + + # Act + $err = $null + try { Get-IntuneRemediationDeviceStatus -Name '*' -ErrorAction Stop } catch { $err = $_ } + + # Assert + $err | Should -Not -BeNullOrEmpty + $err.FullyQualifiedErrorId | Should -Match 'RemediationListFailed' + } + + It 'Should throw with RemediationListAccessDenied when the list request returns 403 Forbidden' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { + $Exception = [Exception]::new('Graph request failed: Forbidden access denied (403)') + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'GraphRequestFailed', + [System.Management.Automation.ErrorCategory]::PermissionDenied, + $null + ) + throw $ErrorRecord + } + + # Act + $err = $null + try { Get-IntuneRemediationDeviceStatus -Name '*' -ErrorAction Stop } catch { $err = $_ } + + # Assert + $err | Should -Not -BeNullOrEmpty + $err.FullyQualifiedErrorId | Should -Match 'RemediationListAccessDenied' + } + + It 'Should emit a warning and skip if deviceRunStates fails, not throw' { + # Arrange + $mockScriptId = 'd1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + + $mockListResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = $mockScriptId; displayName = 'Failing remediation' } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'deviceHealthScripts\?\$select=id,displayName$') { + return $mockListResponse + } + if ($Uri -match '/deviceHealthScripts/.+/deviceRunStates') { + throw 'Run states error' + } + throw "Unexpected URI: $Uri" + } + + # Act — should NOT throw + $result = @(Get-IntuneRemediationDeviceStatus -Name '*' -WarningAction SilentlyContinue) + + # Assert + $result.Count | Should -Be 0 + } + } + + Context 'Pipeline input' { + It 'Should accept Name via pipeline by property name' { + # Arrange + $mockScriptId = 'e1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockScriptName = 'Pipeline input remediation' + + $mockListResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = $mockScriptId; displayName = $mockScriptName } + ) + } + + $mockRunStatesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = 'state-P' + detectionState = 'success' + remediationState = 'success' + lastStateUpdateDateTime = '2026-03-12T10:00:00Z' + preRemediationDetectionScriptOutput = 'Before' + postRemediationDetectionScriptOutput = 'After' + detectionScriptOutput = $null + preRemediationDetectionScriptError = $null + remediationScriptError = $null + detectionScriptError = $null + managedDevice = [PSCustomObject]@{ + id = 'dev-P' + deviceName = 'PC-PIPE' + userPrincipalName = 'eve@contoso.com' + } + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'deviceHealthScripts\?\$select=id,displayName$') { + return $mockListResponse + } + if ($Uri -match '/deviceHealthScripts/.+/deviceRunStates') { + return $mockRunStatesResponse + } + throw "Unexpected URI: $Uri" + } + + # Act — pipe an object whose .Name property matches the -Name parameter + $pipeInput = [PSCustomObject]@{ Name = $mockScriptName } + $result = @($pipeInput | Get-IntuneRemediationDeviceStatus) + + # Assert + $result.Count | Should -Be 1 + $result[0].DeviceName | Should -Be 'PC-PIPE' + $result[0].PreRemediationOutput | Should -Be 'Before' + $result[0].PostRemediationOutput | Should -Be 'After' + } + } +} diff --git a/tests/public/Remediation/Get-IntuneRemediationSummary.Tests.ps1 b/tests/public/Remediation/Get-IntuneRemediationSummary.Tests.ps1 new file mode 100644 index 0000000..bdf1082 --- /dev/null +++ b/tests/public/Remediation/Get-IntuneRemediationSummary.Tests.ps1 @@ -0,0 +1,379 @@ +BeforeAll { + # Import the module functions + $ModuleRoot = Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent + $PublicFunctionPath = Join-Path -Path $ModuleRoot -ChildPath 'src\functions\public\Remediation\Get-IntuneRemediationSummary.ps1' + $PrivateFunctionsPath = Join-Path -Path $ModuleRoot -ChildPath 'src\functions\private' + + # Dot-source private dependency + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Invoke-GraphGet.ps1') + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Get-FirstPropertyValue.ps1') + + # Dot-source function under test + . $PublicFunctionPath +} + +Describe 'Get-IntuneRemediationSummary' { + Context 'When Graph responses are valid' { + It 'Should return one row per remediation with mapped counters' { + # Arrange + $mockScriptId = 'f1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockScriptName = 'BitLocker detection and remediation' + + $mockScriptsResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $mockScriptId + displayName = $mockScriptName + } + ) + } + + $mockSummaryResponse = [PSCustomObject]@{ + noIssueDetectedDeviceCount = 12 + issueDetectedDeviceCount = 5 + issueRemediatedDeviceCount = 4 + issueReoccurredDeviceCount = 1 + issueRemediatedCumulativeDeviceCount = 4 + lastScriptRunDateTime = '2026-03-12T08:00:00Z' + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'deviceHealthScripts\?\$select=id,displayName$') { + return $mockScriptsResponse + } + + if ($Uri -match '/deviceHealthScripts/.+/runSummary$') { + return $mockSummaryResponse + } + + throw "Unexpected URI: $Uri" + } + + # Act + $result = @(Get-IntuneRemediationSummary) + + # Assert + $result.Count | Should -Be 1 + $result[0].Name | Should -Be $mockScriptName + $result[0].Status | Should -Be 'Completed' + $result[0].WithoutIssues | Should -Be 12 + $result[0].WithIssues | Should -Be 5 + $result[0].IssueFixed | Should -Be 4 + $result[0].IssueRecurred | Should -Be 1 + $result[0].TotalRemediated | Should -Be 4 + } + + It 'Should default counters to 0 and status to Unknown when fields are missing' { + # Arrange + $mockScriptId = 'a1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockScriptsResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $mockScriptId + displayName = 'Windows Defender remediation' + } + ) + } + + $mockSummaryResponse = [PSCustomObject]@{} + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'deviceHealthScripts\?\$select=id,displayName$') { + return $mockScriptsResponse + } + + if ($Uri -match '/deviceHealthScripts/.+/runSummary$') { + return $mockSummaryResponse + } + + throw "Unexpected URI: $Uri" + } + + # Act + $result = @(Get-IntuneRemediationSummary) + + # Assert + $result.Count | Should -Be 1 + $result[0].Status | Should -Be 'Unknown' + $result[0].WithoutIssues | Should -Be 0 + $result[0].WithIssues | Should -Be 0 + $result[0].IssueFixed | Should -Be 0 + $result[0].IssueRecurred | Should -Be 0 + $result[0].TotalRemediated | Should -Be 0 + } + + It 'Should handle nested runSummary payload shape' { + # Arrange + $mockScriptId = 'b1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockScriptName = 'Nested payload remediation' + + $mockScriptsResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $mockScriptId + displayName = $mockScriptName + } + ) + } + + $mockSummaryResponse = [PSCustomObject]@{ + value = [PSCustomObject]@{ + runSummary = [PSCustomObject]@{ + noIssueDetectedDeviceCount = 3 + issueDetectedDeviceCount = 2 + issueRemediatedDeviceCount = 1 + issueReoccurredDeviceCount = 1 + issueRemediatedCumulativeDeviceCount = 7 + lastScriptRunDateTime = '2026-03-12T09:00:00Z' + } + } + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'deviceHealthScripts\?\$select=id,displayName$') { + return $mockScriptsResponse + } + + if ($Uri -match '/deviceHealthScripts/.+/runSummary$') { + return $mockSummaryResponse + } + + throw "Unexpected URI: $Uri" + } + + # Act + $result = @(Get-IntuneRemediationSummary) + + # Assert + $result.Count | Should -Be 1 + $result[0].Name | Should -Be $mockScriptName + $result[0].WithoutIssues | Should -Be 3 + $result[0].WithIssues | Should -Be 2 + $result[0].IssueFixed | Should -Be 1 + $result[0].IssueRecurred | Should -Be 1 + $result[0].TotalRemediated | Should -Be 7 + } + + It 'Should fall back to remediation history when runSummary has no counters' { + # Arrange + $mockScriptId = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockScriptName = 'History fallback remediation' + + $mockScriptsResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $mockScriptId + displayName = $mockScriptName + } + ) + } + + $mockSummaryResponse = [PSCustomObject]@{} + + $mockHistoryResponse = [PSCustomObject]@{ + value = [PSCustomObject]@{ + lastModifiedDateTime = '2026-03-12T10:00:00Z' + historyData = @( + [PSCustomObject]@{ + date = '2026-03-12' + remediatedDeviceCount = 6 + noIssueDeviceCount = 9 + detectFailedDeviceCount = 2 + } + ) + } + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'deviceHealthScripts\?\$select=id,displayName$') { + return $mockScriptsResponse + } + + if ($Uri -match '/deviceHealthScripts/.+/runSummary$') { + return $mockSummaryResponse + } + + if ($Uri -match '/deviceHealthScripts/.+/getRemediationHistory$') { + return $mockHistoryResponse + } + + throw "Unexpected URI: $Uri" + } + + # Act + $result = @(Get-IntuneRemediationSummary) + + # Assert + $result.Count | Should -Be 1 + $result[0].Name | Should -Be $mockScriptName + $result[0].Status | Should -Be 'Completed' + $result[0].WithoutIssues | Should -Be 9 + $result[0].WithIssues | Should -Be 8 + $result[0].IssueFixed | Should -Be 6 + $result[0].IssueRecurred | Should -Be 0 + $result[0].TotalRemediated | Should -Be 6 + } + + It 'Should aggregate remediation history when latest entry is zero' { + # Arrange + $mockScriptId = 'd1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockScriptName = 'History aggregate remediation' + + $mockScriptsResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $mockScriptId + displayName = $mockScriptName + } + ) + } + + $mockSummaryResponse = [PSCustomObject]@{} + + $mockHistoryResponse = [PSCustomObject]@{ + value = [PSCustomObject]@{ + lastModifiedDateTime = '2026-03-12T10:00:00Z' + historyData = @( + [PSCustomObject]@{ + date = '2026-03-12' + remediatedDeviceCount = 0 + noIssueDeviceCount = 0 + detectFailedDeviceCount = 0 + }, + [PSCustomObject]@{ + date = '2026-03-11' + remediatedDeviceCount = 4 + noIssueDeviceCount = 7 + detectFailedDeviceCount = 1 + } + ) + } + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'deviceHealthScripts\?\$select=id,displayName$') { + return $mockScriptsResponse + } + + if ($Uri -match '/deviceHealthScripts/.+/runSummary$') { + return $mockSummaryResponse + } + + if ($Uri -match '/deviceHealthScripts/.+/getRemediationHistory$') { + return $mockHistoryResponse + } + + throw "Unexpected URI: $Uri" + } + + # Act + $result = @(Get-IntuneRemediationSummary) + + # Assert + $result.Count | Should -Be 1 + $result[0].Name | Should -Be $mockScriptName + $result[0].Status | Should -Be 'Completed' + $result[0].WithoutIssues | Should -Be 7 + $result[0].WithIssues | Should -Be 5 + $result[0].IssueFixed | Should -Be 4 + $result[0].IssueRecurred | Should -Be 0 + $result[0].TotalRemediated | Should -Be 4 + } + + It 'Should resolve counters from AdditionalProperties payloads' { + # Arrange + $mockScriptId = 'e1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockScriptName = 'AdditionalProperties remediation' + + $mockScriptsResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $mockScriptId + displayName = $mockScriptName + } + ) + } + + $mockSummaryResponse = [PSCustomObject]@{ + AdditionalProperties = @{ + noIssueDetectedDeviceCount = 11 + issueDetectedDeviceCount = 3 + issueRemediatedDeviceCount = 2 + issueReoccurredDeviceCount = 1 + issueRemediatedCumulativeDeviceCount = 9 + lastScriptRunDateTime = '2026-03-12T11:00:00Z' + } + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'deviceHealthScripts\?\$select=id,displayName$') { + return $mockScriptsResponse + } + + if ($Uri -match '/deviceHealthScripts/.+/runSummary$') { + return $mockSummaryResponse + } + + throw "Unexpected URI: $Uri" + } + + # Act + $result = @(Get-IntuneRemediationSummary) + + # Assert + $result.Count | Should -Be 1 + $result[0].Name | Should -Be $mockScriptName + $result[0].Status | Should -Be 'Completed' + $result[0].WithoutIssues | Should -Be 11 + $result[0].WithIssues | Should -Be 3 + $result[0].IssueFixed | Should -Be 2 + $result[0].IssueRecurred | Should -Be 1 + $result[0].TotalRemediated | Should -Be 9 + } + } + + Context 'When Graph request fails' { + It 'Should throw with RemediationListFailed when the list request fails generically' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { + throw 'Graph list failure' + } + + # Act + $err = $null + try { Get-IntuneRemediationSummary -ErrorAction Stop } catch { $err = $_ } + + # Assert + $err | Should -Not -BeNullOrEmpty + $err.FullyQualifiedErrorId | Should -Match 'RemediationListFailed' + } + + It 'Should throw with RemediationListAccessDenied when the list request returns 403 Forbidden' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { + $Exception = [Exception]::new('Graph request failed: Forbidden access denied (403)') + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'GraphRequestFailed', + [System.Management.Automation.ErrorCategory]::PermissionDenied, + $null + ) + throw $ErrorRecord + } + + # Act + $err = $null + try { Get-IntuneRemediationSummary -ErrorAction Stop } catch { $err = $_ } + + # Assert + $err | Should -Not -BeNullOrEmpty + $err.FullyQualifiedErrorId | Should -Match 'RemediationListAccessDenied' + } + } +}