From 1db7a4781c5c0aa4bbcb19817c168c2684d29d92 Mon Sep 17 00:00:00 2001 From: Frederik Hjorslev Nylander Date: Thu, 19 Mar 2026 22:57:09 +0100 Subject: [PATCH 1/6] Add Get-IntuneDevice --- .../private/ConvertTo-IntuneDeviceSummary.ps1 | 56 +++++ .../private/Resolve-IntuneDeviceByName.ps1 | 70 +++++- .../public/Device/Get-IntuneDevice.ps1 | 187 +++++++++++++++ .../Resolve-IntuneDeviceByName.Tests.ps1 | 223 ++++++++++++++++++ .../public/Device/Get-IntuneDevice.Tests.ps1 | 113 +++++++++ 5 files changed, 638 insertions(+), 11 deletions(-) create mode 100644 src/functions/private/ConvertTo-IntuneDeviceSummary.ps1 create mode 100644 src/functions/public/Device/Get-IntuneDevice.ps1 create mode 100644 tests/private/Resolve-IntuneDeviceByName.Tests.ps1 create mode 100644 tests/public/Device/Get-IntuneDevice.Tests.ps1 diff --git a/src/functions/private/ConvertTo-IntuneDeviceSummary.ps1 b/src/functions/private/ConvertTo-IntuneDeviceSummary.ps1 new file mode 100644 index 0000000..3f0db92 --- /dev/null +++ b/src/functions/private/ConvertTo-IntuneDeviceSummary.ps1 @@ -0,0 +1,56 @@ +function ConvertTo-IntuneDeviceSummary { + <# + .SYNOPSIS + Converts a managed device Graph object to the module's Intune device summary shape. + + .DESCRIPTION + Maps selected managed device properties to a stable output object used by Get-IntuneDevice. + Expects any enrichment (for example EnrolledBy resolution) to be done before conversion. + + .PARAMETER Device + A Microsoft Graph managed device object. + + .EXAMPLE + $device | ConvertTo-IntuneDeviceSummary + + Converts each input device object to the standardized output object. + + .INPUTS + System.Object + + .OUTPUTS + PSCustomObject + #> + + [OutputType([PSCustomObject])] + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [ValidateNotNull()] + [object]$Device + ) + + process { + $lastSyncDateTime = $null + # Keep output type stable even if Graph returns an unexpected timestamp format. + if ($null -ne $Device.lastSyncDateTime -and -not [string]::IsNullOrWhiteSpace([string]$Device.lastSyncDateTime)) { + try { + $lastSyncDateTime = [datetime]$Device.lastSyncDateTime + } catch { + $lastSyncDateTime = $null + } + } + + # Stable output contract consumed by Get-IntuneDevice callers. + [PSCustomObject]@{ + DeviceName = [string]$Device.deviceName + PrimaryUser = [string]$Device.userPrincipalName + DeviceManufacturer = [string]$Device.manufacturer + DeviceModel = [string]$Device.model + OperatingSystem = [string]$Device.operatingSystem + SerialNumber = [string]$Device.serialNumber + Compliance = [string]$Device.complianceState + LastSyncDateTime = $lastSyncDateTime + } + } # Process +} # Cmdlet diff --git a/src/functions/private/Resolve-IntuneDeviceByName.ps1 b/src/functions/private/Resolve-IntuneDeviceByName.ps1 index 2c04a79..2e7207b 100644 --- a/src/functions/private/Resolve-IntuneDeviceByName.ps1 +++ b/src/functions/private/Resolve-IntuneDeviceByName.ps1 @@ -46,23 +46,71 @@ } process { - # deviceName is case-insensitive in OData. Exact match. - $encoded = [uri]::EscapeDataString("deviceName eq '$Name'") - $uri = "$baseUri`?`$filter=$encoded&`$select=id,deviceName" + # Escape only the string literal content; keep OData filter syntax intact. + $escapedName = $Name.Replace("'", "''") + $filter = "deviceName eq '$escapedName'" + $select = 'id,deviceName,userPrincipalName,manufacturer,model,operatingSystem,serialNumber,enrolledByUserId,complianceState,lastSyncDateTime' + $candidateUris = @( + "$baseUri`?`$filter=$filter&`$select=$select", + "$baseUri`?`$filter=$filter", + "$baseUri`?`$select=$select", + $baseUri + ) - $resp = Invoke-GraphGet -Uri $uri + $resp = $null + $lastBadRequestError = $null - if ($null -eq $resp.value -or $resp.value.Count -eq 0) { + foreach ($candidateUri in $candidateUris) { + try { + $resp = Invoke-GraphGet -Uri $candidateUri + break + } catch { + $errorMessage = $_.Exception.Message + if ($errorMessage -match 'BadRequest|400') { + $lastBadRequestError = $_ + Write-Verbose -Message "Managed device query returned BadRequest for URI '$candidateUri'. Trying next fallback." + continue + } + + throw + } + } + + if ($null -eq $resp -and $null -ne $lastBadRequestError) { + throw $lastBadRequestError + } + + $devices = @() + if ($null -ne $resp) { + if ($null -ne $resp.value) { + $devices = @($resp.value) + } else { + $devices = @($resp) + } + } + + # Keep exact name semantics even after fallback to unfiltered Graph query. + $matchedDevices = @($devices | Where-Object -FilterScript { [string]$_.deviceName -ieq $Name }) + + if ($matchedDevices.Count -eq 0) { Write-Verbose -Message "No managed devices found with deviceName '$Name'." return [PSCustomObject[]]@() } - # Return PSCustomObject with Id and DeviceName - $resp.value | ForEach-Object -Process { + # Return managed device objects with fields required by downstream callers. + $matchedDevices | ForEach-Object -Process { [PSCustomObject]@{ - Id = $_.id - DeviceName = $_.deviceName + id = $_.id + deviceName = $_.deviceName + userPrincipalName = $_.userPrincipalName + manufacturer = $_.manufacturer + model = $_.model + operatingSystem = $_.operatingSystem + serialNumber = $_.serialNumber + enrolledByUserId = $_.enrolledByUserId + complianceState = $_.complianceState + lastSyncDateTime = $_.lastSyncDateTime } } - } -} + } # Process +} # Cmdlet diff --git a/src/functions/public/Device/Get-IntuneDevice.ps1 b/src/functions/public/Device/Get-IntuneDevice.ps1 new file mode 100644 index 0000000..0eef32e --- /dev/null +++ b/src/functions/public/Device/Get-IntuneDevice.ps1 @@ -0,0 +1,187 @@ +#Requires -Modules @{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.28.0' } + +function Get-IntuneDevice { + <# + .SYNOPSIS + Retrieves Intune managed device details by DeviceId or DeviceName. + + .DESCRIPTION + Queries Microsoft Graph (beta) for managed device details and returns a compact device summary. + Supports lookup by managed device ID or by device name. + + Requires an authenticated Graph session with appropriate scopes. + + Scopes (minimum): + - DeviceManagementManagedDevices.Read.All + + .PARAMETER DeviceId + The Intune managed device identifier (GUID). Parameter set: ById. + + .PARAMETER DeviceName + The device name to resolve in Intune managed devices. Parameter set: ByName. + If multiple devices share the same name, all matches are returned. + + .EXAMPLE + Connect-MgGraph -Scopes "DeviceManagementManagedDevices.Read.All","User.Read.All" + Get-IntuneDevice -DeviceId "c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a" + + Retrieves summary details for the specified managed device. + + .EXAMPLE + Get-IntuneDevice -DeviceName PC-001 + + Resolves device by name and returns summary details for each matching managed device. + + .INPUTS + System.String (DeviceId or DeviceName via pipeline/property name) + + .OUTPUTS + PSCustomObject with the following properties + - DeviceName (string) + - PrimaryUser (string) + - DeviceManufacturer (string) + - DeviceModel (string) + - OperatingSystem (string) + - SerialNumber (string) + - Compliance (string) + - LastSyncDateTime (datetime) + + .NOTES + Author: FHN & GitHub Copilot + Uses /beta Graph endpoints for managed device properties. + #> + + [OutputType([PSCustomObject])] + [CmdletBinding(DefaultParameterSetName = 'ById', SupportsShouldProcess = $false)] + param( + [Parameter( + ParameterSetName = 'ById', + Mandatory = $true, + ValueFromPipelineByPropertyName = $true + )] + [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('Id', 'ManagedDeviceId')] + [string]$DeviceId, + + [Parameter( + ParameterSetName = 'ByName', + Mandatory = $true, + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true + )] + [ValidateNotNullOrEmpty()] + [Alias('Name', 'ComputerName')] + [string]$DeviceName + ) + + begin { + $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' + # Request only fields needed for enrichment + output mapping. + $select = 'deviceName,userPrincipalName,manufacturer,model,operatingSystem,serialNumber,complianceState,lastSyncDateTime' + } + + process { + switch ($PSCmdlet.ParameterSetName) { + 'ById' { + Write-Verbose -Message "Resolving managed device by id: $DeviceId" + $uri = "$baseUri/$DeviceId?`$select=$select" + try { + $device = Invoke-GraphGet -Uri $uri + } catch { + $errorMessage = $_.Exception.Message + # Distinguish 404 (not found) from network/auth failures. + if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { + $exception = [Exception]::new("Managed device not found for id '$DeviceId': $errorMessage", $_.Exception) + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'DeviceNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $DeviceId + ) + $PSCmdlet.WriteError($errorRecord) + return + } + + $exception = [Exception]::new("Failed to resolve device id '$DeviceId': $errorMessage", $_.Exception) + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'DeviceLookupFailed', + [System.Management.Automation.ErrorCategory]::NotSpecified, + $DeviceId + ) + $PSCmdlet.ThrowTerminatingError($errorRecord) + } + + # Graph returned null; invalid or removed device. + if (-not $device) { + $exception = [Exception]::new("Managed device not found for id '$DeviceId'.") + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'DeviceNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $DeviceId + ) + $PSCmdlet.WriteError($errorRecord) + return + } + + # Map to public output contract. + ConvertTo-IntuneDeviceSummary -Device $device + } + + 'ByName' { + Write-Verbose -Message "Resolving managed device(s) by name: $DeviceName" + + try { + $deviceSummaries = Resolve-IntuneDeviceByName -Name $DeviceName + } catch { + $errorMessage = $_.Exception.Message + # Distinguish name resolution failure (no match) from actual Graph errors. + if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { + $exception = [Exception]::new("Managed device not found for name '$DeviceName': $errorMessage", $_.Exception) + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'DeviceNameNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $DeviceName + ) + $PSCmdlet.WriteError($errorRecord) + return + } + + $exception = [Exception]::new("Failed to resolve device name '$DeviceName': $errorMessage", $_.Exception) + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'DeviceNameLookupFailed', + [System.Management.Automation.ErrorCategory]::NotSpecified, + $DeviceName + ) + $PSCmdlet.ThrowTerminatingError($errorRecord) + } + + # Empty result set: name does not match any device. + if ($null -eq $deviceSummaries -or $deviceSummaries.Count -eq 0) { + $exception = [Exception]::new("Managed device not found for name '$DeviceName'.") + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'DeviceNameNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $DeviceName + ) + $PSCmdlet.WriteError($errorRecord) + return + } + + # Resolve-IntuneDeviceByName already returns selected managed device fields. + foreach ($device in $deviceSummaries) { + if (-not $device) { + continue + } + + # Map to public output contract. + ConvertTo-IntuneDeviceSummary -Device $device + } + } + } + } # Process +} # Cmdlet diff --git a/tests/private/Resolve-IntuneDeviceByName.Tests.ps1 b/tests/private/Resolve-IntuneDeviceByName.Tests.ps1 new file mode 100644 index 0000000..a6937f6 --- /dev/null +++ b/tests/private/Resolve-IntuneDeviceByName.Tests.ps1 @@ -0,0 +1,223 @@ +BeforeAll { + $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $PrivateFunctionsPath = Join-Path -Path $ModuleRoot -ChildPath 'src\functions\private' + + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Invoke-GraphGet.ps1') + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Resolve-IntuneDeviceByName.ps1') +} + +Describe 'Resolve-IntuneDeviceByName' { + Context 'When the filtered query succeeds and a match is found' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceName = 'PC-001' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $mockDevice = [PSCustomObject]@{ + id = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + deviceName = $testDeviceName + userPrincipalName = 'primary.user@contoso.com' + manufacturer = 'Dell' + model = 'Latitude 7440' + operatingSystem = 'Windows' + serialNumber = 'ABC123XYZ' + enrolledByUserId = 'd1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + complianceState = 'compliant' + lastSyncDateTime = '2026-03-18T08:30:00Z' + } + } + + It 'Should return a mapped PSCustomObject for the matching device' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($mockDevice) } } + + $result = Resolve-IntuneDeviceByName -Name $testDeviceName + + $result | Should -Not -BeNullOrEmpty + $result.id | Should -Be $mockDevice.id + $result.deviceName | Should -Be $testDeviceName + $result.userPrincipalName | Should -Be $mockDevice.userPrincipalName + $result.manufacturer | Should -Be $mockDevice.manufacturer + $result.model | Should -Be $mockDevice.model + $result.operatingSystem | Should -Be $mockDevice.operatingSystem + $result.serialNumber | Should -Be $mockDevice.serialNumber + $result.complianceState | Should -Be $mockDevice.complianceState + $result.lastSyncDateTime | Should -Be $mockDevice.lastSyncDateTime + } + + It 'Should call Invoke-GraphGet with the filtered and select URI first' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($mockDevice) } } + + Resolve-IntuneDeviceByName -Name $testDeviceName | Out-Null + + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 1 -Exactly -Scope It + Assert-MockCalled -CommandName 'Invoke-GraphGet' -ParameterFilter { + $Uri -match [regex]::Escape('$filter=') -and $Uri -match [regex]::Escape('$select=') + } -Times 1 -Exactly -Scope It + } + + It 'Should accept Name from the pipeline' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($mockDevice) } } + + $result = $testDeviceName | Resolve-IntuneDeviceByName + + $result | Should -Not -BeNullOrEmpty + $result.deviceName | Should -Be $testDeviceName + } + + It 'Should accept Name from pipeline by property name' { + $pipelineObject = [PSCustomObject]@{ Name = $testDeviceName } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($mockDevice) } } + + $result = $pipelineObject | Resolve-IntuneDeviceByName + + $result | Should -Not -BeNullOrEmpty + $result.deviceName | Should -Be $testDeviceName + } + + It 'Should handle a response where the device is returned directly (no value wrapper)' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + + $result = Resolve-IntuneDeviceByName -Name $testDeviceName + + $result | Should -Not -BeNullOrEmpty + $result.deviceName | Should -Be $testDeviceName + } + + It 'Should match device name case-insensitively' { + $upperNameDevice = $mockDevice.PSObject.Copy() + $upperNameDevice.deviceName = $testDeviceName.ToUpper() + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($upperNameDevice) } } + + $result = Resolve-IntuneDeviceByName -Name $testDeviceName.ToLower() + + $result | Should -Not -BeNullOrEmpty + $result.deviceName | Should -Be $testDeviceName.ToUpper() + } + + It 'Should return multiple devices when more than one match exists' { + $secondDevice = [PSCustomObject]@{ + id = 'a2b3c4d5-e6f7-4a8b-9c0d-1e2f3a4b5c6d' + deviceName = $testDeviceName + userPrincipalName = 'second.user@contoso.com' + manufacturer = 'HP' + model = 'EliteBook 840' + operatingSystem = 'Windows' + serialNumber = 'XYZ987ABC' + enrolledByUserId = '' + complianceState = 'noncompliant' + lastSyncDateTime = '2026-03-17T12:00:00Z' + } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($mockDevice, $secondDevice) } } + + $result = Resolve-IntuneDeviceByName -Name $testDeviceName + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 2 + } + } + + Context 'When the filtered query returns BadRequest (fallback behaviour)' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceName = 'PC-002' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $mockDevice = [PSCustomObject]@{ + id = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e' + deviceName = $testDeviceName + userPrincipalName = 'fallback.user@contoso.com' + manufacturer = 'Lenovo' + model = 'ThinkPad T14' + operatingSystem = 'Windows' + serialNumber = 'SER456DEF' + enrolledByUserId = '' + complianceState = 'compliant' + lastSyncDateTime = '2026-03-18T09:00:00Z' + } + } + + It 'Should fall back to the next candidate URI and return the matched device' { + $script:fallbackCallCount = 0 + Mock -CommandName 'Invoke-GraphGet' -MockWith { + $script:fallbackCallCount++ + if ($script:fallbackCallCount -eq 1) { throw 'BadRequest: 400' } + return [PSCustomObject]@{ value = @($mockDevice) } + } + + $result = Resolve-IntuneDeviceByName -Name $testDeviceName + + $result | Should -Not -BeNullOrEmpty + $result.deviceName | Should -Be $testDeviceName + } + + It 'Should throw when all candidate URIs return BadRequest' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { throw 'BadRequest: 400' } + + { Resolve-IntuneDeviceByName -Name $testDeviceName } | Should -Throw + } + } + + Context 'When no matching device is found' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceName = 'UNKNOWN-DEVICE' + } + + It 'Should return an empty array when no devices match the name' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @() } } + + $result = Resolve-IntuneDeviceByName -Name $testDeviceName + + $result | Should -BeNullOrEmpty + } + + It 'Should return an empty array when response value contains only differently-named devices' { + $otherDevice = [PSCustomObject]@{ + id = 'ffffffff-0000-1111-2222-333333333333' + deviceName = 'OTHER-DEVICE' + userPrincipalName = '' + manufacturer = 'HP' + model = 'ProBook 450' + operatingSystem = 'Windows' + serialNumber = 'OTHER123' + enrolledByUserId = '' + complianceState = 'unknown' + lastSyncDateTime = $null + } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($otherDevice) } } + + $result = Resolve-IntuneDeviceByName -Name $testDeviceName + + $result | Should -BeNullOrEmpty + } + + It 'Should return an empty array when Graph response is null' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $null } + + $result = Resolve-IntuneDeviceByName -Name $testDeviceName + + $result | Should -BeNullOrEmpty + } + } + + Context 'When device name contains single quotes' { + It 'Should escape single quotes in the OData filter' { + $nameWithQuote = "O'Brien-PC" + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @() } } + + Resolve-IntuneDeviceByName -Name $nameWithQuote | Out-Null + + Assert-MockCalled -CommandName 'Invoke-GraphGet' -ParameterFilter { + $Uri -match [regex]::Escape("O''Brien-PC") + } -Times 1 -Exactly -Scope It + } + } + + Context 'When a non-BadRequest error occurs' { + It 'Should re-throw the error immediately without falling back' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { throw 'Unauthorized: 401' } + + { Resolve-IntuneDeviceByName -Name 'PC-003' } | Should -Throw 'Unauthorized: 401' + + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 1 -Exactly -Scope It + } + } +} diff --git a/tests/public/Device/Get-IntuneDevice.Tests.ps1 b/tests/public/Device/Get-IntuneDevice.Tests.ps1 new file mode 100644 index 0000000..0b1ff32 --- /dev/null +++ b/tests/public/Device/Get-IntuneDevice.Tests.ps1 @@ -0,0 +1,113 @@ +BeforeAll { + $ModuleRoot = Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent + $PublicFunctionPath = Join-Path -Path $ModuleRoot -ChildPath 'src\functions\public\Device\Get-IntuneDevice.ps1' + $PrivateFunctionsPath = Join-Path -Path $ModuleRoot -ChildPath 'src\functions\private' + + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Resolve-IntuneDeviceByName.ps1') + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Invoke-GraphGet.ps1') + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'ConvertTo-IntuneDeviceSummary.ps1') + + . $PublicFunctionPath +} + +Describe 'Get-IntuneDevice' { + Context 'When called with DeviceId parameter set' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceId = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceName = 'DEVICE-001' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testPrimaryUser = 'primary.user@contoso.com' + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $mockDevice = [PSCustomObject]@{ + id = $testDeviceId + deviceName = $testDeviceName + userPrincipalName = $testPrimaryUser + manufacturer = 'Dell' + model = 'Latitude 7440' + operatingSystem = 'Windows' + serialNumber = 'ABC123XYZ' + complianceState = 'compliant' + lastSyncDateTime = '2026-03-18T08:30:00Z' + } + } + + It 'Should return a PSCustomObject with expected properties' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + + $result = Get-IntuneDevice -DeviceId $testDeviceId + + $result | Should -Not -BeNullOrEmpty + $result.DeviceName | Should -Be $testDeviceName + $result.PrimaryUser | Should -Be $testPrimaryUser + $result.DeviceManufacturer | Should -Be 'Dell' + $result.DeviceModel | Should -Be 'Latitude 7440' + $result.OperatingSystem | Should -Be 'Windows' + $result.SerialNumber | Should -Be 'ABC123XYZ' + $result.Compliance | Should -Be 'compliant' + $result.LastSyncDateTime | Should -BeOfType [datetime] + } + + It 'Should return nothing and write not found error when device does not exist' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $null } + + $result = Get-IntuneDevice -DeviceId $testDeviceId -ErrorAction SilentlyContinue -ErrorVariable deviceNotFoundError + + $result | Should -BeNullOrEmpty + $deviceNotFoundError | Should -Not -BeNullOrEmpty + $deviceNotFoundError[0].FullyQualifiedErrorId | Should -Match 'DeviceNotFound' + } + + It 'Should reject invalid GUID format' { + { Get-IntuneDevice -DeviceId 'not-a-guid' } | Should -Throw + } + } + + Context 'When called with DeviceName parameter set' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceId = 'f7e6d5c4-b3a2-1f0e-9d8c-7b6a5f4e3d2c' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceName = 'DEVICE-002' + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $mockSummary = [PSCustomObject]@{ + id = $testDeviceId + deviceName = $testDeviceName + userPrincipalName = 'another.user@contoso.com' + manufacturer = 'Lenovo' + model = 'ThinkPad T14' + operatingSystem = 'Windows' + serialNumber = 'SER987654' + complianceState = 'noncompliant' + lastSyncDateTime = '2026-03-18T10:00:00Z' + } + } + + It 'Should resolve device by name and return mapped properties' { + Mock -CommandName 'Resolve-IntuneDeviceByName' -MockWith { return @($mockSummary) } + Mock -CommandName 'Invoke-GraphGet' -MockWith { throw 'Should not be called for DeviceName lookups' } + + $result = Get-IntuneDevice -DeviceName $testDeviceName + + $result | Should -Not -BeNullOrEmpty + $result.DeviceName | Should -Be $testDeviceName + $result.DeviceManufacturer | Should -Be 'Lenovo' + $result.Compliance | Should -Be 'noncompliant' + Assert-MockCalled -CommandName 'Resolve-IntuneDeviceByName' -Times 1 -Exactly + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 0 -Exactly + } + + It 'Should return nothing and write not found error when name has no matches' { + Mock -CommandName 'Resolve-IntuneDeviceByName' -MockWith { return @() } + + $result = Get-IntuneDevice -DeviceName $testDeviceName -ErrorAction SilentlyContinue -ErrorVariable deviceNameNotFoundError + + $result | Should -BeNullOrEmpty + $deviceNameNotFoundError | Should -Not -BeNullOrEmpty + $deviceNameNotFoundError[0].FullyQualifiedErrorId | Should -Match 'DeviceNameNotFound' + } + } +} From f93fc4655cc1ee80fc7514c253fd801a7b55a5e4 Mon Sep 17 00:00:00 2001 From: Frederik Hjorslev Nylander Date: Thu, 19 Mar 2026 23:08:50 +0100 Subject: [PATCH 2/6] Add parameter UserPrincipalName --- .../private/Resolve-IntuneDeviceByUser.ps1 | 120 ++++++++++ .../public/Device/Get-IntuneDevice.ps1 | 80 ++++++- .../Resolve-IntuneDeviceByUser.Tests.ps1 | 219 ++++++++++++++++++ .../public/Device/Get-IntuneDevice.Tests.ps1 | 83 +++++++ 4 files changed, 498 insertions(+), 4 deletions(-) create mode 100644 src/functions/private/Resolve-IntuneDeviceByUser.ps1 create mode 100644 tests/private/Resolve-IntuneDeviceByUser.Tests.ps1 diff --git a/src/functions/private/Resolve-IntuneDeviceByUser.ps1 b/src/functions/private/Resolve-IntuneDeviceByUser.ps1 new file mode 100644 index 0000000..861048e --- /dev/null +++ b/src/functions/private/Resolve-IntuneDeviceByUser.ps1 @@ -0,0 +1,120 @@ +function Resolve-IntuneDeviceByUser { + <# + .SYNOPSIS + Resolves one or more Intune managed devices by primary user UPN. + + .DESCRIPTION + Queries Intune managed devices using the userPrincipalName filter. + Performs case-insensitive exact match searching via OData filter. + Returns all devices assigned to the specified user. + + .PARAMETER UserPrincipalName + The UPN of the primary user to search for in Intune managed devices. + + .EXAMPLE + Resolve-IntuneDeviceByUser -UserPrincipalName "jane.doe@contoso.com" + + Returns all managed device objects whose primary user is jane.doe@contoso.com. + + .INPUTS + System.String + + .OUTPUTS + PSCustomObject[] + + .NOTES + Part of the Intune Device helper functions. + Uses Microsoft Graph /beta endpoint. + Requires DeviceManagementManagedDevices.Read.All scope. + #> + + [OutputType([PSCustomObject[]])] + [CmdletBinding()] + param( + [Parameter( + Mandatory = $true, + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true, + HelpMessage = "The UPN of the primary user to resolve" + )] + [ValidateNotNullOrEmpty()] + [string]$UserPrincipalName + ) + + begin { + $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' + $select = 'id,deviceName,userPrincipalName,manufacturer,model,operatingSystem,serialNumber,complianceState,lastSyncDateTime' + } + + process { + # Escape only the string literal; keep OData filter syntax intact. + $escapedUpn = $UserPrincipalName.Replace("'", "''") + $filter = "userPrincipalName eq '$escapedUpn'" + + # Ordered fallback chain matching the BadRequest resilience pattern used elsewhere. + $candidateUris = @( + "$baseUri`?`$filter=$filter&`$select=$select", + "$baseUri`?`$filter=$filter", + "$baseUri`?`$select=$select", + $baseUri + ) + + $resp = $null + $lastBadRequestError = $null + + foreach ($candidateUri in $candidateUris) { + try { + $resp = Invoke-GraphGet -Uri $candidateUri + break + } catch { + $errorMessage = $_.Exception.Message + if ($errorMessage -match 'BadRequest|400') { + $lastBadRequestError = $_ + Write-Verbose -Message "Managed device query returned BadRequest for URI '$candidateUri'. Trying next fallback." + continue + } + + # Non-BadRequest errors are fatal; re-throw immediately. + throw + } + } + + # All candidates failed with BadRequest; surface the last error. + if ($null -eq $resp -and $null -ne $lastBadRequestError) { + throw $lastBadRequestError + } + + # Normalise response: unwrap .value collection or treat as single object. + $devices = @() + if ($null -ne $resp) { + if ($null -ne $resp.value) { + $devices = @($resp.value) + } else { + $devices = @($resp) + } + } + + # Apply exact match locally to handle unfiltered fallback responses. + $matchedDevices = @($devices | Where-Object -FilterScript { [string]$_.userPrincipalName -ieq $UserPrincipalName }) + + if ($matchedDevices.Count -eq 0) { + Write-Verbose -Message "No managed devices found for userPrincipalName '$UserPrincipalName'." + return [PSCustomObject[]]@() + } + + # Return managed device objects with fields required by downstream callers. + $matchedDevices | ForEach-Object -Process { + [PSCustomObject]@{ + id = $_.id + deviceName = $_.deviceName + userPrincipalName = $_.userPrincipalName + manufacturer = $_.manufacturer + model = $_.model + operatingSystem = $_.operatingSystem + serialNumber = $_.serialNumber + complianceState = $_.complianceState + lastSyncDateTime = $_.lastSyncDateTime + } + } + } # Process +} # Cmdlet diff --git a/src/functions/public/Device/Get-IntuneDevice.ps1 b/src/functions/public/Device/Get-IntuneDevice.ps1 index 0eef32e..3213866 100644 --- a/src/functions/public/Device/Get-IntuneDevice.ps1 +++ b/src/functions/public/Device/Get-IntuneDevice.ps1 @@ -3,11 +3,11 @@ function Get-IntuneDevice { <# .SYNOPSIS - Retrieves Intune managed device details by DeviceId or DeviceName. + Retrieves Intune managed device details by DeviceId, DeviceName, or primary user UPN. .DESCRIPTION Queries Microsoft Graph (beta) for managed device details and returns a compact device summary. - Supports lookup by managed device ID or by device name. + Supports lookup by managed device ID, by device name, or by the primary user's UPN. Requires an authenticated Graph session with appropriate scopes. @@ -21,6 +21,9 @@ function Get-IntuneDevice { The device name to resolve in Intune managed devices. Parameter set: ByName. If multiple devices share the same name, all matches are returned. + .PARAMETER UserPrincipalName + The UPN of the primary user whose devices should be returned. Parameter set: ByUser. + If the user has multiple enrolled devices, all matches are returned. .EXAMPLE Connect-MgGraph -Scopes "DeviceManagementManagedDevices.Read.All","User.Read.All" Get-IntuneDevice -DeviceId "c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a" @@ -32,8 +35,13 @@ function Get-IntuneDevice { Resolves device by name and returns summary details for each matching managed device. + .EXAMPLE + Get-IntuneDevice -UserPrincipalName jane.doe@contoso.com + + Returns summary details for all managed devices whose primary user is jane.doe@contoso.com. + .INPUTS - System.String (DeviceId or DeviceName via pipeline/property name) + System.String (DeviceId, DeviceName, or UserPrincipalName via pipeline/property name) .OUTPUTS PSCustomObject with the following properties @@ -71,7 +79,17 @@ function Get-IntuneDevice { )] [ValidateNotNullOrEmpty()] [Alias('Name', 'ComputerName')] - [string]$DeviceName + [string]$DeviceName, + + [Parameter( + ParameterSetName = 'ByUser', + Mandatory = $true, + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true + )] + [ValidateNotNullOrEmpty()] + [Alias('UPN', 'PrimaryUser')] + [string]$UserPrincipalName ) begin { @@ -182,6 +200,60 @@ function Get-IntuneDevice { ConvertTo-IntuneDeviceSummary -Device $device } } + + 'ByUser' { + Write-Verbose -Message "Resolving managed device(s) by user: $UserPrincipalName" + + try { + $deviceSummaries = Resolve-IntuneDeviceByUser -UserPrincipalName $UserPrincipalName + } catch { + $errorMessage = $_.Exception.Message + # Distinguish user not found from actual Graph errors. + if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { + $exception = [Exception]::new("Managed device not found for user '$UserPrincipalName': $errorMessage", $_.Exception) + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'DeviceUserNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $UserPrincipalName + ) + $PSCmdlet.WriteError($errorRecord) + return + } + + $exception = [Exception]::new("Failed to resolve devices for user '$UserPrincipalName': $errorMessage", $_.Exception) + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'DeviceUserLookupFailed', + [System.Management.Automation.ErrorCategory]::NotSpecified, + $UserPrincipalName + ) + $PSCmdlet.ThrowTerminatingError($errorRecord) + } + + # Empty result set: user has no enrolled devices. + if ($null -eq $deviceSummaries -or $deviceSummaries.Count -eq 0) { + $exception = [Exception]::new("No managed devices found for user '$UserPrincipalName'.") + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'DeviceUserNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $UserPrincipalName + ) + $PSCmdlet.WriteError($errorRecord) + return + } + + # Resolve-IntuneDeviceByUser already returns selected managed device fields. + foreach ($device in $deviceSummaries) { + if (-not $device) { + continue + } + + # Map to public output contract. + ConvertTo-IntuneDeviceSummary -Device $device + } + } } } # Process } # Cmdlet diff --git a/tests/private/Resolve-IntuneDeviceByUser.Tests.ps1 b/tests/private/Resolve-IntuneDeviceByUser.Tests.ps1 new file mode 100644 index 0000000..38b5f28 --- /dev/null +++ b/tests/private/Resolve-IntuneDeviceByUser.Tests.ps1 @@ -0,0 +1,219 @@ +BeforeAll { + $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $PrivateFunctionsPath = Join-Path -Path $ModuleRoot -ChildPath 'src\functions\private' + + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Invoke-GraphGet.ps1') + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Resolve-IntuneDeviceByUser.ps1') +} + +Describe 'Resolve-IntuneDeviceByUser' { + Context 'When the filtered query succeeds and a match is found' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUpn = 'jane.doe@contoso.com' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $mockDevice = [PSCustomObject]@{ + id = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + deviceName = 'PC-001' + userPrincipalName = $testUpn + manufacturer = 'Dell' + model = 'Latitude 7440' + operatingSystem = 'Windows' + serialNumber = 'ABC123XYZ' + complianceState = 'compliant' + lastSyncDateTime = '2026-03-18T08:30:00Z' + } + } + + It 'Should return a mapped PSCustomObject for the matching device' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($mockDevice) } } + + $result = Resolve-IntuneDeviceByUser -UserPrincipalName $testUpn + + $result | Should -Not -BeNullOrEmpty + $result.id | Should -Be $mockDevice.id + $result.deviceName | Should -Be $mockDevice.deviceName + $result.userPrincipalName | Should -Be $testUpn + $result.manufacturer | Should -Be $mockDevice.manufacturer + $result.model | Should -Be $mockDevice.model + $result.operatingSystem | Should -Be $mockDevice.operatingSystem + $result.serialNumber | Should -Be $mockDevice.serialNumber + $result.complianceState | Should -Be $mockDevice.complianceState + $result.lastSyncDateTime | Should -Be $mockDevice.lastSyncDateTime + } + + It 'Should call Invoke-GraphGet with the filtered and select URI first' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($mockDevice) } } + + Resolve-IntuneDeviceByUser -UserPrincipalName $testUpn | Out-Null + + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 1 -Exactly -Scope It + Assert-MockCalled -CommandName 'Invoke-GraphGet' -ParameterFilter { + $Uri -match [regex]::Escape('$filter=') -and $Uri -match [regex]::Escape('$select=') + } -Times 1 -Exactly -Scope It + } + + It 'Should accept UserPrincipalName from the pipeline' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($mockDevice) } } + + $result = $testUpn | Resolve-IntuneDeviceByUser + + $result | Should -Not -BeNullOrEmpty + $result.userPrincipalName | Should -Be $testUpn + } + + It 'Should accept UserPrincipalName from pipeline by property name' { + $pipelineObject = [PSCustomObject]@{ UserPrincipalName = $testUpn } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($mockDevice) } } + + $result = $pipelineObject | Resolve-IntuneDeviceByUser + + $result | Should -Not -BeNullOrEmpty + $result.userPrincipalName | Should -Be $testUpn + } + + It 'Should handle a response where the device is returned directly (no value wrapper)' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + + $result = Resolve-IntuneDeviceByUser -UserPrincipalName $testUpn + + $result | Should -Not -BeNullOrEmpty + $result.userPrincipalName | Should -Be $testUpn + } + + It 'Should match UPN case-insensitively' { + $upperUpnDevice = $mockDevice.PSObject.Copy() + $upperUpnDevice.userPrincipalName = $testUpn.ToUpper() + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($upperUpnDevice) } } + + $result = Resolve-IntuneDeviceByUser -UserPrincipalName $testUpn.ToLower() + + $result | Should -Not -BeNullOrEmpty + $result.userPrincipalName | Should -Be $testUpn.ToUpper() + } + + It 'Should return multiple devices when the user has more than one enrolled device' { + $secondDevice = [PSCustomObject]@{ + id = 'a2b3c4d5-e6f7-4a8b-9c0d-1e2f3a4b5c6d' + deviceName = 'PC-002' + userPrincipalName = $testUpn + manufacturer = 'HP' + model = 'EliteBook 840' + operatingSystem = 'Windows' + serialNumber = 'XYZ987ABC' + complianceState = 'noncompliant' + lastSyncDateTime = '2026-03-17T12:00:00Z' + } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($mockDevice, $secondDevice) } } + + $result = Resolve-IntuneDeviceByUser -UserPrincipalName $testUpn + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 2 + } + } + + Context 'When the filtered query returns BadRequest (fallback behaviour)' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUpn = 'fallback.user@contoso.com' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $mockDevice = [PSCustomObject]@{ + id = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e' + deviceName = 'PC-003' + userPrincipalName = $testUpn + manufacturer = 'Lenovo' + model = 'ThinkPad T14' + operatingSystem = 'Windows' + serialNumber = 'SER456DEF' + complianceState = 'compliant' + lastSyncDateTime = '2026-03-18T09:00:00Z' + } + } + + It 'Should fall back to the next candidate URI and return the matched device' { + $script:userFallbackCallCount = 0 + Mock -CommandName 'Invoke-GraphGet' -MockWith { + $script:userFallbackCallCount++ + if ($script:userFallbackCallCount -eq 1) { throw 'BadRequest: 400' } + return [PSCustomObject]@{ value = @($mockDevice) } + } + + $result = Resolve-IntuneDeviceByUser -UserPrincipalName $testUpn + + $result | Should -Not -BeNullOrEmpty + $result.userPrincipalName | Should -Be $testUpn + } + + It 'Should throw when all candidate URIs return BadRequest' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { throw 'BadRequest: 400' } + + { Resolve-IntuneDeviceByUser -UserPrincipalName $testUpn } | Should -Throw + } + } + + Context 'When no matching device is found' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUpn = 'nobody@contoso.com' + } + + It 'Should return an empty array when no devices match the UPN' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @() } } + + $result = Resolve-IntuneDeviceByUser -UserPrincipalName $testUpn + + $result | Should -BeNullOrEmpty + } + + It 'Should return an empty array when response contains only differently-assigned devices' { + $otherDevice = [PSCustomObject]@{ + id = 'ffffffff-0000-1111-2222-333333333333' + deviceName = 'OTHER-PC' + userPrincipalName = 'other.user@contoso.com' + manufacturer = 'HP' + model = 'ProBook 450' + operatingSystem = 'Windows' + serialNumber = 'OTHER123' + complianceState = 'unknown' + lastSyncDateTime = $null + } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @($otherDevice) } } + + $result = Resolve-IntuneDeviceByUser -UserPrincipalName $testUpn + + $result | Should -BeNullOrEmpty + } + + It 'Should return an empty array when Graph response is null' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $null } + + $result = Resolve-IntuneDeviceByUser -UserPrincipalName $testUpn + + $result | Should -BeNullOrEmpty + } + } + + Context 'When UPN contains single quotes' { + It 'Should escape single quotes in the OData filter' { + $upnWithQuote = "o'brien@contoso.com" + Mock -CommandName 'Invoke-GraphGet' -MockWith { return [PSCustomObject]@{ value = @() } } + + Resolve-IntuneDeviceByUser -UserPrincipalName $upnWithQuote | Out-Null + + Assert-MockCalled -CommandName 'Invoke-GraphGet' -ParameterFilter { + $Uri -match [regex]::Escape("o''brien@contoso.com") + } -Times 1 -Exactly -Scope It + } + } + + Context 'When a non-BadRequest error occurs' { + It 'Should re-throw the error immediately without falling back' { + Mock -CommandName 'Invoke-GraphGet' -MockWith { throw 'Unauthorized: 401' } + + { Resolve-IntuneDeviceByUser -UserPrincipalName 'user@contoso.com' } | Should -Throw 'Unauthorized: 401' + + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 1 -Exactly -Scope It + } + } +} diff --git a/tests/public/Device/Get-IntuneDevice.Tests.ps1 b/tests/public/Device/Get-IntuneDevice.Tests.ps1 index 0b1ff32..bb0bb80 100644 --- a/tests/public/Device/Get-IntuneDevice.Tests.ps1 +++ b/tests/public/Device/Get-IntuneDevice.Tests.ps1 @@ -4,6 +4,7 @@ $PrivateFunctionsPath = Join-Path -Path $ModuleRoot -ChildPath 'src\functions\private' . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Resolve-IntuneDeviceByName.ps1') + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Resolve-IntuneDeviceByUser.ps1') . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Invoke-GraphGet.ps1') . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'ConvertTo-IntuneDeviceSummary.ps1') @@ -110,4 +111,86 @@ Describe 'Get-IntuneDevice' { $deviceNameNotFoundError[0].FullyQualifiedErrorId | Should -Match 'DeviceNameNotFound' } } + + Context 'When called with UserPrincipalName parameter set' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUpn = 'jane.doe@contoso.com' + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $mockUserDevice = [PSCustomObject]@{ + id = 'd4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f8a' + deviceName = 'DEVICE-003' + userPrincipalName = $testUpn + manufacturer = 'Microsoft' + model = 'Surface Pro 9' + operatingSystem = 'Windows' + serialNumber = 'SRF789GHI' + complianceState = 'compliant' + lastSyncDateTime = '2026-03-18T11:00:00Z' + } + } + + It 'Should resolve devices by UPN and return mapped properties' { + Mock -CommandName 'Resolve-IntuneDeviceByUser' -MockWith { return @($mockUserDevice) } + Mock -CommandName 'Invoke-GraphGet' -MockWith { throw 'Should not be called for UserPrincipalName lookups' } + + $result = Get-IntuneDevice -UserPrincipalName $testUpn + + $result | Should -Not -BeNullOrEmpty + $result.DeviceName | Should -Be 'DEVICE-003' + $result.PrimaryUser | Should -Be $testUpn + $result.DeviceManufacturer | Should -Be 'Microsoft' + $result.DeviceModel | Should -Be 'Surface Pro 9' + $result.Compliance | Should -Be 'compliant' + Assert-MockCalled -CommandName 'Resolve-IntuneDeviceByUser' -Times 1 -Exactly + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 0 -Exactly + } + + It 'Should return all devices when user has multiple enrolled devices' { + $secondDevice = [PSCustomObject]@{ + id = 'e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b' + deviceName = 'DEVICE-004' + userPrincipalName = $testUpn + manufacturer = 'Dell' + model = 'XPS 15' + operatingSystem = 'Windows' + serialNumber = 'DEL321JKL' + complianceState = 'noncompliant' + lastSyncDateTime = '2026-03-17T14:00:00Z' + } + Mock -CommandName 'Resolve-IntuneDeviceByUser' -MockWith { return @($mockUserDevice, $secondDevice) } + + $result = Get-IntuneDevice -UserPrincipalName $testUpn + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 2 + } + + It 'Should accept UserPrincipalName from pipeline by property name' { + $pipelineObject = [PSCustomObject]@{ UserPrincipalName = $testUpn } + Mock -CommandName 'Resolve-IntuneDeviceByUser' -MockWith { return @($mockUserDevice) } + + $result = $pipelineObject | Get-IntuneDevice + + $result | Should -Not -BeNullOrEmpty + $result.PrimaryUser | Should -Be $testUpn + } + + It 'Should return nothing and write not found error when user has no devices' { + Mock -CommandName 'Resolve-IntuneDeviceByUser' -MockWith { return @() } + + $result = Get-IntuneDevice -UserPrincipalName $testUpn -ErrorAction SilentlyContinue -ErrorVariable userNotFoundError + + $result | Should -BeNullOrEmpty + $userNotFoundError | Should -Not -BeNullOrEmpty + $userNotFoundError[0].FullyQualifiedErrorId | Should -Match 'DeviceUserNotFound' + } + + It 'Should write a terminating error when the Graph call fails unexpectedly' { + Mock -CommandName 'Resolve-IntuneDeviceByUser' -MockWith { throw 'ServiceUnavailable: 503' } + + { Get-IntuneDevice -UserPrincipalName $testUpn -ErrorAction Stop } | Should -Throw + } + } } From 967f7d47ff0d7f6c240dc28f174e8f2cd63c5768 Mon Sep 17 00:00:00 2001 From: Frederik Hjorslev Nylander Date: Thu, 26 Mar 2026 21:23:58 +0100 Subject: [PATCH 3/6] fix linting --- src/functions/private/Resolve-IntuneDeviceByUser.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions/private/Resolve-IntuneDeviceByUser.ps1 b/src/functions/private/Resolve-IntuneDeviceByUser.ps1 index 861048e..107cbf8 100644 --- a/src/functions/private/Resolve-IntuneDeviceByUser.ps1 +++ b/src/functions/private/Resolve-IntuneDeviceByUser.ps1 @@ -43,7 +43,7 @@ begin { $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' - $select = 'id,deviceName,userPrincipalName,manufacturer,model,operatingSystem,serialNumber,complianceState,lastSyncDateTime' + $select = 'id,deviceName,userPrincipalName,manufacturer,model,operatingSystem,serialNumber,complianceState,lastSyncDateTime' } process { From 31d4516c01e2cefdd81601966f6b04a63615cce4 Mon Sep 17 00:00:00 2001 From: Frederik Hjorslev Nylander Date: Thu, 26 Mar 2026 21:29:05 +0100 Subject: [PATCH 4/6] fix linting --- src/functions/public/Device/Get-IntuneDevice.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions/public/Device/Get-IntuneDevice.ps1 b/src/functions/public/Device/Get-IntuneDevice.ps1 index 3213866..be69d0a 100644 --- a/src/functions/public/Device/Get-IntuneDevice.ps1 +++ b/src/functions/public/Device/Get-IntuneDevice.ps1 @@ -36,7 +36,7 @@ function Get-IntuneDevice { Resolves device by name and returns summary details for each matching managed device. .EXAMPLE - Get-IntuneDevice -UserPrincipalName jane.doe@contoso.com + Get-IntuneDevice -UserPrincipalName "jane.doe@contoso.com" Returns summary details for all managed devices whose primary user is jane.doe@contoso.com. From cc634a1a5d4e04fe895e1f93a36c9c2e1825675d Mon Sep 17 00:00:00 2001 From: Frederik Hjorslev Nylander Date: Thu, 26 Mar 2026 21:34:07 +0100 Subject: [PATCH 5/6] fix linting --- src/functions/public/Device/Get-IntuneDevice.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions/public/Device/Get-IntuneDevice.ps1 b/src/functions/public/Device/Get-IntuneDevice.ps1 index be69d0a..2b65a3c 100644 --- a/src/functions/public/Device/Get-IntuneDevice.ps1 +++ b/src/functions/public/Device/Get-IntuneDevice.ps1 @@ -36,9 +36,9 @@ function Get-IntuneDevice { Resolves device by name and returns summary details for each matching managed device. .EXAMPLE - Get-IntuneDevice -UserPrincipalName "jane.doe@contoso.com" + Get-IntuneDevice -UserPrincipalName `jane.doe@contoso.com` - Returns summary details for all managed devices whose primary user is jane.doe@contoso.com. + Returns summary details for all managed devices whose primary user is `jane.doe@contoso.com`. .INPUTS System.String (DeviceId, DeviceName, or UserPrincipalName via pipeline/property name) From a686266046507c1dece370be046d09aa096c771a Mon Sep 17 00:00:00 2001 From: Frederik Hjorslev Nylander Date: Thu, 26 Mar 2026 21:47:01 +0100 Subject: [PATCH 6/6] change so DeviceName can take multiple devices --- .../public/Device/Get-IntuneDevice.ps1 | 86 ++++++++++--------- .../public/Device/Get-IntuneDevice.Tests.ps1 | 35 ++++++++ 2 files changed, 80 insertions(+), 41 deletions(-) diff --git a/src/functions/public/Device/Get-IntuneDevice.ps1 b/src/functions/public/Device/Get-IntuneDevice.ps1 index 2b65a3c..389942a 100644 --- a/src/functions/public/Device/Get-IntuneDevice.ps1 +++ b/src/functions/public/Device/Get-IntuneDevice.ps1 @@ -18,8 +18,9 @@ function Get-IntuneDevice { The Intune managed device identifier (GUID). Parameter set: ById. .PARAMETER DeviceName - The device name to resolve in Intune managed devices. Parameter set: ByName. + One or more device names to resolve in Intune managed devices. Parameter set: ByName. If multiple devices share the same name, all matches are returned. + Accepts multiple names via array or pipeline. .PARAMETER UserPrincipalName The UPN of the primary user whose devices should be returned. Parameter set: ByUser. @@ -79,7 +80,7 @@ function Get-IntuneDevice { )] [ValidateNotNullOrEmpty()] [Alias('Name', 'ComputerName')] - [string]$DeviceName, + [string[]]$DeviceName, [Parameter( ParameterSetName = 'ByUser', @@ -148,56 +149,59 @@ function Get-IntuneDevice { } 'ByName' { - Write-Verbose -Message "Resolving managed device(s) by name: $DeviceName" + # Iterate through each provided device name. + foreach ($currentDeviceName in $DeviceName) { + Write-Verbose -Message "Resolving managed device(s) by name: $currentDeviceName" + + try { + $deviceSummaries = Resolve-IntuneDeviceByName -Name $currentDeviceName + } catch { + $errorMessage = $_.Exception.Message + # Distinguish name resolution failure (no match) from actual Graph errors. + if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { + $exception = [Exception]::new("Managed device not found for name '$currentDeviceName': $errorMessage", $_.Exception) + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'DeviceNameNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $currentDeviceName + ) + $PSCmdlet.WriteError($errorRecord) + continue + } + + $exception = [Exception]::new("Failed to resolve device name '$currentDeviceName': $errorMessage", $_.Exception) + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'DeviceNameLookupFailed', + [System.Management.Automation.ErrorCategory]::NotSpecified, + $currentDeviceName + ) + $PSCmdlet.ThrowTerminatingError($errorRecord) + } - try { - $deviceSummaries = Resolve-IntuneDeviceByName -Name $DeviceName - } catch { - $errorMessage = $_.Exception.Message - # Distinguish name resolution failure (no match) from actual Graph errors. - if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { - $exception = [Exception]::new("Managed device not found for name '$DeviceName': $errorMessage", $_.Exception) + # Empty result set: name does not match any device. + if ($null -eq $deviceSummaries -or $deviceSummaries.Count -eq 0) { + $exception = [Exception]::new("Managed device not found for name '$currentDeviceName'.") $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'DeviceNameNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, - $DeviceName + $currentDeviceName ) $PSCmdlet.WriteError($errorRecord) - return + continue } - $exception = [Exception]::new("Failed to resolve device name '$DeviceName': $errorMessage", $_.Exception) - $errorRecord = [System.Management.Automation.ErrorRecord]::new( - $exception, - 'DeviceNameLookupFailed', - [System.Management.Automation.ErrorCategory]::NotSpecified, - $DeviceName - ) - $PSCmdlet.ThrowTerminatingError($errorRecord) - } + # Resolve-IntuneDeviceByName already returns selected managed device fields. + foreach ($device in $deviceSummaries) { + if (-not $device) { + continue + } - # Empty result set: name does not match any device. - if ($null -eq $deviceSummaries -or $deviceSummaries.Count -eq 0) { - $exception = [Exception]::new("Managed device not found for name '$DeviceName'.") - $errorRecord = [System.Management.Automation.ErrorRecord]::new( - $exception, - 'DeviceNameNotFound', - [System.Management.Automation.ErrorCategory]::ObjectNotFound, - $DeviceName - ) - $PSCmdlet.WriteError($errorRecord) - return - } - - # Resolve-IntuneDeviceByName already returns selected managed device fields. - foreach ($device in $deviceSummaries) { - if (-not $device) { - continue + # Map to public output contract. + ConvertTo-IntuneDeviceSummary -Device $device } - - # Map to public output contract. - ConvertTo-IntuneDeviceSummary -Device $device } } diff --git a/tests/public/Device/Get-IntuneDevice.Tests.ps1 b/tests/public/Device/Get-IntuneDevice.Tests.ps1 index bb0bb80..a10b85f 100644 --- a/tests/public/Device/Get-IntuneDevice.Tests.ps1 +++ b/tests/public/Device/Get-IntuneDevice.Tests.ps1 @@ -101,6 +101,41 @@ Describe 'Get-IntuneDevice' { Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 0 -Exactly } + It 'Should accept multiple DeviceName values and resolve each name' { + $secondSummary = [PSCustomObject]@{ + id = 'a1111111-b222-c333-d444-e55555555555' + deviceName = 'DEVICE-099' + userPrincipalName = 'another.user@contoso.com' + manufacturer = 'HP' + model = 'EliteBook 840' + operatingSystem = 'Windows' + serialNumber = 'HP123456' + complianceState = 'compliant' + lastSyncDateTime = '2026-03-18T12:00:00Z' + } + + Mock -CommandName 'Resolve-IntuneDeviceByName' -MockWith { + param($Name) + if ($Name -eq $testDeviceName) { + return @($mockSummary) + } + + if ($Name -eq 'DEVICE-099') { + return @($secondSummary) + } + + return @() + } + + $result = Get-IntuneDevice -DeviceName @($testDeviceName, 'DEVICE-099') + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 2 + $result[0].DeviceName | Should -Be $testDeviceName + $result[1].DeviceName | Should -Be 'DEVICE-099' + Assert-MockCalled -CommandName 'Resolve-IntuneDeviceByName' -Times 2 -Exactly + } + It 'Should return nothing and write not found error when name has no matches' { Mock -CommandName 'Resolve-IntuneDeviceByName' -MockWith { return @() }