Tests/sessions/Get-RdpSession.Tests.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
    Pester v5 test suite for Get-RdpSession v2.0 (quser-based implementation).
 
.DESCRIPTION
    Validates ConvertFrom-QUserIdleTime and Get-RdpSession using controlled
    quser output strings. Invoke-Command is mocked throughout remote-query tests
    to return deterministic output independent of the test environment.
 
    Test scope:
      - ConvertFrom-QUserIdleTime: all idle-time formats and edge cases.
      - Get-RdpSession: output parsing, object shape, IsCurrentSession flag,
        idle-time conversion, pipeline input, local-vs-remote code path,
        Credential forwarding, and error handling.
 
.NOTES
    Author: Franck SALLET
    Version: 2.0.0
    Last Modified: 2026-03-11
    Requires: PowerShell 5.1+, Pester 5.x, PSWinOps module
    Permissions: None -- all system calls are mocked
#>


BeforeAll {
    # ---------------------------------------------------------------------------
    # Fake quser output -- column positions verified against real quser.exe output.
    #
    # Header column offsets:
    # colUser = 1 (start of USERNAME field)
    # colSession = 23 (IndexOf 'SESSIONNAME')
    # colId = 42 (IndexOf ' ID ' + 1)
    # colState = 46 (IndexOf 'STATE')
    # colIdle = 54 (IndexOf 'IDLE TIME')
    # colLogon = 65 (IndexOf 'LOGON TIME')
    # ---------------------------------------------------------------------------
    $script:fakeHeader = ' USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME'
    $script:fakeActive = '>adm-fsallet rdp-tcp#3 3 Active . 11/03/2026 19:35'
    $script:fakeDisc = ' adm-asaintpierre rdp-tcp#2 2 Disc 1+08:15 10/03/2026 11:24'
    $script:fakeTwoSessions = @($script:fakeHeader, $script:fakeActive, $script:fakeDisc)
    $script:fakeHeaderOnly = @($script:fakeHeader)
    $script:fakeRemoteHost = 'FAKE-REMOTE-HOST'

    # PSScriptAnalyzer suppression: dummy test credential only, not used in production
    # cSpell:disable
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Test-only credential')]
    $script:fakeCredential = [System.Management.Automation.PSCredential]::new(
        'TESTDOMAIN\testuser',
        (ConvertTo-SecureString -String 'FakeTestP@ss!' -AsPlainText -Force)
    )
    # cSpell:enable

    $script:modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\PSWinOps.psd1'
    Import-Module -Name $script:modulePath -Force -ErrorAction Stop
}

# ===========================================================================
# ConvertFrom-QUserIdleTime -- private function, accessed via InModuleScope
# ===========================================================================

Describe -Name 'ConvertFrom-QUserIdleTime' -Fixture {

    Context -Name 'When the session is currently active (zero idle time)' -Fixture {

        It -Name 'Should return TimeSpan.Zero for a dot' -Test {
            InModuleScope -ModuleName 'PSWinOps' -ScriptBlock {
                $result = ConvertFrom-QUserIdleTime -IdleTimeString '.'
                $result | Should -Be ([TimeSpan]::Zero)
            }
        }

        It -Name 'Should return TimeSpan.Zero for the word none' -Test {
            InModuleScope -ModuleName 'PSWinOps' -ScriptBlock {
                $result = ConvertFrom-QUserIdleTime -IdleTimeString 'none'
                $result | Should -Be ([TimeSpan]::Zero)
            }
        }

        It -Name 'Should return TimeSpan.Zero for an empty string' -Test {
            InModuleScope -ModuleName 'PSWinOps' -ScriptBlock {
                $result = ConvertFrom-QUserIdleTime -IdleTimeString ''
                $result | Should -Be ([TimeSpan]::Zero)
            }
        }

        It -Name 'Should return TimeSpan.Zero for a whitespace-only string' -Test {
            InModuleScope -ModuleName 'PSWinOps' -ScriptBlock {
                $result = ConvertFrom-QUserIdleTime -IdleTimeString ' '
                $result | Should -Be ([TimeSpan]::Zero)
            }
        }
    }

    Context -Name 'When idle time is expressed in minutes only' -Fixture {

        It -Name 'Should return the correct TimeSpan for a single-digit minute count' -Test {
            InModuleScope -ModuleName 'PSWinOps' -ScriptBlock {
                $result = ConvertFrom-QUserIdleTime -IdleTimeString '5'
                $result | Should -Be ([TimeSpan]::FromMinutes(5))
            }
        }

        It -Name 'Should return the correct TimeSpan for a two-digit minute count' -Test {
            InModuleScope -ModuleName 'PSWinOps' -ScriptBlock {
                $result = ConvertFrom-QUserIdleTime -IdleTimeString '45'
                $result | Should -Be ([TimeSpan]::FromMinutes(45))
            }
        }
    }

    Context -Name 'When idle time is in H:MM format' -Fixture {

        It -Name 'Should parse hours and minutes into the correct TimeSpan' -Test {
            InModuleScope -ModuleName 'PSWinOps' -ScriptBlock {
                $result = ConvertFrom-QUserIdleTime -IdleTimeString '8:15'
                $result | Should -Be ([TimeSpan]::new(8, 15, 0))
            }
        }

        It -Name 'Should handle zero minutes correctly' -Test {
            InModuleScope -ModuleName 'PSWinOps' -ScriptBlock {
                $result = ConvertFrom-QUserIdleTime -IdleTimeString '2:00'
                $result | Should -Be ([TimeSpan]::new(2, 0, 0))
            }
        }
    }

    Context -Name 'When idle time is in D+H:MM format' -Fixture {

        It -Name 'Should parse a one-day idle period correctly' -Test {
            InModuleScope -ModuleName 'PSWinOps' -ScriptBlock {
                $result = ConvertFrom-QUserIdleTime -IdleTimeString '1+08:15'
                $result | Should -Be ([TimeSpan]::new(1, 8, 15, 0))
            }
        }

        It -Name 'Should parse a multi-day idle period correctly' -Test {
            InModuleScope -ModuleName 'PSWinOps' -ScriptBlock {
                $result = ConvertFrom-QUserIdleTime -IdleTimeString '14+10:17'
                $result | Should -Be ([TimeSpan]::new(14, 10, 17, 0))
            }
        }
    }

    Context -Name 'When the idle time format is not recognised' -Fixture {

        It -Name 'Should return TimeSpan.Zero as a safe fallback' -Test {
            InModuleScope -ModuleName 'PSWinOps' -ScriptBlock {
                $result = ConvertFrom-QUserIdleTime -IdleTimeString 'unknown-format'
                $result | Should -Be ([TimeSpan]::Zero)
            }
        }
    }
}

# ===========================================================================
# Get-RdpSession -- public exported function
# ===========================================================================

Describe -Name 'Get-RdpSession' -Fixture {

    Context -Name 'When a remote computer returns active and disconnected sessions' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -ModuleName 'PSWinOps' -MockWith {
                return $script:fakeTwoSessions
            }
        }

        It -Name 'Should return one object per data row' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result.Count | Should -Be 2
        }

        It -Name 'Should stamp the correct PSTypeName on each returned object' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[0].PSObject.TypeNames | Should -Contain 'PSWinOps.ActiveRdpSession'
        }

        It -Name 'Should expose all expected properties on the returned object' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $propNames = $result[0].PSObject.Properties.Name
            $propNames | Should -Contain 'ComputerName'
            $propNames | Should -Contain 'SessionID'
            $propNames | Should -Contain 'SessionName'
            $propNames | Should -Contain 'UserName'
            $propNames | Should -Contain 'State'
            $propNames | Should -Contain 'IdleTime'
            $propNames | Should -Contain 'LogonTime'
            $propNames | Should -Contain 'IsCurrentSession'
        }

        It -Name 'Should set IsCurrentSession to true only for the line prefixed with >' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[0].IsCurrentSession | Should -BeTrue
            $result[1].IsCurrentSession | Should -BeFalse
        }

        It -Name 'Should parse the Active state for the first session' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[0].State | Should -Be 'Active'
        }

        It -Name 'Should parse the Disc state for the disconnected session' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[1].State | Should -Be 'Disc'
        }

        It -Name 'Should set IdleTime to TimeSpan.Zero for the active session' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[0].IdleTime | Should -Be ([TimeSpan]::Zero)
        }

        It -Name 'Should parse D+H:MM idle time for the disconnected session' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[1].IdleTime | Should -Be ([TimeSpan]::new(1, 8, 15, 0))
        }

        It -Name 'Should parse the correct UserName from the active session' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[0].UserName | Should -Be 'adm-fsallet'
        }

        It -Name 'Should parse the correct UserName from the disconnected session' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[1].UserName | Should -Be 'adm-asaintpierre'
        }

        It -Name 'Should parse the correct SessionName from the active session' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[0].SessionName | Should -Be 'rdp-tcp#3'
        }

        It -Name 'Should set ComputerName to the queried computer on every object' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[0].ComputerName | Should -Be $script:fakeRemoteHost
            $result[1].ComputerName | Should -Be $script:fakeRemoteHost
        }

        It -Name 'Should set SessionID to the parsed integer for the active session' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[0].SessionID | Should -Be 3
        }

        It -Name 'Should set SessionID to the parsed integer for the disconnected session' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[1].SessionID | Should -Be 2
        }

        It -Name 'Should populate LogonTime as a DateTime object' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result[0].LogonTime | Should -BeOfType ([datetime])
        }

        It -Name 'Should invoke Invoke-Command exactly once for a single remote query' -Test {
            Get-RdpSession -ComputerName $script:fakeRemoteHost
            Should -Invoke -CommandName 'Invoke-Command' -ModuleName 'PSWinOps' -Times 1 -Exactly
        }
    }

    Context -Name 'When the remote computer has no users logged on' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -ModuleName 'PSWinOps' -MockWith {
                return $script:fakeHeaderOnly
            }
        }

        It -Name 'Should return nothing when quser outputs only a header line' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost
            $result | Should -BeNullOrEmpty
        }
    }

    Context -Name 'When the local computer is queried' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -ModuleName 'PSWinOps' -MockWith {}
        }

        It -Name 'Should not call Invoke-Command when the target is the local machine' -Test {
            # quser.exe is invoked directly for local queries -- WinRM is not used.
            Get-RdpSession -ComputerName $env:COMPUTERNAME -ErrorAction SilentlyContinue
            Should -Invoke -CommandName 'Invoke-Command' -ModuleName 'PSWinOps' -Times 0 -Exactly
        }
    }

    Context -Name 'When multiple computers are provided via pipeline input' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -ModuleName 'PSWinOps' -MockWith {
                return $script:fakeHeaderOnly
            }
        }

        It -Name 'Should invoke Invoke-Command exactly once per remote computer' -Test {
            @('FAKE-SRV01', 'FAKE-SRV02') | Get-RdpSession -ErrorAction SilentlyContinue
            Should -Invoke -CommandName 'Invoke-Command' -ModuleName 'PSWinOps' -Times 2 -Exactly
        }
    }

    Context -Name 'When a Credential is provided for a remote query' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -ModuleName 'PSWinOps' -MockWith {
                return $script:fakeHeaderOnly
            }
        }

        It -Name 'Should forward the Credential parameter to Invoke-Command' -Test {
            Get-RdpSession -ComputerName $script:fakeRemoteHost -Credential $script:fakeCredential -ErrorAction SilentlyContinue
            Should -Invoke -CommandName 'Invoke-Command' -ModuleName 'PSWinOps' -Times 1 -Exactly -ParameterFilter {
                $null -ne $Credential
            }
        }
    }

    Context -Name 'When a general runtime error occurs during the remote query' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -ModuleName 'PSWinOps' -MockWith {
                throw 'Simulated remote query failure'
            }
        }

        It -Name 'Should not throw when an unexpected error is caught' -Test {
            { Get-RdpSession -ComputerName $script:fakeRemoteHost -ErrorAction SilentlyContinue } | Should -Not -Throw
        }

        It -Name 'Should return no objects when an unexpected error occurs' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost -ErrorAction SilentlyContinue
            $result | Should -BeNullOrEmpty
        }
    }

    Context -Name 'When access is denied on the remote computer' -Fixture {

        BeforeEach {
            Mock -CommandName 'Invoke-Command' -ModuleName 'PSWinOps' -MockWith {
                throw [System.UnauthorizedAccessException]::new('Access denied')
            }
        }

        It -Name 'Should not throw when an UnauthorizedAccessException is caught' -Test {
            { Get-RdpSession -ComputerName $script:fakeRemoteHost -ErrorAction SilentlyContinue } | Should -Not -Throw
        }

        It -Name 'Should return no objects when access is denied' -Test {
            $result = Get-RdpSession -ComputerName $script:fakeRemoteHost -ErrorAction SilentlyContinue
            $result | Should -BeNullOrEmpty
        }
    }
}