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