diff --git a/.github/linters/.powershell-psscriptanalyzer.psd1 b/.github/linters/.powershell-psscriptanalyzer.psd1 index 09cc3d0..a817a0c 100644 --- a/.github/linters/.powershell-psscriptanalyzer.psd1 +++ b/.github/linters/.powershell-psscriptanalyzer.psd1 @@ -5,7 +5,7 @@ CheckHashtable = $true } PSAvoidLongLines = @{ - Enable = $true + Enable = $false MaximumLineLength = 150 } PSAvoidSemicolonsAsLineTerminators = @{ diff --git a/LICENSE b/LICENSE index 75789b6..c7d6107 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 PSModule +Copyright (c) 2026 Frederik Hjorslev Nylander Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6319793..a37f8b3 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,62 @@ -# {{ NAME }} +# IntuneOperator -{{ DESCRIPTION }} +## IntuneOperator -## Prerequisites - -This uses the following external resources: -- The [PSModule framework](https://github.com/PSModule/Process-PSModule) for building, testing and publishing the module. +IntuneOperator is a PowerShell module for Intune that helps managing your Endpoint fleet. ## Installation To install the module from the PowerShell Gallery, you can use the following command: ```powershell -Install-PSResource -Name {{ NAME }} -Import-Module -Name {{ NAME }} +Install-PSResource -Name IntuneOperator +Import-Module -Name IntuneOperator ``` ## Usage Here is a list of example that are typical use cases for the module. -### Example 1: Greet an entity +### Example 1: Get-IntuneDeviceLogin -Provide examples for typical commands that a user would like to do with the module. +As for March 2026 there is one cmdlet: `Get-IntuneDeviceLogin`. ```powershell -Greet-Entity -Name 'World' -Hello, World! +Get-IntuneDeviceLogin -DeviceName PC-001 ``` -### Example 2 - -Provide examples for typical commands that a user would like to do with the module. +```text +DeviceName : PC-001 +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 -Import-Module -Name PSModuleTemplate +Get-IntuneDeviceLogin -UserPrincipalName john.doe@contoso.com ``` -### Find more examples - -To find more examples of how to use the module, please refer to the [examples](examples) folder. - -Alternatively, you can use the Get-Command -Module 'This module' to find more commands that are available in the module. -To find examples of each of the commands you can use Get-Help -Examples 'CommandName'. - -## Documentation - -Link to further documentation if available, or describe where in the repository users can find more detailed documentation about -the module's functions and features. - -## Contributing - -Coder or not, you can contribute to the project! We welcome all contributions. - -### For Users - -If you don't code, you still sit on valuable information that can make this project even better. If you experience that the -product does unexpected things, throw errors or is missing functionality, you can help by submitting bugs and feature requests. -Please see the issues tab on this project and submit a new issue that matches your needs. - -### For Developers - -If you do code, we'd love to have your contributions. Please read the [Contribution guidelines](CONTRIBUTING.md) for more information. -You can either help by picking up an existing issue or submit a new one if you have an idea for a new feature or improvement. +```text +DeviceName : PC-001 +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 +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 +``` ## Acknowledgements -Here is a list of people and projects that helped this project in some way. +- [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/examples/General.ps1 b/examples/General.ps1 deleted file mode 100644 index e193423..0000000 --- a/examples/General.ps1 +++ /dev/null @@ -1,19 +0,0 @@ -<# - .SYNOPSIS - This is a general example of how to use the module. -#> - -# Import the module -Import-Module -Name 'PSModule' - -# Define the path to the font file -$FontFilePath = 'C:\Fonts\CodeNewRoman\CodeNewRomanNerdFontPropo-Regular.tff' - -# Install the font -Install-Font -Path $FontFilePath -Verbose - -# List installed fonts -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' - -# Uninstall the font -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' | Uninstall-Font -Verbose diff --git a/src/README.md b/src/README.md index af76160..d47affd 100644 --- a/src/README.md +++ b/src/README.md @@ -1,3 +1,3 @@ -# Details +# IntuneOperator -For more info about the expected structure of a module repository, please refer to [Build-PSModule](https://github.com/PSModule/Build-PSModule) +For more info about the module, please see [IntuneOperator](https://github.com/hjorslev/IntuneOperator). diff --git a/src/assemblies/LsonLib.dll b/src/assemblies/LsonLib.dll deleted file mode 100644 index 3661807..0000000 Binary files a/src/assemblies/LsonLib.dll and /dev/null differ diff --git a/src/classes/private/SecretWriter.ps1 b/src/classes/private/SecretWriter.ps1 deleted file mode 100644 index 1b1732a..0000000 --- a/src/classes/private/SecretWriter.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -class SecretWriter { - [string] $Alias - [string] $Name - [string] $Secret - - SecretWriter([string] $alias, [string] $name, [string] $secret) { - $this.Alias = $alias - $this.Name = $name - $this.Secret = $secret - } - - [string] GetAlias() { - return $this.Alias - } -} diff --git a/src/classes/public/Book.ps1 b/src/classes/public/Book.ps1 deleted file mode 100644 index 8917d9a..0000000 --- a/src/classes/public/Book.ps1 +++ /dev/null @@ -1,147 +0,0 @@ -class Book { - # Class properties - [string] $Title - [string] $Author - [string] $Synopsis - [string] $Publisher - [datetime] $PublishDate - [int] $PageCount - [string[]] $Tags - # Default constructor - Book() { $this.Init(@{}) } - # Convenience constructor from hashtable - Book([hashtable]$Properties) { $this.Init($Properties) } - # Common constructor for title and author - Book([string]$Title, [string]$Author) { - $this.Init(@{Title = $Title; Author = $Author }) - } - # Shared initializer method - [void] Init([hashtable]$Properties) { - foreach ($Property in $Properties.Keys) { - $this.$Property = $Properties.$Property - } - } - # Method to calculate reading time as 2 minutes per page - [timespan] GetReadingTime() { - if ($this.PageCount -le 0) { - throw 'Unable to determine reading time from page count.' - } - $Minutes = $this.PageCount * 2 - return [timespan]::new(0, $Minutes, 0) - } - # Method to calculate how long ago a book was published - [timespan] GetPublishedAge() { - if ( - $null -eq $this.PublishDate -or - $this.PublishDate -eq [datetime]::MinValue - ) { throw 'PublishDate not defined' } - - return (Get-Date) - $this.PublishDate - } - # Method to return a string representation of the book - [string] ToString() { - return "$($this.Title) by $($this.Author) ($($this.PublishDate.Year))" - } -} - -class BookList { - # Static property to hold the list of books - static [System.Collections.Generic.List[Book]] $Books - # Static method to initialize the list of books. Called in the other - # static methods to avoid needing to explicit initialize the value. - static [void] Initialize() { [BookList]::Initialize($false) } - static [bool] Initialize([bool]$force) { - if ([BookList]::Books.Count -gt 0 -and -not $force) { - return $false - } - - [BookList]::Books = [System.Collections.Generic.List[Book]]::new() - - return $true - } - # Ensure a book is valid for the list. - static [void] Validate([book]$Book) { - $Prefix = @( - 'Book validation failed: Book must be defined with the Title,' - 'Author, and PublishDate properties, but' - ) -join ' ' - if ($null -eq $Book) { throw "$Prefix was null" } - if ([string]::IsNullOrEmpty($Book.Title)) { - throw "$Prefix Title wasn't defined" - } - if ([string]::IsNullOrEmpty($Book.Author)) { - throw "$Prefix Author wasn't defined" - } - if ([datetime]::MinValue -eq $Book.PublishDate) { - throw "$Prefix PublishDate wasn't defined" - } - } - # Static methods to manage the list of books. - # Add a book if it's not already in the list. - static [void] Add([Book]$Book) { - [BookList]::Initialize() - [BookList]::Validate($Book) - if ([BookList]::Books.Contains($Book)) { - throw "Book '$Book' already in list" - } - - $FindPredicate = { - param([Book]$b) - - $b.Title -eq $Book.Title -and - $b.Author -eq $Book.Author -and - $b.PublishDate -eq $Book.PublishDate - }.GetNewClosure() - if ([BookList]::Books.Find($FindPredicate)) { - throw "Book '$Book' already in list" - } - - [BookList]::Books.Add($Book) - } - # Clear the list of books. - static [void] Clear() { - [BookList]::Initialize() - [BookList]::Books.Clear() - } - # Find a specific book using a filtering scriptblock. - static [Book] Find([scriptblock]$Predicate) { - [BookList]::Initialize() - return [BookList]::Books.Find($Predicate) - } - # Find every book matching the filtering scriptblock. - static [Book[]] FindAll([scriptblock]$Predicate) { - [BookList]::Initialize() - return [BookList]::Books.FindAll($Predicate) - } - # Remove a specific book. - static [void] Remove([Book]$Book) { - [BookList]::Initialize() - [BookList]::Books.Remove($Book) - } - # Remove a book by property value. - static [void] RemoveBy([string]$Property, [string]$Value) { - [BookList]::Initialize() - $Index = [BookList]::Books.FindIndex({ - param($b) - $b.$Property -eq $Value - }.GetNewClosure()) - if ($Index -ge 0) { - [BookList]::Books.RemoveAt($Index) - } - } -} - -enum Binding { - Hardcover - Paperback - EBook -} - -enum Genre { - Mystery - Thriller - Romance - ScienceFiction - Fantasy - Horror -} diff --git a/src/data/Config.psd1 b/src/data/Config.psd1 deleted file mode 100644 index fea4466..0000000 --- a/src/data/Config.psd1 +++ /dev/null @@ -1,3 +0,0 @@ -@{ - RandomKey = 'RandomValue' -} diff --git a/src/data/Settings.psd1 b/src/data/Settings.psd1 deleted file mode 100644 index bcfa7b4..0000000 --- a/src/data/Settings.psd1 +++ /dev/null @@ -1,3 +0,0 @@ -@{ - RandomSetting = 'RandomSettingValue' -} diff --git a/src/finally.ps1 b/src/finally.ps1 deleted file mode 100644 index d8fc207..0000000 --- a/src/finally.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '------------------------------' -Write-Verbose '--- THIS IS A LAST LOADER ---' -Write-Verbose '------------------------------' diff --git a/src/formats/CultureInfo.Format.ps1xml b/src/formats/CultureInfo.Format.ps1xml deleted file mode 100644 index a715e08..0000000 --- a/src/formats/CultureInfo.Format.ps1xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - System.Globalization.CultureInfo - - System.Globalization.CultureInfo - - - - - 16 - - - 16 - - - - - - - - LCID - - - Name - - - DisplayName - - - - - - - - diff --git a/src/formats/Mygciview.Format.ps1xml b/src/formats/Mygciview.Format.ps1xml deleted file mode 100644 index 4c972c2..0000000 --- a/src/formats/Mygciview.Format.ps1xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - mygciview - - System.IO.DirectoryInfo - System.IO.FileInfo - - - PSParentPath - - - - - - 7 - Left - - - - 26 - Right - - - - 26 - Right - - - - 14 - Right - - - - Left - - - - - - - - ModeWithoutHardLink - - - LastWriteTime - - - CreationTime - - - Length - - - Name - - - - - - - - diff --git a/src/functions/private/.gitkeep b/src/functions/private/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/functions/private/Get-InternalPSModule.ps1 b/src/functions/private/Get-InternalPSModule.ps1 deleted file mode 100644 index 89f053c..0000000 --- a/src/functions/private/Get-InternalPSModule.ps1 +++ /dev/null @@ -1,18 +0,0 @@ -function Get-InternalPSModule { - <# - .SYNOPSIS - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/functions/private/Invoke-GraphGet.ps1 b/src/functions/private/Invoke-GraphGet.ps1 new file mode 100644 index 0000000..bacbc83 --- /dev/null +++ b/src/functions/private/Invoke-GraphGet.ps1 @@ -0,0 +1,106 @@ +function Invoke-GraphGet { + <# + .SYNOPSIS + Invokes a GET request against the Microsoft Graph API with error handling and automatic pagination. + + .DESCRIPTION + Wrapper function for making authenticated GET requests to the Microsoft Graph API. + Provides consistent error handling, verbose output, and automatic pagination handling for all Graph API calls. + When the response contains a 'value' collection and '@odata.nextLink', automatically follows pagination + to retrieve all results. + + Requires an established Microsoft Graph connection via Connect-MgGraph. + + .PARAMETER Uri + The full URI of the Graph API endpoint to query. + + .EXAMPLE + Invoke-GraphGet -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices" + + Retrieves all managed devices from the Graph API, automatically following pagination links. + + .EXAMPLE + Invoke-GraphGet -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/12345" + + Retrieves a single managed device by ID (no pagination applies). + + .INPUTS + System.String + + .OUTPUTS + PSObject + + .NOTES + Part of the Intune Device Login helper functions. + Requires Microsoft.Graph PowerShell module with active connection. + Automatically handles pagination for collection responses. + #> + + [OutputType([PSObject])] + [CmdletBinding()] + param( + [Parameter( + Mandatory = $true, + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true, + HelpMessage = "The full URI of the Graph API endpoint" + )] + [ValidateNotNullOrEmpty()] + [string]$Uri + ) + + process { + Write-Verbose -Message "GET $Uri" + try { + $splat = @{ + Method = 'GET' + Uri = $Uri + ErrorAction = 'Stop' + } + $response = Invoke-MgGraphRequest @splat + + # Check if response has pagination (value collection with nextLink) + if ($null -ne $response.value -and $null -ne $response.'@odata.nextLink') { + + Write-Verbose -Message "Response contains pagination, retrieving all pages" + $allValues = [System.Collections.Generic.List[object]]::new() + $allValues.AddRange($response.value) + + $nextLink = $response.'@odata.nextLink' + $pageCount = 1 + + while ($null -ne $nextLink) { + $pageCount++ + Write-Verbose -Message "Following pagination link (page $pageCount, current items: $($allValues.Count))" + + $splat.Uri = $nextLink + $nextResponse = Invoke-MgGraphRequest @splat + + if ($null -ne $nextResponse.value) { + $allValues.AddRange($nextResponse.value) + } + + $nextLink = $nextResponse.'@odata.nextLink' + } + + Write-Verbose -Message "Pagination complete: retrieved $($allValues.Count) total items across $pageCount pages" + + # Return modified response with all values + $response.value = $allValues.ToArray() + $response.PSObject.Properties.Remove('@odata.nextLink') + } + + return $response + + } catch { + $Exception = [Exception]::new("Graph request failed for '$Uri': $($_.Exception.Message)", $_.Exception) + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'GraphRequestFailed', + [System.Management.Automation.ErrorCategory]::NotSpecified, + $Uri + ) + $PSCmdlet.ThrowTerminatingError($ErrorRecord) + } + } # Process +} # Cmdlet diff --git a/src/functions/private/Resolve-EntraUserById.ps1 b/src/functions/private/Resolve-EntraUserById.ps1 new file mode 100644 index 0000000..0df2e50 --- /dev/null +++ b/src/functions/private/Resolve-EntraUserById.ps1 @@ -0,0 +1,62 @@ +function Resolve-EntraUserById { + <# + .SYNOPSIS + Resolves an Entra ID user by user ID to retrieve user principal name and other details. + + .DESCRIPTION + Queries Microsoft Graph for a specific user by their user ID (object ID). + Returns user object including userPrincipalName for reporting and audit purposes. + Handles cases where user may no longer exist in Entra ID. + + .PARAMETER UserId + The Entra ID user object identifier (GUID). + + .EXAMPLE + Resolve-EntraUserById -UserId "d1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a" + + Returns the user object with UPN for the specified user ID. + + .INPUTS + System.String + + .OUTPUTS + PSObject + + .NOTES + Part of the Intune Device Login helper functions. + Uses Microsoft Graph /v1.0 endpoint. + Requires User.Read.All scope. + Returns a minimal user object if user cannot be found. + #> + + [OutputType([PSObject])] + [CmdletBinding()] + param( + [Parameter( + Mandatory = $true, + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true, + HelpMessage = "The Entra ID user object ID" + )] + [ValidateNotNullOrEmpty()] + [string]$UserId + ) + + begin { + $baseUri = 'https://graph.microsoft.com/v1.0/users' + } + + process { + $uri = "$baseUri/$UserId" + try { + Invoke-GraphGet -Uri $uri + } catch { + Write-Verbose -Message "Could not resolve user ID '$UserId': $($_.Exception.Message)" + # Return a minimal object with the ID and a placeholder UPN + [PSCustomObject]@{ + id = $UserId + userPrincipalName = "Unknown (ID: $UserId)" + } + } + } +} diff --git a/src/functions/private/Resolve-IntuneDeviceByName.ps1 b/src/functions/private/Resolve-IntuneDeviceByName.ps1 new file mode 100644 index 0000000..2c04a79 --- /dev/null +++ b/src/functions/private/Resolve-IntuneDeviceByName.ps1 @@ -0,0 +1,68 @@ +function Resolve-IntuneDeviceByName { + <# + .SYNOPSIS + Resolves one or more Intune managed devices by device name. + + .DESCRIPTION + Queries Intune managed devices using the device name filter. + Performs case-insensitive exact match searching via OData filter. + Returns all devices matching the specified name. + + .PARAMETER Name + The device name to search for in Intune managed devices. + + .EXAMPLE + Resolve-IntuneDeviceByName -Name "PC-001" + + Returns the managed device object matching the name "PC-001", if found. + + .INPUTS + System.String + + .OUTPUTS + PSObject[] + + .NOTES + Part of the Intune Device Login 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 device name to resolve" + )] + [ValidateNotNullOrEmpty()] + [string]$Name + ) + + begin { + $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' + } + + process { + # deviceName is case-insensitive in OData. Exact match. + $encoded = [uri]::EscapeDataString("deviceName eq '$Name'") + $uri = "$baseUri`?`$filter=$encoded&`$select=id,deviceName" + + $resp = Invoke-GraphGet -Uri $uri + + if ($null -eq $resp.value -or $resp.value.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 { + [PSCustomObject]@{ + Id = $_.id + DeviceName = $_.deviceName + } + } + } +} diff --git a/src/functions/private/Set-InternalPSModule.ps1 b/src/functions/private/Set-InternalPSModule.ps1 deleted file mode 100644 index cf870ba..0000000 --- a/src/functions/private/Set-InternalPSModule.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -function Set-InternalPSModule { - <# - .SYNOPSIS - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function', - Justification = 'Reason for suppressing' - )] - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/functions/public/Device/Get-IntuneDeviceLogin.ps1 b/src/functions/public/Device/Get-IntuneDeviceLogin.ps1 new file mode 100644 index 0000000..2b85ae9 --- /dev/null +++ b/src/functions/public/Device/Get-IntuneDeviceLogin.ps1 @@ -0,0 +1,366 @@ +function Get-IntuneDeviceLogin { + <# + .SYNOPSIS + Retrieves logged-on user info for an Intune-managed device by DeviceId, DeviceName, UserPrincipalName, or UserId. + + .DESCRIPTION + Uses Microsoft Graph (beta) to read managed device metadata and the `usersLoggedOn` collection. + When given a DeviceId, queries that specific device. When given a DeviceName, resolves one or more + matching managed devices (`deviceName eq ''`) and returns logon info for each match. + When given a UserPrincipalName or UserId, searches all managed devices and returns only those where + the specified user has logged in. + + Requires an authenticated Graph session with appropriate scopes. + + Scopes (minimum): + - DeviceManagementManagedDevices.Read.All + - User.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 processed. + + .PARAMETER UserPrincipalName + The user principal name (UPN) to search for across all managed devices. Parameter set: ByUserPrincipalName. + Returns all devices where this user has logged in. + + .PARAMETER UserId + The Entra ID user object identifier (GUID). Parameter set: ByUserId. + Returns all devices where this user has logged in. + + .EXAMPLE + Connect-MgGraph -Scopes "DeviceManagementManagedDevices.Read.All","User.Read.All" + Get-IntuneDeviceLogin -DeviceId "c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a" + + Gets logged-on user info for the specified device. + + .EXAMPLE + Get-IntuneDeviceLogin -DeviceName PC-001 + + Resolves the device name and returns logged-on user info for the match. + + .EXAMPLE + Get-IntuneDeviceLogin -UserPrincipalName "" + + Returns all devices where the specified user principal name has logged in. + + .EXAMPLE + Get-IntuneDeviceLogin -UserId "c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a" + + Returns all devices where the specified user (by ID) has logged in. + + .INPUTS + System.String (DeviceId, DeviceName, UserPrincipalName, or UserId via pipeline/property name) + + .OUTPUTS + PSCustomObject with the following properties + - DeviceId (string) + - DeviceName (string) + - UserId (string) + - UserPrincipalName (string) + - LastLogonDateTime (datetime) + + .NOTES + Author: FHN & ChatGPT & GitHub Copilot + - Uses /beta Graph endpoints because usersLoggedOn is exposed there. + #> + + [OutputType([PSCustomObject])] + [CmdletBinding(DefaultParameterSetName = 'ById', SupportsShouldProcess = $false)] + param( + # ById: DeviceId (GUID) + [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, + + # ByName: DeviceName (string) + [Parameter( + ParameterSetName = 'ByName', + Mandatory = $true, + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true + )] + [ValidateNotNullOrEmpty()] + [Alias('Name', 'ComputerName')] + [string]$DeviceName, + + # ByUserPrincipalName: UserPrincipalName (string) + [Parameter( + ParameterSetName = 'ByUserPrincipalName', + Mandatory = $true, + ValueFromPipelineByPropertyName = $true + )] + [ValidateNotNullOrEmpty()] + [Alias('UPN')] + [string]$UserPrincipalName, + + # ByUserId: UserId (GUID) + [Parameter( + ParameterSetName = 'ByUserId', + 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}$')] + [string]$UserId + ) + + begin { + $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' + } + + process { + switch ($PSCmdlet.ParameterSetName) { + 'ById' { + Write-Verbose -Message "Resolving usersLoggedOn for device id: $DeviceId" + $uri = "$baseUri/$DeviceId" + try { + $device = Invoke-GraphGet -Uri $uri + } catch { + $errorMessage = $_.Exception.Message + 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) + } + + 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 + } + + if (-not $device.usersLoggedOn -or $device.usersLoggedOn.Count -eq 0) { + Write-Verbose -Message "No logged-on users found for device '$($device.deviceName)' ($DeviceId)." + return + } + + foreach ($entry in $device.usersLoggedOn) { + $user = Resolve-EntraUserById -UserId $entry.userId + [PSCustomObject]@{ + DeviceName = $device.deviceName + UserPrincipalName = $user.userPrincipalName + DeviceId = $device.id + UserId = $entry.userId + LastLogonDateTime = [datetime]$entry.lastLogOnDateTime + } + } + } + + 'ByName' { + Write-Verbose -Message "Resolving device(s) by name: $DeviceName" + try { + $deviceSummaries = Resolve-IntuneDeviceByName -Name $DeviceName + } catch { + $errorMessage = $_.Exception.Message + 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) + } + + 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 + } + + if ($deviceSummaries.Count -gt 1) { + Write-Verbose -Message "Multiple devices matched name '$DeviceName' ($($deviceSummaries.Count) matches). Returning results for all." + } + + foreach ($summary in $deviceSummaries) { + $uri = "$baseUri/$($summary.Id)" + try { + $device = Invoke-GraphGet -Uri $uri + } catch { + $errorMessage = $_.Exception.Message + if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { + $Exception = [Exception]::new("Managed device not found for id '$($summary.Id)' while resolving name '$DeviceName': $errorMessage", $_.Exception) + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'DeviceNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $summary.Id + ) + $PSCmdlet.WriteError($ErrorRecord) + continue + } + + $Exception = [Exception]::new("Failed to retrieve device id '$($summary.Id)' while resolving name '$DeviceName': $errorMessage", $_.Exception) + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'DeviceLookupFailed', + [System.Management.Automation.ErrorCategory]::NotSpecified, + $summary.Id + ) + $PSCmdlet.ThrowTerminatingError($ErrorRecord) + } + + if (-not $device) { + $Exception = [Exception]::new("Managed device not found for id '$($summary.Id)' while resolving name '$DeviceName'.") + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'DeviceNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $summary.Id + ) + $PSCmdlet.WriteError($ErrorRecord) + continue + } + + if (-not $device.usersLoggedOn -or $device.usersLoggedOn.Count -eq 0) { + Write-Verbose -Message "No logged-on users found for device '$($summary.DeviceName)' ($($summary.Id))." + continue + } + + foreach ($entry in $device.usersLoggedOn) { + $user = Resolve-EntraUserById -UserId $entry.userId + [PSCustomObject]@{ + DeviceName = $device.deviceName + UserPrincipalName = $user.userPrincipalName + DeviceId = $device.id + UserId = $entry.userId + LastLogonDateTime = [datetime]$entry.lastLogOnDateTime + } + } + } + } + + { $_ -in 'ByUserPrincipalName', 'ByUserId' } { + # Resolve UPN to UserId if needed + if ($PSCmdlet.ParameterSetName -eq 'ByUserPrincipalName') { + Write-Verbose -Message "Resolving UserPrincipalName '$UserPrincipalName' to UserId" + $userUri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName" + try { + $userObj = Invoke-GraphGet -Uri $userUri + $targetUserId = $userObj.id + Write-Verbose -Message "Resolved to UserId: $targetUserId" + } catch { + $errorMessage = $_.Exception.Message + if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { + $Exception = [Exception]::new("Could not resolve UserPrincipalName '$UserPrincipalName': $errorMessage", $_.Exception) + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'UserResolutionFailed', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $UserPrincipalName + ) + $PSCmdlet.WriteError($ErrorRecord) + return + } + + $Exception = [Exception]::new("Failed to resolve UserPrincipalName '$UserPrincipalName': $errorMessage", $_.Exception) + $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( + $Exception, + 'UserResolutionGraphRequestFailed', + [System.Management.Automation.ErrorCategory]::NotSpecified, + $UserPrincipalName + ) + $PSCmdlet.ThrowTerminatingError($ErrorRecord) + } + } else { + $targetUserId = $UserId + } + + Write-Verbose -Message "Searching for devices where user '$targetUserId' has logged in" + + # Get all managed devices with usersLoggedOn property + # Note: Graph API may not support filtering on usersLoggedOn collection, so we retrieve all and filter client-side + # Invoke-GraphGet automatically handles pagination + $uri = "$baseUri`?`$select=id,deviceName,usersLoggedOn" + $allDevices = [System.Collections.Generic.List[object]]::new() + $nextUri = $uri + + while ($null -ne $nextUri) { + $resp = Invoke-GraphGet -Uri $nextUri + + if ($null -ne $resp.value) { + $allDevices.AddRange($resp.value) + } + + $nextUri = $resp.'@odata.nextLink' + } + + if ($allDevices.Count -eq 0) { + Write-Verbose -Message "No managed devices found." + return + } + + Write-Verbose -Message "Checking $($allDevices.Count) managed devices for user logons" + + $matchCount = 0 + foreach ($device in $allDevices) { + # Check if target user is in the usersLoggedOn collection + $userLogon = $device.usersLoggedOn | Where-Object -FilterScript { $_.userId -eq $targetUserId } + if ($userLogon) { + $matchCount++ + $user = Resolve-EntraUserById -UserId $targetUserId + [PSCustomObject]@{ + DeviceName = $device.deviceName + UserPrincipalName = $user.userPrincipalName + DeviceId = $device.id + UserId = $targetUserId + LastLogonDateTime = [datetime]$userLogon.lastLogOnDateTime + } + } + } + + if ($matchCount -eq 0) { + Write-Verbose -Message "No devices found where user '$targetUserId' has logged in." + } + } + } + } # Process +} # Cmdlet diff --git a/src/functions/public/PSModule/Get-PSModuleTest.ps1 b/src/functions/public/PSModule/Get-PSModuleTest.ps1 deleted file mode 100644 index a07d05b..0000000 --- a/src/functions/public/PSModule/Get-PSModuleTest.ps1 +++ /dev/null @@ -1,26 +0,0 @@ -#Requires -Modules Utilities -#Requires -Modules @{ ModuleName = 'PSSemVer'; RequiredVersion = '1.1.4' } -#Requires -Modules @{ ModuleName = 'DynamicParams'; ModuleVersion = '1.1.8' } -#Requires -Modules @{ ModuleName = 'Store'; ModuleVersion = '0.3.1' } - -function Get-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/functions/public/PSModule/New-PSModuleTest.ps1 b/src/functions/public/PSModule/New-PSModuleTest.ps1 deleted file mode 100644 index e003841..0000000 --- a/src/functions/public/PSModule/New-PSModuleTest.ps1 +++ /dev/null @@ -1,40 +0,0 @@ -#Requires -Modules @{ModuleName='PSSemVer'; ModuleVersion='1.1.4'} - -function New-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - - .NOTES - Testing if a module can have a [Markdown based link](https://example.com). - !"#¤%&/()=?`´^¨*'-_+§½{[]}<>|@£$€¥¢:;.," - \[This is a test\] - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function', - Justification = 'Reason for suppressing' - )] - [Alias('New-PSModuleTestAlias1')] - [Alias('New-PSModuleTestAlias2')] - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} - -New-Alias New-PSModuleTestAlias3 New-PSModuleTest -New-Alias -Name New-PSModuleTestAlias4 -Value New-PSModuleTest - - -Set-Alias New-PSModuleTestAlias5 New-PSModuleTest diff --git a/src/functions/public/PSModule/PSModule.md b/src/functions/public/PSModule/PSModule.md deleted file mode 100644 index a657773..0000000 --- a/src/functions/public/PSModule/PSModule.md +++ /dev/null @@ -1,3 +0,0 @@ -# PSModule - -This is a sub page for PSModule. diff --git a/src/functions/public/SomethingElse/Set-PSModuleTest.ps1 b/src/functions/public/SomethingElse/Set-PSModuleTest.ps1 deleted file mode 100644 index 23ec98e..0000000 --- a/src/functions/public/SomethingElse/Set-PSModuleTest.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -function Set-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function', - Justification = 'Reason for suppressing' - )] - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/functions/public/SomethingElse/SomethingElse.md b/src/functions/public/SomethingElse/SomethingElse.md deleted file mode 100644 index d9f7e9e..0000000 --- a/src/functions/public/SomethingElse/SomethingElse.md +++ /dev/null @@ -1 +0,0 @@ -# This is SomethingElse diff --git a/src/functions/public/Test-PSModuleTest.ps1 b/src/functions/public/Test-PSModuleTest.ps1 deleted file mode 100644 index 0c27510..0000000 --- a/src/functions/public/Test-PSModuleTest.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -function Test-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/header.ps1 b/src/header.ps1 deleted file mode 100644 index cc1fde9..0000000 --- a/src/header.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains long links.')] -[CmdletBinding()] -param() diff --git a/src/init/initializer.ps1 b/src/init/initializer.ps1 index 28396fb..506377a 100644 --- a/src/init/initializer.ps1 +++ b/src/init/initializer.ps1 @@ -1,3 +1 @@ -Write-Verbose '-------------------------------' -Write-Verbose '--- THIS IS AN INITIALIZER ---' -Write-Verbose '-------------------------------' +#Requires -Modules @{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.28.0' } diff --git a/src/manifest.psd1 b/src/manifest.psd1 index ff720bd..dcff56b 100644 --- a/src/manifest.psd1 +++ b/src/manifest.psd1 @@ -1,5 +1,7 @@ # This file always wins! # Use this file to override any of the framework defaults and generated values. @{ - ModuleVersion = '0.0.0' + Author = 'Frederik Hjorslev Nylander' + Copyright = '(c) 2026 Frederik Hjorslev Nylander. All rights reserved.' + Description = 'A PowerShell module for managing and enhancing Microsoft Intune.' } diff --git a/src/modules/OtherPSModule.psm1 b/src/modules/OtherPSModule.psm1 deleted file mode 100644 index 5d6af8e..0000000 --- a/src/modules/OtherPSModule.psm1 +++ /dev/null @@ -1,19 +0,0 @@ -function Get-OtherPSModule { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - A longer description of the function. - - .EXAMPLE - Get-OtherPSModule -Name 'World' - #> - [CmdletBinding()] - param( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/scripts/loader.ps1 b/src/scripts/loader.ps1 deleted file mode 100644 index 973735a..0000000 --- a/src/scripts/loader.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '-------------------------' -Write-Verbose '--- THIS IS A LOADER ---' -Write-Verbose '-------------------------' diff --git a/src/types/DirectoryInfo.Types.ps1xml b/src/types/DirectoryInfo.Types.ps1xml deleted file mode 100644 index aef538b..0000000 --- a/src/types/DirectoryInfo.Types.ps1xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - System.IO.FileInfo - - - Status - Success - - - - - System.IO.DirectoryInfo - - - Status - Success - - - - diff --git a/src/types/FileInfo.Types.ps1xml b/src/types/FileInfo.Types.ps1xml deleted file mode 100644 index 4cfaf6b..0000000 --- a/src/types/FileInfo.Types.ps1xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - System.IO.FileInfo - - - Age - - ((Get-Date) - ($this.CreationTime)).Days - - - - - diff --git a/src/variables/private/PrivateVariables.ps1 b/src/variables/private/PrivateVariables.ps1 deleted file mode 100644 index f1fc2c3..0000000 --- a/src/variables/private/PrivateVariables.ps1 +++ /dev/null @@ -1,47 +0,0 @@ -$script:HabitablePlanets = @( - @{ - Name = 'Earth' - Mass = 5.97 - Diameter = 12756 - DayLength = 24.0 - }, - @{ - Name = 'Mars' - Mass = 0.642 - Diameter = 6792 - DayLength = 24.7 - }, - @{ - Name = 'Proxima Centauri b' - Mass = 1.17 - Diameter = 11449 - DayLength = 5.15 - }, - @{ - Name = 'Kepler-442b' - Mass = 2.34 - Diameter = 11349 - DayLength = 5.7 - }, - @{ - Name = 'Kepler-452b' - Mass = 5.0 - Diameter = 17340 - DayLength = 20.0 - } -) - -$script:InhabitedPlanets = @( - @{ - Name = 'Earth' - Mass = 5.97 - Diameter = 12756 - DayLength = 24.0 - }, - @{ - Name = 'Mars' - Mass = 0.642 - Diameter = 6792 - DayLength = 24.7 - } -) diff --git a/src/variables/public/Moons.ps1 b/src/variables/public/Moons.ps1 deleted file mode 100644 index dd0f33c..0000000 --- a/src/variables/public/Moons.ps1 +++ /dev/null @@ -1,6 +0,0 @@ -$script:Moons = @( - @{ - Planet = 'Earth' - Name = 'Moon' - } -) diff --git a/src/variables/public/Planets.ps1 b/src/variables/public/Planets.ps1 deleted file mode 100644 index 5927bc5..0000000 --- a/src/variables/public/Planets.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -$script:Planets = @( - @{ - Name = 'Mercury' - Mass = 0.330 - Diameter = 4879 - DayLength = 4222.6 - }, - @{ - Name = 'Venus' - Mass = 4.87 - Diameter = 12104 - DayLength = 2802.0 - }, - @{ - Name = 'Earth' - Mass = 5.97 - Diameter = 12756 - DayLength = 24.0 - } -) diff --git a/src/variables/public/SolarSystems.ps1 b/src/variables/public/SolarSystems.ps1 deleted file mode 100644 index acbcedf..0000000 --- a/src/variables/public/SolarSystems.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -$script:SolarSystems = @( - @{ - Name = 'Solar System' - Planets = $script:Planets - Moons = $script:Moons - }, - @{ - Name = 'Alpha Centauri' - Planets = @() - Moons = @() - }, - @{ - Name = 'Sirius' - Planets = @() - Moons = @() - } -) diff --git a/tests/PSModuleTest.Tests.ps1 b/tests/PSModuleTest.Tests.ps1 deleted file mode 100644 index b856855..0000000 --- a/tests/PSModuleTest.Tests.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSReviewUnusedParameter', '', - Justification = 'Required for Pester tests' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Required for Pester tests' -)] -[CmdletBinding()] -param() - -Describe 'Module' { - It 'Function: Get-PSModuleTest' { - Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } - It 'Function: New-PSModuleTest' { - New-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } - It 'Function: Set-PSModuleTest' { - Set-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } - It 'Function: Test-PSModuleTest' { - Test-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } -} diff --git a/tests/private/Invoke-GraphGet.Tests.ps1 b/tests/private/Invoke-GraphGet.Tests.ps1 new file mode 100644 index 0000000..e290906 --- /dev/null +++ b/tests/private/Invoke-GraphGet.Tests.ps1 @@ -0,0 +1,349 @@ +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 'Invoke-GraphGet.ps1') +} + +Describe 'Invoke-GraphGet' { + Context 'When making a simple GET request without pagination' { + It 'Should return the response as-is when no pagination exists' { + # Arrange + $testUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices/12345' + $mockResponse = [PSCustomObject]@{ + id = '12345' + deviceName = 'TEST-DEVICE' + osVersion = '10.0.19045' + } + + Mock -CommandName 'Invoke-MgGraphRequest' -MockWith { return $mockResponse } + + # Act + $result = Invoke-GraphGet -Uri $testUri + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.id | Should -Be '12345' + $result.deviceName | Should -Be 'TEST-DEVICE' + Assert-MockCalled -CommandName 'Invoke-MgGraphRequest' -Times 1 -Exactly + } + + It 'Should return collection response without pagination' { + # Arrange + $testUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' + $mockResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = 'device1' + deviceName = 'DEVICE-001' + }, + [PSCustomObject]@{ + id = 'device2' + deviceName = 'DEVICE-002' + } + ) + } + + Mock -CommandName 'Invoke-MgGraphRequest' -MockWith { return $mockResponse } + + # Act + $result = Invoke-GraphGet -Uri $testUri + + # Assert + $result.value | Should -Not -BeNullOrEmpty + $result.value.Count | Should -Be 2 + $result.value[0].id | Should -Be 'device1' + $result.value[1].id | Should -Be 'device2' + Assert-MockCalled -CommandName 'Invoke-MgGraphRequest' -Times 1 -Exactly + } + } + + Context 'When response contains pagination' { + It 'Should follow pagination links and aggregate all results' { + # Arrange + $testUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' + + $page1 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'device1'; deviceName = 'DEVICE-001' }, + [PSCustomObject]@{ id = 'device2'; deviceName = 'DEVICE-002' } + ) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$skiptoken=page2' + } + + $page2 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'device3'; deviceName = 'DEVICE-003' }, + [PSCustomObject]@{ id = 'device4'; deviceName = 'DEVICE-004' } + ) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$skiptoken=page3' + } + + $page3 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'device5'; deviceName = 'DEVICE-005' } + ) + } + + Mock -CommandName 'Invoke-MgGraphRequest' -MockWith { + param($Uri) + if ($Uri -match 'skiptoken=page2') { + return $page2 + } elseif ($Uri -match 'skiptoken=page3') { + return $page3 + } else { + return $page1 + } + } + + # Act + $result = Invoke-GraphGet -Uri $testUri + + # Assert + $result.value | Should -Not -BeNullOrEmpty + $result.value.Count | Should -Be 5 + $result.value[0].id | Should -Be 'device1' + $result.value[2].id | Should -Be 'device3' + $result.value[4].id | Should -Be 'device5' + + # Verify nextLink was removed + $result.'@odata.nextLink' | Should -BeNullOrEmpty + + # Verify all pages were requested + Assert-MockCalled -CommandName 'Invoke-MgGraphRequest' -Times 3 -Exactly + } + + It 'Should handle pagination with only two pages' { + # Arrange + $testUri = 'https://graph.microsoft.com/beta/users' + + $page1 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'user1'; userPrincipalName = 'user1@contoso.com' } + ) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/users?$skiptoken=page2' + } + + $page2 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'user2'; userPrincipalName = 'user2@contoso.com' } + ) + } + + Mock -CommandName 'Invoke-MgGraphRequest' -MockWith { + param($Uri) + if ($Uri -match 'skiptoken=page2') { + return $page2 + } else { + return $page1 + } + } + + # Act + $result = Invoke-GraphGet -Uri $testUri + + # Assert + $result.value.Count | Should -Be 2 + $result.value[0].id | Should -Be 'user1' + $result.value[1].id | Should -Be 'user2' + $result.'@odata.nextLink' | Should -BeNullOrEmpty + Assert-MockCalled -CommandName 'Invoke-MgGraphRequest' -Times 2 -Exactly + } + + It 'Should handle empty pages in pagination' { + # Arrange + $testUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' + + $page1 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'device1'; deviceName = 'DEVICE-001' } + ) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$skiptoken=page2' + } + + $page2 = [PSCustomObject]@{ + value = @() + '@odata.nextLink' = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$skiptoken=page3' + } + + $page3 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'device2'; deviceName = 'DEVICE-002' } + ) + } + + Mock -CommandName 'Invoke-MgGraphRequest' -MockWith { + param($Uri) + if ($Uri -match 'skiptoken=page2') { + return $page2 + } elseif ($Uri -match 'skiptoken=page3') { + return $page3 + } else { + return $page1 + } + } + + # Act + $result = Invoke-GraphGet -Uri $testUri + + # Assert + $result.value.Count | Should -Be 2 + $result.value[0].id | Should -Be 'device1' + $result.value[1].id | Should -Be 'device2' + Assert-MockCalled -CommandName 'Invoke-MgGraphRequest' -Times 3 -Exactly + } + + It 'Should write verbose messages during pagination' { + # Arrange + $testUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' + + $page1 = [PSCustomObject]@{ + value = @([PSCustomObject]@{ id = 'device1' }) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$skiptoken=page2' + } + + $page2 = [PSCustomObject]@{ + value = @([PSCustomObject]@{ id = 'device2' }) + } + + Mock -CommandName 'Invoke-MgGraphRequest' -MockWith { + param($Uri) + if ($Uri -match 'skiptoken=page2') { return $page2 } + else { return $page1 } + } + + # Act + $verboseOutput = Invoke-GraphGet -Uri $testUri -Verbose 4>&1 + + # Assert + $verboseOutput | Should -Not -BeNullOrEmpty + $verboseOutput | Where-Object -FilterScript { $_ -match 'Response contains pagination' } | Should -Not -BeNullOrEmpty + $verboseOutput | Where-Object -FilterScript { $_ -match 'Following pagination link' } | Should -Not -BeNullOrEmpty + $verboseOutput | Where-Object -FilterScript { $_ -match 'Pagination complete' } | Should -Not -BeNullOrEmpty + } + } + + Context 'When handling errors' { + It 'Should throw an error when Invoke-MgGraphRequest fails' { + # Arrange + $testUri = 'https://graph.microsoft.com/beta/nonexistent' + Mock -CommandName 'Invoke-MgGraphRequest' -MockWith { + throw [System.Exception]::new('Resource not found') + } + + # Act & Assert + { Invoke-GraphGet -Uri $testUri } | Should -Throw -ExpectedMessage "*Resource not found*" + } + + It 'Should provide clear error message with URI' { + # Arrange + $testUri = 'https://graph.microsoft.com/beta/test' + Mock -CommandName 'Invoke-MgGraphRequest' -MockWith { + throw [System.Exception]::new('Unauthorized') + } + + # Act & Assert + { Invoke-GraphGet -Uri $testUri } | Should -Throw -ExpectedMessage "*Graph request failed for '$testUri'*" + } + } + + Context 'When handling edge cases' { + It 'Should handle response with null value property' { + # Arrange + $testUri = 'https://graph.microsoft.com/beta/test' + $mockResponse = [PSCustomObject]@{ + value = $null + } + + Mock -CommandName 'Invoke-MgGraphRequest' -MockWith { return $mockResponse } + + # Act + $result = Invoke-GraphGet -Uri $testUri + + # Assert + $result.value | Should -BeNullOrEmpty + } + + It 'Should not treat response without value property as paginated' { + # Arrange + $testUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices/12345' + $mockResponse = [PSCustomObject]@{ + id = '12345' + deviceName = 'TEST-DEVICE' + '@odata.nextLink' = 'https://somelink.com' # This shouldn't trigger pagination without 'value' + } + + Mock -CommandName 'Invoke-MgGraphRequest' -MockWith { return $mockResponse } + + # Act + $result = Invoke-GraphGet -Uri $testUri + + # Assert + $result.id | Should -Be '12345' + $result.'@odata.nextLink' | Should -Be 'https://somelink.com' + Assert-MockCalled -CommandName 'Invoke-MgGraphRequest' -Times 1 -Exactly + } + + It 'Should handle multiple pages with many items' { + # Arrange + $testUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' + + $page1 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'device1'; deviceName = 'DEVICE-001' }, + [PSCustomObject]@{ id = 'device2'; deviceName = 'DEVICE-002' }, + [PSCustomObject]@{ id = 'device3'; deviceName = 'DEVICE-003' } + ) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$skiptoken=page2' + } + + $page2 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'device4'; deviceName = 'DEVICE-004' }, + [PSCustomObject]@{ id = 'device5'; deviceName = 'DEVICE-005' } + ) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$skiptoken=page3' + } + + $page3 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'device6'; deviceName = 'DEVICE-006' }, + [PSCustomObject]@{ id = 'device7'; deviceName = 'DEVICE-007' } + ) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$skiptoken=page4' + } + + $page4 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ id = 'device8'; deviceName = 'DEVICE-008' } + ) + } + + Mock -CommandName 'Invoke-MgGraphRequest' -MockWith { + param($Uri) + if ($Uri -match 'skiptoken=page4') { + return $page4 + } elseif ($Uri -match 'skiptoken=page3') { + return $page3 + } elseif ($Uri -match 'skiptoken=page2') { + return $page2 + } else { + return $page1 + } + } + + # Act + $result = Invoke-GraphGet -Uri $testUri + + # Assert + $result.value.Count | Should -Be 8 + $result.value[0].id | Should -Be 'device1' + $result.value[3].id | Should -Be 'device4' + $result.value[7].id | Should -Be 'device8' + $result.'@odata.nextLink' | Should -BeNullOrEmpty + Assert-MockCalled -CommandName 'Invoke-MgGraphRequest' -Times 4 -Exactly + } + } +} diff --git a/tests/private/Resolve-EntraUserById.Tests.ps1 b/tests/private/Resolve-EntraUserById.Tests.ps1 new file mode 100644 index 0000000..448656d --- /dev/null +++ b/tests/private/Resolve-EntraUserById.Tests.ps1 @@ -0,0 +1,238 @@ +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 private functions + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Invoke-GraphGet.ps1') + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Resolve-EntraUserById.ps1') +} + +Describe 'Resolve-EntraUserById' { + Context 'When user exists in Entra ID' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUserId = 'd1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUserPrincipalName = 'testuser@contoso.com' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + displayName = 'Test User' + } + } + + It 'Should return user object with userPrincipalName' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockUser } + + # Act + $result = Resolve-EntraUserById -UserId $testUserId + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.id | Should -Be $testUserId + $result.userPrincipalName | Should -Be $testUserPrincipalName + } + + It 'Should call Invoke-GraphGet with correct URI' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockUser } + + # Act + Resolve-EntraUserById -UserId $testUserId | Out-Null + + # Assert + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 1 -Exactly -Scope It + Assert-MockCalled -CommandName 'Invoke-GraphGet' -ParameterFilter { + $Uri -eq "https://graph.microsoft.com/v1.0/users/$testUserId" + } + } + + It 'Should accept UserId from pipeline' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockUser } + + # Act + $result = $testUserId | Resolve-EntraUserById + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.id | Should -Be $testUserId + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 1 -Exactly + } + + It 'Should accept UserId from pipeline by property name' { + # Arrange + $pipelineObject = [PSCustomObject]@{ UserId = $testUserId } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockUser } + + # Act + $result = $pipelineObject | Resolve-EntraUserById + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.id | Should -Be $testUserId + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 1 -Exactly + } + } + + Context 'When user does not exist in Entra ID' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUserId = 'd1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + } + + It 'Should return placeholder object when user is not found' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { + throw "User not found" + } + + # Act + $result = Resolve-EntraUserById -UserId $testUserId + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.id | Should -Be $testUserId + $result.userPrincipalName | Should -Be "Unknown (ID: $testUserId)" + } + + It 'Should write verbose message when user is not found' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { + throw "User not found" + } + + # Act + $result = Resolve-EntraUserById -UserId $testUserId -Verbose 4>&1 + + # Assert + $verboseMessages = $result | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] } + $verboseMessages | Should -Not -BeNullOrEmpty + $verboseMessages[0].Message | Should -BeLike "*Could not resolve user ID*" + } + + It 'Should handle Graph API errors gracefully' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { + throw "Graph request failed: Authentication needed" + } + + # Act & Assert - Should not throw + { Resolve-EntraUserById -UserId $testUserId } | Should -Not -Throw + } + } + + Context 'Parameter validation' { + It 'Should reject null or empty UserId' { + # Act & Assert + { Resolve-EntraUserById -UserId $null } | Should -Throw + { Resolve-EntraUserById -UserId '' } | Should -Throw + } + + It 'Should accept valid GUID format' { + # Arrange + $validUserId = 'd1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockUser = [PSCustomObject]@{ + id = $validUserId + userPrincipalName = 'test@contoso.com' + } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockUser } + + # Act & Assert - Should not throw + { Resolve-EntraUserById -UserId $validUserId } | Should -Not -Throw + } + + It 'Should accept non-GUID string format' { + # Arrange + # Note: The function doesn't validate GUID format, so any string is accepted + $nonGuidUserId = 'not-a-guid-123' + $mockUser = [PSCustomObject]@{ + id = $nonGuidUserId + userPrincipalName = 'test@contoso.com' + } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockUser } + + # Act & Assert - Should not throw + { Resolve-EntraUserById -UserId $nonGuidUserId } | Should -Not -Throw + } + } + + Context 'Output validation' { + It 'Should return PSObject type' { + # Arrange + $testUserId = 'd1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = 'test@contoso.com' + displayName = 'Test User' + } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockUser } + + # Act + $result = Resolve-EntraUserById -UserId $testUserId + + # Assert + $result | Should -BeOfType [PSCustomObject] + } + + It 'Should preserve all properties from Graph API response' { + # Arrange + $testUserId = 'd1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = 'test@contoso.com' + displayName = 'Test User' + mail = 'test@contoso.com' + jobTitle = 'Developer' + } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockUser } + + # Act + $result = Resolve-EntraUserById -UserId $testUserId + + # Assert + $result.id | Should -Be $testUserId + $result.userPrincipalName | Should -Be 'test@contoso.com' + $result.displayName | Should -Be 'Test User' + $result.mail | Should -Be 'test@contoso.com' + $result.jobTitle | Should -Be 'Developer' + } + } + + Context 'Error handling' { + It 'Should handle authentication errors' { + # Arrange + $testUserId = 'd1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + Mock -CommandName 'Invoke-GraphGet' -MockWith { + throw "Authentication needed. Please call Connect-MgGraph." + } + + # Act + $result = Resolve-EntraUserById -UserId $testUserId + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.id | Should -Be $testUserId + $result.userPrincipalName | Should -Be "Unknown (ID: $testUserId)" + } + + It 'Should handle permission errors' { + # Arrange + $testUserId = 'd1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + Mock -CommandName 'Invoke-GraphGet' -MockWith { + throw "Insufficient privileges to complete the operation" + } + + # Act + $result = Resolve-EntraUserById -UserId $testUserId + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.id | Should -Be $testUserId + $result.userPrincipalName | Should -Be "Unknown (ID: $testUserId)" + } + } +} diff --git a/tests/public/Device/Get-IntuneDeviceLogin.Tests.ps1 b/tests/public/Device/Get-IntuneDeviceLogin.Tests.ps1 new file mode 100644 index 0000000..da4cc93 --- /dev/null +++ b/tests/public/Device/Get-IntuneDeviceLogin.Tests.ps1 @@ -0,0 +1,1268 @@ +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\Device\Get-IntuneDeviceLogin.ps1' + $PrivateFunctionsPath = Join-Path -Path $ModuleRoot -ChildPath 'src\functions\private' + + # Dot-source the private functions that Get-IntuneDeviceLogin depends on + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Resolve-EntraUserById.ps1') + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Resolve-IntuneDeviceByName.ps1') + . (Join-Path -Path $PrivateFunctionsPath -ChildPath 'Invoke-GraphGet.ps1') + + # Dot-source the function we're testing + . $PublicFunctionPath +} + +Describe 'Get-IntuneDeviceLogin' { + 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', '')] + $testUserId = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUserPrincipalName = 'user@contoso.com' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testLastLogonDateTime = '2024-03-05T10:30:00Z' + } + + It 'Should return a PSCustomObject with logged-on user information' { + # Arrange + $mockDevice = [PSCustomObject]@{ + id = $testDeviceId + deviceName = $testDeviceName + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = $testLastLogonDateTime + } + ) + } + + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $result = Get-IntuneDeviceLogin -DeviceId $testDeviceId + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.DeviceId | Should -Be $testDeviceId + $result.DeviceName | Should -Be $testDeviceName + $result.UserId | Should -Be $testUserId + $result.UserPrincipalName | Should -Be $testUserPrincipalName + $result.LastLogonDateTime | Should -BeOfType [datetime] + } + + It 'Should handle multiple logged-on users for a single device' { + # Arrange + $userId1 = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $userId2 = 'u2e2a2d7-2d2b-4d8c-9f0a-0d2a3d1e2f3b' + $upn1 = 'user1@contoso.com' + $upn2 = 'user2@contoso.com' + + $mockDevice = [PSCustomObject]@{ + id = $testDeviceId + deviceName = $testDeviceName + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $userId1 + lastLogOnDateTime = '2024-03-05T10:30:00Z' + }, + [PSCustomObject]@{ + userId = $userId2 + lastLogOnDateTime = '2024-03-04T09:15:00Z' + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { + param([string]$UserId) + if ($UserId -eq $userId1) { + return [PSCustomObject]@{ + id = $userId1 + userPrincipalName = $upn1 + } + } else { + return [PSCustomObject]@{ + id = $userId2 + userPrincipalName = $upn2 + } + } + } + + # Act + $results = @(Get-IntuneDeviceLogin -DeviceId $testDeviceId) + + # Assert + $results.Count | Should -Be 2 + $results[0].UserPrincipalName | Should -Be $upn1 + $results[1].UserPrincipalName | Should -Be $upn2 + } + + It 'Should return nothing if no users are logged on' { + # Arrange + $mockDevice = [PSCustomObject]@{ + id = $testDeviceId + deviceName = $testDeviceName + usersLoggedOn = @() + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + + # Act + $result = Get-IntuneDeviceLogin -DeviceId $testDeviceId + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 1 + } + + It 'Should return nothing if device is not found' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $null } + + # Act + $result = Get-IntuneDeviceLogin -DeviceId $testDeviceId -ErrorAction SilentlyContinue -ErrorVariable deviceNotFoundError + + # Assert + $result | Should -BeNullOrEmpty + $deviceNotFoundError | Should -Not -BeNullOrEmpty + $deviceNotFoundError[0].FullyQualifiedErrorId | Should -Match 'DeviceNotFound' + } + + It 'Should work with DeviceId alias "Id"' { + # Arrange + $mockDevice = [PSCustomObject]@{ + id = $testDeviceId + deviceName = $testDeviceName + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = $testLastLogonDateTime + } + ) + } + + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $result = Get-IntuneDeviceLogin -Id $testDeviceId + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.DeviceId | Should -Be $testDeviceId + } + + It 'Should work with DeviceId alias "ManagedDeviceId"' { + # Arrange + $mockDevice = [PSCustomObject]@{ + id = $testDeviceId + deviceName = $testDeviceName + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = $testLastLogonDateTime + } + ) + } + + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $result = Get-IntuneDeviceLogin -ManagedDeviceId $testDeviceId + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.DeviceId | Should -Be $testDeviceId + } + + It 'Should reject invalid GUID format' { + # Act & Assert + { Get-IntuneDeviceLogin -DeviceId 'not-a-guid' } | Should -Throw + } + } + + Context 'When called with DeviceName 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', '')] + $testUserId = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUserPrincipalName = 'user@contoso.com' + } + + It 'Should resolve device by name and return logged-on users' { + # Arrange + $mockDeviceSummary = [PSCustomObject]@{ + Id = $testDeviceId + DeviceName = $testDeviceName + } + + $mockDevice = [PSCustomObject]@{ + id = $testDeviceId + deviceName = $testDeviceName + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + } + + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + Mock -CommandName 'Resolve-IntuneDeviceByName' -MockWith { return @($mockDeviceSummary) } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $result = Get-IntuneDeviceLogin -DeviceName $testDeviceName + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.DeviceName | Should -Be $testDeviceName + $result.UserPrincipalName | Should -Be $testUserPrincipalName + Assert-MockCalled -CommandName 'Resolve-IntuneDeviceByName' -Times 1 -Exactly + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 1 -Exactly + } + + It 'Should handle multiple devices with the same name' { + # Arrange + $deviceId1 = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $deviceId2 = 'c2f6d2d8-2d2c-4d8d-9f0b-0d2b3d1e2f3b' + $userId1 = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $userId2 = 'u2e2a2d7-2d2b-4d8c-9f0a-0d2a3d1e2f3b' + + $mockDeviceSummaries = @( + [PSCustomObject]@{ + Id = $deviceId1 + DeviceName = $testDeviceName + }, + [PSCustomObject]@{ + Id = $deviceId2 + DeviceName = $testDeviceName + } + ) + + $mockDevices = @( + [PSCustomObject]@{ + id = $deviceId1 + deviceName = $testDeviceName + usersLoggedOn = @([PSCustomObject]@{ + userId = $userId1 + lastLogOnDateTime = '2024-03-05T10:30:00Z' + }) + }, + [PSCustomObject]@{ + id = $deviceId2 + deviceName = $testDeviceName + usersLoggedOn = @([PSCustomObject]@{ + userId = $userId2 + lastLogOnDateTime = '2024-03-05T11:00:00Z' + }) + } + ) + + Mock -CommandName 'Resolve-IntuneDeviceByName' -MockWith { return $mockDeviceSummaries } + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match $deviceId1) { return $mockDevices[0] } + else { return $mockDevices[1] } + } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { + param([string]$UserId) + if ($UserId -eq $userId1) { + return [PSCustomObject]@{ + id = $userId1 + userPrincipalName = 'user1@contoso.com' + } + } else { + return [PSCustomObject]@{ + id = $userId2 + userPrincipalName = 'user2@contoso.com' + } + } + } + + # Act + $results = @(Get-IntuneDeviceLogin -DeviceName $testDeviceName) + + # Assert + $results.Count | Should -Be 2 + $results[0].DeviceId | Should -Be $deviceId1 + $results[1].DeviceId | Should -Be $deviceId2 + } + + It 'Should work with DeviceName alias "Name"' { + # Arrange + $mockDeviceSummary = [PSCustomObject]@{ + Id = $testDeviceId + DeviceName = $testDeviceName + } + + $mockDevice = [PSCustomObject]@{ + id = $testDeviceId + deviceName = $testDeviceName + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + } + + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + Mock -CommandName 'Resolve-IntuneDeviceByName' -MockWith { return @($mockDeviceSummary) } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $result = Get-IntuneDeviceLogin -Name $testDeviceName + + # Assert + $result | Should -Not -BeNullOrEmpty + } + + It 'Should work with DeviceName alias "ComputerName"' { + # Arrange + $mockDeviceSummary = [PSCustomObject]@{ + Id = $testDeviceId + DeviceName = $testDeviceName + } + + $mockDevice = [PSCustomObject]@{ + id = $testDeviceId + deviceName = $testDeviceName + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + } + + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + Mock -CommandName 'Resolve-IntuneDeviceByName' -MockWith { return @($mockDeviceSummary) } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $result = Get-IntuneDeviceLogin -ComputerName $testDeviceName + + # Assert + $result | Should -Not -BeNullOrEmpty + } + + It 'Should return nothing if device name is not found' { + # Arrange + Mock -CommandName 'Resolve-IntuneDeviceByName' -MockWith { return @() } + + # Act + $result = Get-IntuneDeviceLogin -DeviceName 'NonExistent' -ErrorAction SilentlyContinue -ErrorVariable deviceNameNotFoundError + + # Assert + $result | Should -BeNullOrEmpty + $deviceNameNotFoundError | Should -Not -BeNullOrEmpty + $deviceNameNotFoundError[0].FullyQualifiedErrorId | Should -Match 'DeviceNameNotFound' + } + + It 'Should skip devices with no logged-on users' { + # Arrange + $deviceId1 = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $deviceId2 = 'c2f6d2d8-2d2c-4d8d-9f0b-0d2b3d1e2f3b' + + $mockDeviceSummaries = @( + [PSCustomObject]@{ + Id = $deviceId1 + DeviceName = 'DEVICE-001' + }, + [PSCustomObject]@{ + Id = $deviceId2 + DeviceName = 'DEVICE-001' + } + ) + + $mockDevices = @( + [PSCustomObject]@{ + id = $deviceId1 + deviceName = 'DEVICE-001' + usersLoggedOn = @() + }, + [PSCustomObject]@{ + id = $deviceId2 + deviceName = 'DEVICE-001' + usersLoggedOn = @([PSCustomObject]@{ + userId = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + lastLogOnDateTime = '2024-03-05T11:00:00Z' + }) + } + ) + + Mock -CommandName 'Resolve-IntuneDeviceByName' -MockWith { return $mockDeviceSummaries } + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match $deviceId1) { return $mockDevices[0] } + else { return $mockDevices[1] } + } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { + return [PSCustomObject]@{ + id = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + userPrincipalName = 'user1@contoso.com' + } + } + + # Act + $results = @(Get-IntuneDeviceLogin -DeviceName 'DEVICE-001') + + # Assert + $results.Count | Should -Be 1 + $results.DeviceId | Should -Be $deviceId2 + } + + It 'Should reject empty DeviceName' { + # Act & Assert + { Get-IntuneDeviceLogin -DeviceName '' } | Should -Throw + } + + It 'Should reject null DeviceName' { + # Act & Assert + { Get-IntuneDeviceLogin -DeviceName $null } | Should -Throw + } + } + + Context 'Pipeline and property binding' { + It 'Should accept DeviceId from pipeline' { + # Arrange + $testDeviceId = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $pipelineObject = [PSCustomObject]@{ Id = $testDeviceId } + + $mockDevice = [PSCustomObject]@{ + id = $testDeviceId + deviceName = 'TEST-DEVICE' + usersLoggedOn = @([PSCustomObject]@{ + userId = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + lastLogOnDateTime = '2024-03-05T10:30:00Z' + }) + } + + $mockUser = [PSCustomObject]@{ + id = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + userPrincipalName = 'user@contoso.com' + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $result = $pipelineObject | Get-IntuneDeviceLogin + + # Assert + $result | Should -Not -BeNullOrEmpty + Assert-MockCalled -CommandName 'Invoke-GraphGet' -Times 1 + } + + It 'Should accept DeviceName from pipeline' { + # Arrange + $testDeviceName = 'TEST-DEVICE' + + $mockDeviceSummary = [PSCustomObject]@{ + Id = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + DeviceName = $testDeviceName + } + + $mockDevice = [PSCustomObject]@{ + id = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + deviceName = $testDeviceName + usersLoggedOn = @([PSCustomObject]@{ + userId = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + lastLogOnDateTime = '2024-03-05T10:30:00Z' + }) + } + + $mockUser = [PSCustomObject]@{ + id = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + userPrincipalName = 'user@contoso.com' + } + + Mock -CommandName 'Resolve-IntuneDeviceByName' -MockWith { return @($mockDeviceSummary) } + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $result = $testDeviceName | Get-IntuneDeviceLogin + + # Assert + $result | Should -Not -BeNullOrEmpty + Assert-MockCalled -CommandName 'Resolve-IntuneDeviceByName' -Times 1 + } + + It 'Should accept DeviceId from pipeline by property name' { + # Arrange + $testDeviceId = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + $pipelineObject = [PSCustomObject]@{ + Id = $testDeviceId + } + + $mockDevice = [PSCustomObject]@{ + id = $testDeviceId + deviceName = 'TEST-DEVICE' + usersLoggedOn = @([PSCustomObject]@{ + userId = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + lastLogOnDateTime = '2024-03-05T10:30:00Z' + }) + } + + $mockUser = [PSCustomObject]@{ + id = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + userPrincipalName = 'user@contoso.com' + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevice } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $result = $pipelineObject | Get-IntuneDeviceLogin + + # Assert + $result | Should -Not -BeNullOrEmpty + } + } + + Context 'When called with ByUserPrincipalName parameter set' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUserId = 'u1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUserPrincipalName = 'john.doe@contoso.com' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceId1 = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceId2 = 'c2f6d2d8-2d2c-4d8d-9f0b-0d2b3d1e2f3b' + } + + It 'Should find devices where the user has logged in' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + $mockDevicesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId1 + deviceName = 'DEVICE-001' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + }, + [PSCustomObject]@{ + id = $testDeviceId2 + deviceName = 'DEVICE-002' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = 'u2e2a2d7-2d2b-4d8c-9f0a-0d2a3d1e2f3b' + lastLogOnDateTime = '2024-03-04T09:15:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'users/') { + return $mockUser + } else { + return $mockDevicesResponse + } + } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @(Get-IntuneDeviceLogin -UserPrincipalName $testUserPrincipalName) + + # Assert + $results.Count | Should -Be 1 + $results[0].DeviceId | Should -Be $testDeviceId1 + $results[0].DeviceName | Should -Be 'DEVICE-001' + $results[0].UserId | Should -Be $testUserId + $results[0].UserPrincipalName | Should -Be $testUserPrincipalName + } + + It 'Should find multiple devices where the user has logged in' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + $mockDevicesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId1 + deviceName = 'DEVICE-001' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + }, + [PSCustomObject]@{ + id = $testDeviceId2 + deviceName = 'DEVICE-002' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-04T09:15:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'users/') { + return $mockUser + } else { + return $mockDevicesResponse + } + } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @(Get-IntuneDeviceLogin -UserPrincipalName $testUserPrincipalName) + + # Assert + $results.Count | Should -Be 2 + $results[0].DeviceId | Should -Be $testDeviceId1 + $results[1].DeviceId | Should -Be $testDeviceId2 + } + + It 'Should return nothing if user has not logged into any devices' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + $mockDevicesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId1 + deviceName = 'DEVICE-001' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = 'u2e2a2d7-2d2b-4d8c-9f0a-0d2a3d1e2f3b' + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'users/') { + return $mockUser + } else { + return $mockDevicesResponse + } + } + + # Act + $results = @(Get-IntuneDeviceLogin -UserPrincipalName $testUserPrincipalName) + + # Assert + $results.Count | Should -Be 0 + } + + It 'Should handle non-existent UserPrincipalName gracefully' { + # Arrange + Mock -CommandName 'Invoke-GraphGet' -MockWith { + throw "User not found" + } + + # Act & Assert + { Get-IntuneDeviceLogin -UserPrincipalName 'nonexistent@contoso.com' -ErrorAction Stop } | Should -Throw + } + + It 'Should work with UserPrincipalName alias "UPN"' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + $mockDevicesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId1 + deviceName = 'DEVICE-001' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'users/') { + return $mockUser + } else { + return $mockDevicesResponse + } + } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @(Get-IntuneDeviceLogin -UPN $testUserPrincipalName) + + # Assert + $results.Count | Should -Be 1 + $results[0].UserPrincipalName | Should -Be $testUserPrincipalName + } + + It 'Should skip devices with no logged-on users' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + $mockDevicesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId1 + deviceName = 'DEVICE-001' + usersLoggedOn = @() + }, + [PSCustomObject]@{ + id = $testDeviceId2 + deviceName = 'DEVICE-002' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'users/') { + return $mockUser + } else { + return $mockDevicesResponse + } + } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @(Get-IntuneDeviceLogin -UserPrincipalName $testUserPrincipalName) + + # Assert + $results.Count | Should -Be 1 + $results[0].DeviceId | Should -Be $testDeviceId2 + } + + It 'Should find device in second page during pagination' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + # Simulate paginated response: first page has different user, second page has target user + $page1 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = 'c0000000-0000-0000-0000-000000000001' + deviceName = 'OTHER-DEVICE-1' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = 'u0000000-0000-0000-0000-000000000099' + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + } + ) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$select=id,deviceName,usersLoggedOn&$skiptoken=xyz' + } + + $page2 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId1 + deviceName = 'DEVICE-001' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + } + ) + } + + $callCount = 0 + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + $callCount++ + if ($Uri -match 'users/') { + return $mockUser + } else { + # Simulate pagination by returning page1 first, then page2 + if ($Uri -match 'skiptoken') { + return $page2 + } else { + return $page1 + } + } + } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @(Get-IntuneDeviceLogin -UserPrincipalName $testUserPrincipalName) + + # Assert + $results.Count | Should -Be 1 + $results[0].DeviceId | Should -Be $testDeviceId1 + $results[0].DeviceName | Should -Be 'DEVICE-001' + } + + It 'Should find multiple devices across multiple pages' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + # Simulate multiple pages with target user on both pages + $page1 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId1 + deviceName = 'DEVICE-001' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + } + ) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$select=id,deviceName,usersLoggedOn&$skiptoken=abc' + } + + $page2 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId2 + deviceName = 'DEVICE-002' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-04T09:15:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'users/') { + return $mockUser + } else { + if ($Uri -match 'skiptoken') { + return $page2 + } else { + return $page1 + } + } + } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @(Get-IntuneDeviceLogin -UserPrincipalName $testUserPrincipalName) + + # Assert + $results.Count | Should -Be 2 + $results[0].DeviceId | Should -Be $testDeviceId1 + $results[1].DeviceId | Should -Be $testDeviceId2 + } + + It 'Should handle pagination with no matches on first page but match on second' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + # First page: no target user + $page1 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = 'c0000000-0000-0000-0000-000000000001' + deviceName = 'OTHER-DEVICE' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = 'u0000000-0000-0000-0000-000000000099' + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + }, + [PSCustomObject]@{ + id = 'c0000000-0000-0000-0000-000000000002' + deviceName = 'OTHER-DEVICE-2' + usersLoggedOn = @() + } + ) + '@odata.nextLink' = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$select=id,deviceName,usersLoggedOn&$skiptoken=def' + } + + # Second page: match found + $page2 = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId1 + deviceName = 'DEVICE-ON-PAGE2' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-03T14:22:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { + param([string]$Uri) + if ($Uri -match 'users/') { + return $mockUser + } else { + if ($Uri -match 'skiptoken') { + return $page2 + } else { + return $page1 + } + } + } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @(Get-IntuneDeviceLogin -UserPrincipalName $testUserPrincipalName) + + # Assert + $results.Count | Should -Be 1 + $results[0].DeviceId | Should -Be $testDeviceId1 + $results[0].DeviceName | Should -Be 'DEVICE-ON-PAGE2' + } + } + + Context 'When called with ByUserId parameter set' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUserId = 'a1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUserPrincipalName = 'john.doe@contoso.com' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceId1 = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + } + + It 'Should find devices where the user (by ID) has logged in' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + $mockDevicesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId1 + deviceName = 'DEVICE-001' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevicesResponse } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @(Get-IntuneDeviceLogin -UserId $testUserId) + + # Assert + $results.Count | Should -Be 1 + $results[0].DeviceId | Should -Be $testDeviceId1 + $results[0].UserId | Should -Be $testUserId + $results[0].UserPrincipalName | Should -Be $testUserPrincipalName + } + + It 'Should reject invalid GUID format for UserId' { + # Act & Assert + { Get-IntuneDeviceLogin -UserId 'not-a-guid' } | Should -Throw + } + + It 'Should accept UserId from pipeline by property name' { + # Arrange + $pipelineObject = [PSCustomObject]@{ + UserId = $testUserId + } + + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + $mockDevicesResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId1 + deviceName = 'DEVICE-001' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $mockDevicesResponse } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @($pipelineObject | Get-IntuneDeviceLogin) + + # Assert + $results.Count | Should -Be 1 + $results[0].UserId | Should -Be $testUserId + } + } + + Context 'Pagination handling for user searches' { + BeforeEach { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUserId = 'a1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testUserPrincipalName = 'john.doe@contoso.com' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceId1 = 'c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a' + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $testDeviceId2 = 'c2f6d2d8-2d2c-4d8d-9f0b-0d2b3d1e2f3b' + } + + It 'Should handle paginated response for UserId search' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + # Simulate paginated response: Invoke-GraphGet should aggregate results + $paginatedResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = $testDeviceId1 + deviceName = 'DEVICE-001' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = 'u0000000-0000-0000-0000-000000000001' + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + }, + [PSCustomObject]@{ + id = $testDeviceId2 + deviceName = 'DEVICE-002' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-04T09:15:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $paginatedResponse } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @(Get-IntuneDeviceLogin -UserId $testUserId) + + # Assert + # Invoke-GraphGet handles pagination and aggregates results + $results.Count | Should -Be 1 + $results[0].DeviceId | Should -Be $testDeviceId2 + $results[0].UserId | Should -Be $testUserId + } + + It 'Should find user on device in aggregated paginated results' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + # Simulate result from Invoke-GraphGet that already aggregated multiple pages + $aggregatedResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = 'c0000000-0000-0000-0000-000000000001' + deviceName = 'PAGE1-DEVICE' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = 'u0000000-0000-0000-0000-000000000099' + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + }, + [PSCustomObject]@{ + id = 'c0000000-0000-0000-0000-000000000002' + deviceName = 'PAGE2-DEVICE' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-03T14:22:00Z' + } + ) + }, + [PSCustomObject]@{ + id = 'c0000000-0000-0000-0000-000000000003' + deviceName = 'PAGE3-DEVICE' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = 'u0000000-0000-0000-0000-000000000088' + lastLogOnDateTime = '2024-03-02T08:45:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $aggregatedResponse } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @(Get-IntuneDeviceLogin -UserId $testUserId) + + # Assert + # Should find the user even though they're in the middle of aggregated results + $results.Count | Should -Be 1 + $results[0].DeviceId | Should -Be 'c0000000-0000-0000-0000-000000000002' + $results[0].DeviceName | Should -Be 'PAGE2-DEVICE' + } + + It 'Should find multiple matches across aggregated pages' { + # Arrange + $mockUser = [PSCustomObject]@{ + id = $testUserId + userPrincipalName = $testUserPrincipalName + } + + # Simulate aggregated result with target user on multiple devices + $aggregatedResponse = [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ + id = 'c0000000-0000-0000-0000-000000000001' + deviceName = 'PAGE1-DEVICE' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-05T10:30:00Z' + } + ) + }, + [PSCustomObject]@{ + id = 'c0000000-0000-0000-0000-000000000002' + deviceName = 'PAGE2-DEVICE' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = 'u0000000-0000-0000-0000-000000000099' + lastLogOnDateTime = '2024-03-04T09:15:00Z' + } + ) + }, + [PSCustomObject]@{ + id = 'c0000000-0000-0000-0000-000000000003' + deviceName = 'PAGE3-DEVICE' + usersLoggedOn = @( + [PSCustomObject]@{ + userId = $testUserId + lastLogOnDateTime = '2024-03-02T08:45:00Z' + } + ) + } + ) + } + + Mock -CommandName 'Invoke-GraphGet' -MockWith { return $aggregatedResponse } + Mock -CommandName 'Resolve-EntraUserById' -MockWith { return $mockUser } + + # Act + $results = @(Get-IntuneDeviceLogin -UserId $testUserId) + + # Assert + $results.Count | Should -Be 2 + $results[0].DeviceName | Should -Be 'PAGE1-DEVICE' + $results[1].DeviceName | Should -Be 'PAGE3-DEVICE' + } + } +} \ No newline at end of file