From 171285bae288f12370c25a67d00857fbff1b81cc Mon Sep 17 00:00:00 2001 From: Initzero Date: Mon, 5 Oct 2026 08:03:27 +0200 Subject: [PATCH] Restore logon timestamp conversion and OS uptime --- CHANGELOG.md | 11 ++ IT-ToolBox.psd1 | 2 + IT-ToolBox.psm1 | 2 + Private/Get-ITToolBoxRemoteUptime.ps1 | 40 ++++++ Public/Convert-LogonTimestamp.ps1 | 47 +++++++ Public/Get-OsUpTime.ps1 | 55 ++++++++ README.md | 44 +++++- Staging/v3/Convert-LogonTimestamp.ps1 | 94 ------------- Staging/v3/Get-OsUpTime.ps1 | 161 --------------------- Staging/v3/README.md | 4 +- Tests/Module.Tests.ps1 | 2 +- Tests/TimeUtilities.Tests.ps1 | 192 ++++++++++++++++++++++++++ 12 files changed, 392 insertions(+), 262 deletions(-) create mode 100644 Private/Get-ITToolBoxRemoteUptime.ps1 create mode 100644 Public/Convert-LogonTimestamp.ps1 create mode 100644 Public/Get-OsUpTime.ps1 delete mode 100644 Staging/v3/Convert-LogonTimestamp.ps1 delete mode 100644 Staging/v3/Get-OsUpTime.ps1 create mode 100644 Tests/TimeUtilities.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ba62..4f1c555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## Timestamp and uptime restoration (unreleased) + +- Restore Convert-LogonTimestamp and Get-OsUpTime; export twenty-two supported commands. +- Preserve local DateTime and default date formatting; add UTC and pipeline conversion. +- Treat zero logon timestamps as no recorded logon; reject invalid FILETIME values. +- Use invariant string formatting and document replicated AD timestamp semantics. +- Preserve uptime days/TimeSpan outputs; support local uptime on Windows, Linux and macOS. +- Replace remote WMI with CIM, use the server clock, support explicit/prompted credentials and clean up sessions. +- Propagate errors without changing caller preferences; document remote Windows requirements. +- Add boundary, culture, local uptime and mocked CIM regression tests. + ## Email and URL validation restoration (unreleased) - Restore Test-IsEmail and Test-IsUrl; export twenty supported commands. diff --git a/IT-ToolBox.psd1 b/IT-ToolBox.psd1 index 4fb03b8..6757fb5 100644 --- a/IT-ToolBox.psd1 +++ b/IT-ToolBox.psd1 @@ -20,6 +20,8 @@ 'Test-IsDate' 'Test-IsEmail' 'Test-IsUrl' + 'Convert-LogonTimestamp' + 'Get-OsUpTime' 'New-StringEncryption' 'New-StringDecryption' 'New-RandomString' diff --git a/IT-ToolBox.psm1 b/IT-ToolBox.psm1 index 8a6fe15..0b55c55 100644 --- a/IT-ToolBox.psm1 +++ b/IT-ToolBox.psm1 @@ -20,6 +20,8 @@ Export-ModuleMember -Function @( 'Test-IsDate' 'Test-IsEmail' 'Test-IsUrl' + 'Convert-LogonTimestamp' + 'Get-OsUpTime' 'New-StringEncryption' 'New-StringDecryption' 'New-RandomString' diff --git a/Private/Get-ITToolBoxRemoteUptime.ps1 b/Private/Get-ITToolBoxRemoteUptime.ps1 new file mode 100644 index 0000000..a278509 --- /dev/null +++ b/Private/Get-ITToolBoxRemoteUptime.ps1 @@ -0,0 +1,40 @@ +function Get-ITToolBoxRemoteUptime { + param([string]$ComputerName, [pscredential]$Credential) + + if (-not (Get-Command Get-CimInstance -ErrorAction SilentlyContinue)) { + throw [System.PlatformNotSupportedException]::new('Remote uptime requires Windows CIM cmdlets; run this query from PowerShell on Windows.') + } + $query = @{ + Namespace = 'root/cimv2' + ClassName = 'Win32_OperatingSystem' + Property = @('LastBootUpTime', 'LocalDateTime') + ErrorAction = 'Stop' + } + $session = $null + $queryFailed = $false + try { + if ($null -ne $Credential) { + $session = New-CimSession -ComputerName $ComputerName -Credential $Credential -ErrorAction Stop + $query.CimSession = $session + } + else { $query.ComputerName = $ComputerName } + $os = Get-CimInstance @query + if ($os.LastBootUpTime -isnot [datetime] -or $os.LocalDateTime -isnot [datetime]) { + throw 'The remote OS did not return valid LastBootUpTime and LocalDateTime values.' + } + return ($os.LocalDateTime.ToUniversalTime() - $os.LastBootUpTime.ToUniversalTime()) + } + catch { + $queryFailed = $true + throw + } + finally { + if ($null -ne $session) { + try { Remove-CimSession -CimSession $session -ErrorAction Stop } + catch { + if (-not $queryFailed) { throw } + Write-Warning ('CIM session cleanup also failed: {0}' -f $_.Exception.Message) + } + } + } +} diff --git a/Public/Convert-LogonTimestamp.ps1 b/Public/Convert-LogonTimestamp.ps1 new file mode 100644 index 0000000..7a78eb2 --- /dev/null +++ b/Public/Convert-LogonTimestamp.ps1 @@ -0,0 +1,47 @@ +function Convert-LogonTimestamp { + <# + .SYNOPSIS + Converts an AD logon FILETIME into a DateTime or formatted string. + .DESCRIPTION + Accepts an unsigned decimal string or an Int64 FILETIME value, with pipeline + support. Returns local DateTime by default; -Utc returns UTC instead. Zero + means no recorded logon and produces no output. Invalid or out-of-range input + throws. This converts the value only; lastLogonTimestamp is replicated and is + not a precise real-time record of a user's most recent authentication. + .PARAMETER StringOutput + Returns text instead of DateTime. Formatting uses invariant culture. + .PARAMETER DateFormat + .NET date format; specifying this also selects string output. Defaults to yyyy-MM-dd. + .EXAMPLE + Convert-LogonTimestamp -TimeStamp 133801632000000000 -Utc + #> + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([datetime], ParameterSetName = 'Default')] + [OutputType([string], ParameterSetName = 'StringOutput')] + param( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] + [ValidateNotNullOrEmpty()] + [string]$TimeStamp, + [Parameter(ParameterSetName = 'StringOutput')] + [switch]$StringOutput, + [Parameter(ParameterSetName = 'StringOutput')] + [ValidateNotNullOrEmpty()] + [string]$DateFormat = 'yyyy-MM-dd', + [switch]$Utc + ) + + process { + [long]$fileTime = 0 + if (-not [long]::TryParse($TimeStamp, [System.Globalization.NumberStyles]::None, + [System.Globalization.CultureInfo]::InvariantCulture, [ref]$fileTime)) { + throw [System.FormatException]::new('TimeStamp must be an unsigned decimal Int64 FILETIME value.') + } + if ($fileTime -eq 0) { return } + $date = [datetime]::FromFileTimeUtc($fileTime) + if (-not $Utc) { $date = $date.ToLocalTime() } + if ($PSCmdlet.ParameterSetName -eq 'StringOutput') { + return $date.ToString($DateFormat, [System.Globalization.CultureInfo]::InvariantCulture) + } + return $date + } +} diff --git a/Public/Get-OsUpTime.ps1 b/Public/Get-OsUpTime.ps1 new file mode 100644 index 0000000..dbd1c63 --- /dev/null +++ b/Public/Get-OsUpTime.ps1 @@ -0,0 +1,55 @@ +function Get-OsUpTime { + <# + .SYNOPSIS + Returns local OS uptime or a remote Windows computer's uptime. + .DESCRIPTION + Uses Get-Uptime locally on Windows, Linux and macOS. Remote queries require + Windows CIM cmdlets and a reachable Windows CIM endpoint. Remote uptime is + computed from the server's LocalDateTime and LastBootUpTime, avoiding use of + the client's clock. Failures terminate; any session created here is disposed. + .PARAMETER FullOutput + Returns TimeSpan. Without this switch, returns whole elapsed days as Int32. + .PARAMETER Credential + Explicit credentials for a remote computer, without an interactive prompt. + .PARAMETER Credentials + Compatibility switch: prompts with Get-Credential for a remote computer. + .EXAMPLE + Get-OsUpTime -FullOutput + .EXAMPLE + Get-OsUpTime -ComputerName server01 -Credential $credential -FullOutput + #> + [CmdletBinding(DefaultParameterSetName = 'LocalMachine')] + [OutputType([int], [timespan])] + param( + [Parameter(ParameterSetName = 'RemoteMachine', Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ComputerName, + [switch]$FullOutput, + [Parameter(ParameterSetName = 'RemoteMachine')] + [ValidateNotNull()] + [pscredential]$Credential, + [Parameter(ParameterSetName = 'RemoteMachine')] + [switch]$Credentials + ) + + if ($Credentials -and $PSBoundParameters.ContainsKey('Credential')) { + throw 'Use either -Credential or -Credentials, not both.' + } + if ($PSCmdlet.ParameterSetName -eq 'RemoteMachine') { + if ([string]::IsNullOrWhiteSpace($ComputerName)) { throw 'ComputerName cannot be whitespace.' } + if ($Credentials) { + $promptedCredential = Get-Credential -Message 'Please specify alternate credentials to be used:' -ErrorAction Stop + if ($null -eq $promptedCredential) { throw 'No credential was supplied.' } + $Credential = $promptedCredential + } + $uptime = Get-ITToolBoxRemoteUptime -ComputerName $ComputerName -Credential $Credential + } + else { + $uptime = Get-Uptime -ErrorAction Stop + } + if ($uptime -isnot [timespan] -or $uptime -lt [timespan]::Zero) { + throw 'The uptime source did not return a nonnegative TimeSpan.' + } + if ($FullOutput) { return $uptime } + return [int]$uptime.Days +} diff --git a/README.md b/README.md index ac99847..7c31fbb 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ The foundation imports without WinSCP, GnuPG, Active Directory or Exchange depen | Test-IsDate | Culture-aware date validation with optional exact format | | Test-IsEmail | Common bare email-address syntax, including IDN domains | | Test-IsUrl | Absolute HTTP/HTTPS/FTP/FTPS URL syntax | +| Convert-LogonTimestamp | AD FILETIME conversion with local/UTC output | +| Get-OsUpTime | Local OS uptime and remote Windows CIM queries | | New-StringEncryption | Passphrase-based AES-256-GCM string encryption | | New-StringDecryption | Authenticate and decrypt the versioned string format | | New-RandomString | Secure random selection from the historical alphabet | @@ -60,10 +62,10 @@ Redaction is opt-in and does not guarantee detection of every secret. - SCP and GnuPG wrappers and bundled WinSCP binaries are removed. Separate modules will own file transfer and OpenPGP; no replacement is bundled here. - `Legacy/` retains string encryption, Exchange and script-context helpers for reference. -- `Staging/v3/` retains 7 candidate commands pending tests and compatibility fixes. - These cover logon timestamps, uptime, report chains, character removal, - distinguished names, user principal names and registry values. They are not currently exported. -- Only the twenty listed commands are exported. Private helpers, variables and aliases +- `Staging/v3/` retains 5 candidate commands pending tests and compatibility fixes. + These cover report chains, character removal, distinguished names, + user principal names and registry values. They are not currently exported. +- Only the twenty-two listed commands are exported. Private helpers, variables and aliases are not exported. Existing calls to other v2 commands require the v2 release until those commands return to the supported API. - The module GUID and Git history are preserved. @@ -246,3 +248,37 @@ Use `-LegacyFormat` only when comparing with old delimiter-free decimal output. The hash algorithm remains SHA-256; the default representation intentionally changes. Both hash commands use UTF-8 without a BOM and do not normalize text. Neither is a password-storage function or an authentication mechanism. + +## Logon timestamps and OS uptime + +`Convert-LogonTimestamp` accepts an unsigned decimal FILETIME string or Int64 value +and supports pipeline input. Local DateTime output remains the default; use `-Utc` +for UTC. `-StringOutput` defaults to `yyyy-MM-dd`, and `-DateFormat` also selects +string output. String formatting uses invariant culture. Invalid values throw. +Zero means no recorded logon and produces no output rather than a date in 1601. +The command converts a supplied value only: AD's replicated `lastLogonTimestamp` +is not an exact record of the user's latest authentication. + +```powershell +Convert-LogonTimestamp -TimeStamp $user.lastLogonTimestamp -Utc +Convert-LogonTimestamp -TimeStamp $user.lastLogonTimestamp -Utc -DateFormat 'yyyy-MM-dd HH:mm:ss' +Get-OsUpTime -FullOutput +Get-OsUpTime -ComputerName 'server01' -Credential $credential -FullOutput +``` + +`Get-OsUpTime` preserves whole elapsed days as Int32 by default and TimeSpan with +`-FullOutput`. Local uptime delegates to PowerShell's built-in `Get-Uptime` on +Windows, Linux and macOS. Remote uptime requires Windows CIM cmdlets and a +reachable Windows CIM endpoint; no remote Linux/macOS support is implied. + +Remote queries replace removed `Get-WmiObject` calls with `Get-CimInstance`. +They compute uptime from the server's `LocalDateTime` and `LastBootUpTime`, not +the client's clock. With `-Credential`, a temporary CIM session is created and +cleanup is attempted in `finally`. The historical `-Credentials` switch prompts +with `Get-Credential`; either credential option requires `-ComputerName`. Errors +terminate rather than returning warnings and no value. A failed cleanup is reported +without replacing an already-failed query's error. The caller's error preference +is not changed. + +Tests cover actual local uptime and mocked remote query/session behavior. Remote +Windows connectivity and authentication have not been integration-tested. diff --git a/Staging/v3/Convert-LogonTimestamp.ps1 b/Staging/v3/Convert-LogonTimestamp.ps1 deleted file mode 100644 index caf772f..0000000 --- a/Staging/v3/Convert-LogonTimestamp.ps1 +++ /dev/null @@ -1,94 +0,0 @@ -function Convert-LogonTimeStamp -{ -<# - .SYNOPSIS - Will convert a logontimstamp to human redeable format. - - .DESCRIPTION - LastLoginTimeStamp attribute in AD stores the time a user last authenticated to the domain in LONG format - which is hard to parse and read. - - Function will take the attribute value, either as a string or long value, and return a DateTimeObject by default - or a string. - - .PARAMETER TimeStamp - The value to convert to human redeable format. - - .PARAMETER StringOutput - When specified paramater will convert output to string rather than datetime. - - .PARAMETER DateFormat - Available only when -StringOutput parameter is specified allows user to format the format of return string - if not specified date string will be formatted as YYYY-MM-dd - - .EXAMPLE - PS C:\> Convert-LogonTimeStamp -TimeStamp $lastLogin - - .OUTPUTS - System.String, System.DateTime -#> - - [CmdletBinding(DefaultParameterSetName = 'Default', - HelpUri = 'https://PsCustomObject.github.io', - SupportsShouldProcess = $true)] - [OutputType([datetime], ParameterSetName = 'Default')] - [OutputType([string], ParameterSetName = 'StringOutput')] - param - ( - [Parameter(ParameterSetName = 'Default', - Mandatory = $true)] - [Parameter(ParameterSetName = 'StringOutput', - Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string] - $TimeStamp, - [Parameter(ParameterSetName = 'StringOutput')] - [switch] - $StringOutput, - [Parameter(ParameterSetName = 'StringOutput')] - [ValidateNotNullOrEmpty()] - [string] - $DateFormat = 'yyyy-MM-dd' - ) - - switch ($PsCmdlet.ParameterSetName) - { - 'Default' - { - try - { - # Return the datetime object - return [datetime]::FromFileTime($TimeStamp) - } - catch - { - throw - } - - break - } - 'StringOutput' - { - try - { - if ($PSBoundParameters.ContainsKey('DateFormat')) - { - # Format according to user settings - return [datetime]::FromFileTime($TimeStamp).ToString($DateFormat) - } - else - { - # Use default format - return [datetime]::FromFileTime($TimeStamp).ToString($DateFormat) - } - } - - catch - { - throw - } - - break - } - } -} \ No newline at end of file diff --git a/Staging/v3/Get-OsUpTime.ps1 b/Staging/v3/Get-OsUpTime.ps1 deleted file mode 100644 index dbfef9e..0000000 --- a/Staging/v3/Get-OsUpTime.ps1 +++ /dev/null @@ -1,161 +0,0 @@ -function Get-OsUptime -{ - [CmdletBinding(DefaultParameterSetName = 'LocalMachine', - ConfirmImpact = 'None')] - param - ( - [Parameter(ParameterSetName = 'RemoteMachine')] - [ValidateNotNullOrEmpty()] - [string] - $ComputerName, - [Parameter(ParameterSetName = 'LocalMachine')] - [Parameter(ParameterSetName = 'RemoteMachine')] - [switch] - $FullOutput, - [Parameter(ParameterSetName = 'RemoteMachine')] - [switch] - $Credentials - ) - - if ($Credentials) - { - $userCredentials = Get-Credential -Message 'Please specify alternate credentials to be used:' - } - - if ($ComputerName) - { - if ($userCredentials) - { - try - { - # Catch non terminating errrors - $ErrorActionPreference = 'Stop' - - # Get releavant OS Info from WMI - $paramGetWmiObject = @{ - Namespace = 'root\cimv2' - Class = 'Win32_OperatingSystem' - ComputerName = $ComputerName - Credential = $userCredentials - } - - [System.Management.ManagementObject]$osInfo = Get-WmiObject @paramGetWmiObject - } - - catch [System.UnauthorizedAccessException] - { - [string]$reportedException = $Error[0].Exception.Message - - Write-Warning "Could not get remote machine $ComputerName uptime" - Write-Warning 'Access Denied error was returned - Try to use the -Credentials parameter' - Write-Warning 'Use -Credentials parameter to specify alternate credentials' - - # Revert change - $ErrorActionPreference = 'Continue' - - return - } - - catch - { - Write-Warning "Could not get remote machine $ComputerName uptime" - Write-Warning "Reported exception is $reportedException" - - # Revert change - $ErrorActionPreference = 'Continue' - - return - } - - finally - { - $ErrorActionPreference = 'Continue' - } - - } - else - { - try - { - # Catch non terminating errrors - $ErrorActionPreference = 'Stop' - - # Get releavant OS Info from WMI - $paramGetWmiObject = @{ - Namespace = 'root\cimv2' - Class = 'Win32_OperatingSystem' - ComputerName = $ComputerName - } - - [System.Management.ManagementObject]$osInfo = Get-WmiObject @paramGetWmiObject - } - - # Catch access denied - catch [System.UnauthorizedAccessException] - { - [string]$reportedException = $Error[0].Exception.Message - - Write-Warning "Could not get remote machine $ComputerName uptime" - Write-Warning 'Access Denied error was returned' - Write-Warning 'Use -Credentials parameter to specify alternate credentials' - - # Revert change - $ErrorActionPreference = 'Continue' - - return - } - - # Catch all - catch - { - - [string]$reportedException = $Error[0].Exception.Message - - if ($reportedException.Contains('Access denied')) - { - Write-Warning "Could not get remote machine $ComputerName uptime" - Write-Warning 'Access Denied error was returned' - Write-Warning 'Use -Credentials parameter to specify alternate credentials' - } - else - { - Write-Warning "Could not get remote machine $ComputerName uptime" - Write-Warning "Reported exception is $reportedException" - } - - # Revert change - $ErrorActionPreference = 'Continue' - - return - } - - finally - { - $ErrorActionPreference = 'Continue' - } - } - } - else - { - # Get releavant OS Info from WMI - $paramGetWmiObject = @{ - Namespace = 'root\cimv2' - Class = 'Win32_OperatingSystem' - ComputerName = 'localhost' - } - - [System.Management.ManagementObject]$osInfo = Get-WmiObject @paramGetWmiObject - } - - # Convert uptime to proper type - [timespan]$totalUptime = (Get-Date) - ($osInfo.ConvertToDateTime($osInfo.lastbootuptime)) - - if ($FullOutput) - { - return [timespan]$totalUptime - } - else - { - return [int]$totalUptime.Days - } -} \ No newline at end of file diff --git a/Staging/v3/README.md b/Staging/v3/README.md index 368d5de..466b6fb 100644 --- a/Staging/v3/README.md +++ b/Staging/v3/README.md @@ -2,6 +2,6 @@ These existing utilities are retained unchanged but are not loaded or exported. They return to Public only after behavioral tests and necessary PowerShell 7 fixes. -Remaining candidates cover logon timestamps, uptime, report chains, character removal, -distinguished names, user principal names and registry values. +Remaining candidates cover report chains, character removal, distinguished names, +user principal names and registry values. This folder is not a supported API and must not be dot-sourced as part of normal module import. diff --git a/Tests/Module.Tests.ps1 b/Tests/Module.Tests.ps1 index 123849c..1ad2b07 100644 --- a/Tests/Module.Tests.ps1 +++ b/Tests/Module.Tests.ps1 @@ -10,7 +10,7 @@ Describe 'IT-ToolBox module boundary' { } It 'exports exactly the supported commands' { - $expected = @('New-LogEntry', 'New-Timer', 'Get-TimerStatus', 'Stop-Timer', 'Get-ElapsedTime', 'Test-FileName', 'Test-IsValidPath', 'Test-IsIP', 'Test-IsDate', 'Test-IsEmail', 'Test-IsUrl', 'New-StringEncryption', 'New-StringDecryption', 'New-RandomString', 'New-RandomPassword', 'New-PhoneticPassword', 'New-ApiRequest', 'New-StringConversion', 'Get-StringCheckSum', 'Get-StringHashCode') | Sort-Object + $expected = @('New-LogEntry', 'New-Timer', 'Get-TimerStatus', 'Stop-Timer', 'Get-ElapsedTime', 'Test-FileName', 'Test-IsValidPath', 'Test-IsIP', 'Test-IsDate', 'Test-IsEmail', 'Test-IsUrl', 'Convert-LogonTimestamp', 'Get-OsUpTime', 'New-StringEncryption', 'New-StringDecryption', 'New-RandomString', 'New-RandomPassword', 'New-PhoneticPassword', 'New-ApiRequest', 'New-StringConversion', 'Get-StringCheckSum', 'Get-StringHashCode') | Sort-Object $actual = @($module.ExportedFunctions.Keys | Sort-Object) ($actual -join ',') | Should -Be ($expected -join ',') $module.ExportedVariables.Count | Should -Be 0 diff --git a/Tests/TimeUtilities.Tests.ps1 b/Tests/TimeUtilities.Tests.ps1 new file mode 100644 index 0000000..bd2917b --- /dev/null +++ b/Tests/TimeUtilities.Tests.ps1 @@ -0,0 +1,192 @@ +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '../IT-ToolBox.psd1') -Force -ErrorAction Stop + $knownUtc = [datetime]::new(2024, 2, 29, 12, 34, 56, [DateTimeKind]::Utc) + $knownFileTime = $knownUtc.ToFileTimeUtc() +} + +Describe 'Logon FILETIME conversion' { + It 'preserves default local DateTime output for numeric and string input' { + $date = Convert-LogonTimestamp -TimeStamp $knownFileTime + $date.Kind | Should -Be ([DateTimeKind]::Local) + $date.ToUniversalTime() | Should -Be $knownUtc + Convert-LogonTimestamp "$knownFileTime" | Should -Be $date + } + It 'supports explicit UTC and string formats' { + $date = Convert-LogonTimestamp $knownFileTime -Utc + $date.Kind | Should -Be ([DateTimeKind]::Utc) + $date | Should -Be $knownUtc + Convert-LogonTimestamp $knownFileTime -Utc -StringOutput | Should -Be '2024-02-29' + Convert-LogonTimestamp $knownFileTime -Utc -StringOutput -DateFormat 'yyyy-MM-dd HH:mm:ss' | Should -Be '2024-02-29 12:34:56' + Convert-LogonTimestamp $knownFileTime -Utc -DateFormat 'yyyy' | Should -Be '2024' + } + It 'formats strings independently of the current culture' { + $oldCulture = [System.Threading.Thread]::CurrentThread.CurrentCulture + try { + [System.Threading.Thread]::CurrentThread.CurrentCulture = [cultureinfo]'it-IT' + Convert-LogonTimestamp $knownFileTime -Utc -DateFormat 'MMMM' | Should -Be 'February' + } + finally { [System.Threading.Thread]::CurrentThread.CurrentCulture = $oldCulture } + } + It 'emits no fabricated date for zero and preserves pipeline ordering' { + @(Convert-LogonTimestamp 0).Count | Should -Be 0 + $results = @(0, $knownFileTime, 0, ($knownFileTime + 10000000)) | Convert-LogonTimestamp -Utc + $results.Count | Should -Be 2 + $results[0] | Should -Be $knownUtc + $results[1] | Should -Be $knownUtc.AddSeconds(1) + } + It 'rejects malformed and out-of-range input: ' -ForEach @( + @{ Value = $null }; @{ Value = '' }; @{ Value = ' ' }; @{ Value = '-1' } + @{ Value = '+1' }; @{ Value = '1.5' }; @{ Value = '1e10' }; @{ Value = '0x10' } + @{ Value = 'abc' }; @{ Value = ' 1' }; @{ Value = '1 ' } + @{ Value = '9223372036854775808' }; @{ Value = '9223372036854775807' } + ) { + { Convert-LogonTimestamp -TimeStamp $Value } | Should -Throw + } + It 'accepts the FILETIME range boundaries above zero' { + (Convert-LogonTimestamp 1 -Utc).ToFileTimeUtc() | Should -Be 1 + $maximum = [datetime]::MaxValue.ToFileTimeUtc() + (Convert-LogonTimestamp $maximum -Utc).ToFileTimeUtc() | Should -Be $maximum + } + It 'propagates invalid date-format errors' { + { Convert-LogonTimestamp $knownFileTime -DateFormat '%' } | Should -Throw + } +} + +Describe 'Local and remote OS uptime' { + It 'preserves whole-day output and optional full TimeSpan' { + Mock Get-Uptime -ModuleName IT-ToolBox { [timespan]::FromHours(59) } + Get-OsUpTime | Should -Be 2 + Get-OsUpTime | Should -BeOfType ([int]) + Get-OsUpTime -FullOutput | Should -Be ([timespan]::FromHours(59)) + } + It 'queries the actual local OS without external dependencies' { + $before = Get-Uptime + $actual = Get-OsUpTime -FullOutput + $after = Get-Uptime + $actual | Should -BeOfType ([timespan]) + $actual | Should -BeGreaterOrEqual $before + $actual | Should -BeLessOrEqual $after + } + It 'forwards explicit remote credentials and preserves output types' { + $cred = [pscredential]::new('example\test', (ConvertTo-SecureString 'example' -AsPlainText -Force)) + Mock Get-ITToolBoxRemoteUptime -ModuleName IT-ToolBox { [timespan]::FromHours(49) } + Get-OsUpTime -ComputerName 'server01' -Credential $cred | Should -Be 2 + Get-OsUpTime -ComputerName 'server01' -Credential $cred -FullOutput | Should -Be ([timespan]::FromHours(49)) + Should -Invoke Get-ITToolBoxRemoteUptime -ModuleName IT-ToolBox -Times 2 -Exactly -ParameterFilter { + $ComputerName -eq 'server01' -and $Credential.UserName -eq 'example\test' + } + } + It 'retains the legacy interactive credential switch' { + Mock Get-Credential -ModuleName IT-ToolBox { + [pscredential]::new('example\prompted', (ConvertTo-SecureString 'example' -AsPlainText -Force)) + } + Mock Get-ITToolBoxRemoteUptime -ModuleName IT-ToolBox { [timespan]::FromDays(3) } + Get-OsUpTime -ComputerName server01 -Credentials | Should -Be 3 + Should -Invoke Get-Credential -ModuleName IT-ToolBox -Times 1 -Exactly + Should -Invoke Get-ITToolBoxRemoteUptime -ModuleName IT-ToolBox -Times 1 -ParameterFilter { + $Credential.UserName -eq 'example\prompted' + } + } + It 'rejects mixed credential options before prompting' { + $cred = [pscredential]::new('test', (ConvertTo-SecureString 'example' -AsPlainText -Force)) + Mock Get-Credential -ModuleName IT-ToolBox { throw 'unexpected prompt' } + { Get-OsUpTime -ComputerName server01 -Credential $cred -Credentials } | Should -Throw '*either*' + Should -Invoke Get-Credential -ModuleName IT-ToolBox -Times 0 -Exactly + } + It 'requires a computer name for remote credential options' { + $cmd = Get-Command Get-OsUpTime -Module IT-ToolBox + ($cmd.ParameterSets | Where-Object Name -eq RemoteMachine).Parameters.Where({ $_.Name -eq 'ComputerName' }).IsMandatory | Should -BeTrue + { Get-OsUpTime -ComputerName ' ' } | Should -Throw + { Get-OsUpTime -ComputerName '' } | Should -Throw + } + It 'propagates failures without changing the caller error preference' { + Mock Get-ITToolBoxRemoteUptime -ModuleName IT-ToolBox { throw 'remote failed' } + $preference = $ErrorActionPreference + { Get-OsUpTime -ComputerName server01 } | Should -Throw '*remote failed*' + $ErrorActionPreference | Should -Be $preference + } + It 'rejects a cancelled credentials prompt' { + Mock Get-Credential -ModuleName IT-ToolBox { $null } + { Get-OsUpTime -ComputerName server01 -Credentials } | Should -Throw '*No credential*' + } + It 'rejects negative or malformed uptime results' { + Mock Get-Uptime -ModuleName IT-ToolBox { [timespan]::FromSeconds(-1) } + { Get-OsUpTime } | Should -Throw '*nonnegative*' + Mock Get-ITToolBoxRemoteUptime -ModuleName IT-ToolBox { 'not a duration' } + { Get-OsUpTime -ComputerName server01 } | Should -Throw '*nonnegative*' + } +} + +Describe 'Remote Windows CIM queries' { + BeforeAll { + $testSession = if ($IsWindows) { [Microsoft.Management.Infrastructure.CimSession]::Create('server01') } else { 'test-session' } + # Narrow test doubles allow testing CIM orchestration on non-Windows hosts. + InModuleScope IT-ToolBox { + if (-not (Get-Command Get-CimInstance -ErrorAction SilentlyContinue)) { + function script:Get-CimInstance { param($Namespace, $ClassName, $Property, $ComputerName, $CimSession, $ErrorAction) throw 'test stub' } + function script:New-CimSession { param($ComputerName, $Credential, $ErrorAction) throw 'test stub' } + function script:Remove-CimSession { param($CimSession, $ErrorAction) throw 'test stub' } + $script:UptimeTestStubs = $true + } + } + } + AfterAll { + if ($IsWindows) { $testSession.Dispose() } + InModuleScope IT-ToolBox { + if ($script:UptimeTestStubs) { + Remove-Item Function:Get-CimInstance, Function:New-CimSession, Function:Remove-CimSession + Remove-Variable UptimeTestStubs -Scope Script + } + } + } + BeforeEach { + Mock Get-CimInstance -ModuleName IT-ToolBox { + [pscustomobject]@{ + LastBootUpTime = [datetime]::new(2024, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) + LocalDateTime = [datetime]::new(2024, 1, 3, 12, 0, 0, [DateTimeKind]::Utc) + } + } + Mock New-CimSession -ModuleName IT-ToolBox { $testSession } + Mock Remove-CimSession -ModuleName IT-ToolBox {} + } + It 'computes uptime from the server clock and queries only required properties' { + Get-OsUpTime -ComputerName server01 -FullOutput | Should -Be ([timespan]::FromHours(60)) + Should -Invoke Get-CimInstance -ModuleName IT-ToolBox -Times 1 -Exactly -ParameterFilter { + $ComputerName -eq 'server01' -and $ClassName -eq 'Win32_OperatingSystem' -and + ($Property -join ',') -eq 'LastBootUpTime,LocalDateTime' + } + Should -Invoke New-CimSession -ModuleName IT-ToolBox -Times 0 -Exactly + } + It 'disposes an explicitly created session after success' { + $cred = [pscredential]::new('test', (ConvertTo-SecureString 'example' -AsPlainText -Force)) + Get-OsUpTime -ComputerName server01 -Credential $cred | Should -Be 2 + Should -Invoke Get-CimInstance -ModuleName IT-ToolBox -Times 1 -ParameterFilter { $CimSession -contains $testSession } + Should -Invoke Remove-CimSession -ModuleName IT-ToolBox -Times 1 -Exactly -ParameterFilter { $CimSession -contains $testSession } + } + It 'disposes the session and preserves a failed query error' { + Mock Get-CimInstance -ModuleName IT-ToolBox { throw 'CIM query failed' } + $cred = [pscredential]::new('test', (ConvertTo-SecureString 'example' -AsPlainText -Force)) + { Get-OsUpTime -ComputerName server01 -Credential $cred } | Should -Throw '*CIM query failed*' + Should -Invoke Remove-CimSession -ModuleName IT-ToolBox -Times 1 -Exactly + } + It 'preserves the query error if session cleanup also fails' { + Mock Get-CimInstance -ModuleName IT-ToolBox { throw 'original query failed' } + Mock Remove-CimSession -ModuleName IT-ToolBox { throw 'cleanup failed' } + $cred = [pscredential]::new('test', (ConvertTo-SecureString 'example' -AsPlainText -Force)) + { Get-OsUpTime -ComputerName server01 -Credential $cred -WarningAction SilentlyContinue } | Should -Throw '*original query failed*' + } + It 'propagates session-creation failures without attempting cleanup of a missing session' { + Mock New-CimSession -ModuleName IT-ToolBox { throw 'session failed' } + $cred = [pscredential]::new('test', (ConvertTo-SecureString 'example' -AsPlainText -Force)) + { Get-OsUpTime -ComputerName server01 -Credential $cred } | Should -Throw '*session failed*' + Should -Invoke Remove-CimSession -ModuleName IT-ToolBox -Times 0 -Exactly + } + It 'rejects incomplete server timestamps' { + Mock Get-CimInstance -ModuleName IT-ToolBox { [pscustomobject]@{ LastBootUpTime = $null; LocalDateTime = [datetime]::UtcNow } } + { Get-OsUpTime -ComputerName server01 } | Should -Throw '*valid LastBootUpTime*' + } + It 'reports a missing CIM capability clearly' { + Mock Get-Command -ModuleName IT-ToolBox { $null } -ParameterFilter { $Name -eq 'Get-CimInstance' } + { Get-OsUpTime -ComputerName server01 } | Should -Throw '*Windows CIM cmdlets*' + } +}