From 4dae2c62feb5241d371ddacfe7e276374e632877 Mon Sep 17 00:00:00 2001 From: Josh Dearing Date: Fri, 24 Apr 2026 21:03:43 -0500 Subject: [PATCH 1/3] fix: tag datetime fields as DateTimeKind.Utc in Get-RTTicket and Search-RTTicket RT's REST API returns datetimes as strings without a timezone indicator. The [datetime] cast produced DateTimeKind.Unspecified, causing silent incorrect comparisons against local time on systems with a UTC offset. Wraps all cast sites with [datetime]::SpecifyKind(..., Utc) so callers can use .ToLocalTime() for display and compare directly against UTC values. --- RTShell/functions/Get-RTTicket.ps1 | 2 +- RTShell/functions/Search-RTTicket.ps1 | 4 +- Tests/functions/Get-RTTicket.Tests.ps1 | 79 +++++++++++++++++++++++ Tests/functions/Search-RTTicket.Tests.ps1 | 41 ++++++++++++ 4 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 Tests/functions/Get-RTTicket.Tests.ps1 create mode 100644 Tests/functions/Search-RTTicket.Tests.ps1 diff --git a/RTShell/functions/Get-RTTicket.ps1 b/RTShell/functions/Get-RTTicket.ps1 index ef36323..2f52640 100644 --- a/RTShell/functions/Get-RTTicket.ps1 +++ b/RTShell/functions/Get-RTTicket.ps1 @@ -63,7 +63,7 @@ param($val) if (-not $val) { return $null } try { - $dt = [datetime]$val + $dt = [datetime]::SpecifyKind([datetime]$val, [System.DateTimeKind]::Utc) if ($dt.Year -le 1970) { return $null } return $dt } diff --git a/RTShell/functions/Search-RTTicket.ps1 b/RTShell/functions/Search-RTTicket.ps1 index a0b034f..90e8332 100644 --- a/RTShell/functions/Search-RTTicket.ps1 +++ b/RTShell/functions/Search-RTTicket.ps1 @@ -274,8 +274,8 @@ Owner = if ($item.Owner.id) { $item.Owner.id } elseif ($item.Owner -is [string]) { $item.Owner } else { $null } - Created = if ($item.Created) { try { [datetime]$item.Created } catch { $null } } else { $null } - LastUpdated = if ($item.LastUpdated) { try { [datetime]$item.LastUpdated } catch { $null } } else { $null } + Created = if ($item.Created) { try { [datetime]::SpecifyKind([datetime]$item.Created, [System.DateTimeKind]::Utc) } catch { $null } } else { $null } + LastUpdated = if ($item.LastUpdated) { try { [datetime]::SpecifyKind([datetime]$item.LastUpdated, [System.DateTimeKind]::Utc) } catch { $null } } else { $null } } $obj # Add numerical_id alias so Get-RTTicket can consume via pipeline diff --git a/Tests/functions/Get-RTTicket.Tests.ps1 b/Tests/functions/Get-RTTicket.Tests.ps1 new file mode 100644 index 0000000..e7be67f --- /dev/null +++ b/Tests/functions/Get-RTTicket.Tests.ps1 @@ -0,0 +1,79 @@ +#Requires -Module Pester + +BeforeAll { + $modulePath = Join-Path $PSScriptRoot '..\..\RTShell\RTShell.psd1' + Import-Module $modulePath -Force -ErrorAction Stop +} + +Describe 'Get-RTTicket — datetime UTC kind' { + + BeforeAll { + InModuleScope RTShell { + $script:fakeRaw = [PSCustomObject]@{ + id = 42 + Subject = 'Test ticket' + Status = 'open' + Queue = [PSCustomObject]@{ id = ''; _url = '' } + Owner = 'Nobody' + Requestor = @() + Cc = @() + AdminCc = @() + Priority = 0 + FinalPriority = 0 + InitialPriority = 0 + TimeEstimated = 0 + TimeWorked = 0 + TimeLeft = 0 + Created = '2026-04-24 21:38:00' + Starts = '2026-04-24 00:00:00' + Started = '2026-04-24 00:00:00' + Due = '2026-04-30 00:00:00' + Resolved = '1970-01-01 00:00:00' + LastUpdated = '2026-04-24 21:40:00' + CustomFields = @() + } + + Mock Invoke-RTRequest { $script:fakeRaw } -ParameterFilter { $Path -like 'ticket/*' } + } + + $script:Ticket = Get-RTTicket -Id 42 + } + + It 'Created has DateTimeKind.Utc' { + $script:Ticket.Created.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + + It 'LastUpdated has DateTimeKind.Utc' { + $script:Ticket.LastUpdated.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + + It 'Resolved is null for epoch sentinel 1970-01-01' { + $script:Ticket.Resolved | Should -BeNullOrEmpty + } + + Context '-Detailed' { + BeforeAll { + $script:Detailed = Get-RTTicket -Id 42 -Detailed + } + + It 'Created has DateTimeKind.Utc' { + $script:Detailed.Created.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + + It 'LastUpdated has DateTimeKind.Utc' { + $script:Detailed.LastUpdated.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + + It 'Starts has DateTimeKind.Utc' { + $script:Detailed.Starts.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + + It 'Started has DateTimeKind.Utc' { + $script:Detailed.Started.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + + It 'Due has DateTimeKind.Utc' { + $script:Detailed.Due.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + } +} diff --git a/Tests/functions/Search-RTTicket.Tests.ps1 b/Tests/functions/Search-RTTicket.Tests.ps1 new file mode 100644 index 0000000..def9d7b --- /dev/null +++ b/Tests/functions/Search-RTTicket.Tests.ps1 @@ -0,0 +1,41 @@ +#Requires -Module Pester + +BeforeAll { + $modulePath = Join-Path $PSScriptRoot '..\..\RTShell\RTShell.psd1' + Import-Module $modulePath -Force -ErrorAction Stop +} + +Describe 'Search-RTTicket — datetime UTC kind' { + + BeforeAll { + InModuleScope RTShell { + $script:fakeResponse = [PSCustomObject]@{ + total = 1 + page = 1 + items = @( + [PSCustomObject]@{ + id = 99 + Subject = 'Test search ticket' + Status = 'open' + Queue = [PSCustomObject]@{ Name = 'General'; id = '' } + Owner = [PSCustomObject]@{ id = 'Nobody' } + Created = '2026-04-24 21:38:00' + LastUpdated = '2026-04-24 21:40:00' + } + ) + } + + Mock Invoke-RTRequest { $script:fakeResponse } -ParameterFilter { $Path -eq 'tickets' } + } + + $script:Result = Search-RTTicket -Query 'id=99' | Select-Object -First 1 + } + + It 'Created has DateTimeKind.Utc' { + $script:Result.Created.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + + It 'LastUpdated has DateTimeKind.Utc' { + $script:Result.LastUpdated.Kind | Should -Be ([System.DateTimeKind]::Utc) + } +} From 84cbebe7e4ec36c1bada509a7b21393d02ce40f0 Mon Sep 17 00:00:00 2001 From: DearingDev Date: Sat, 25 Apr 2026 22:33:20 -0500 Subject: [PATCH 2/3] Changed config options to allow other secret names --- RTShell/functions/Connect-RT.ps1 | 12 +++++++---- RTShell/functions/Save-RTConfiguration.ps1 | 24 +++++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/RTShell/functions/Connect-RT.ps1 b/RTShell/functions/Connect-RT.ps1 index 6b4b643..adeceaf 100644 --- a/RTShell/functions/Connect-RT.ps1 +++ b/RTShell/functions/Connect-RT.ps1 @@ -71,20 +71,24 @@ throw "No saved configuration found. Run Save-RTConfiguration first." } $BaseUri = $config.BaseUri + $secretName = if ($config.TokenName) { $config.TokenName } else { 'RTShell_Token' } try { - $TokenPlainText = Get-Secret -Name 'RTShell_Token' -AsPlainText -ErrorAction Stop + $TokenPlainText = Get-Secret -Name $secretName -AsPlainText -ErrorAction Stop } catch { - throw "Could not retrieve 'RTShell_Token' from SecretManagement. Make sure your vault is unlocked or run Save-RTConfiguration to save your token." + throw "Could not retrieve '$secretName' from SecretManagement. Make sure your vault is unlocked or run Save-RTConfiguration to save your token." } } elseif ($PSCmdlet.ParameterSetName -eq 'FromConfig' -and $BaseUri) { + $config = Get-RTConfig + $secretName = if ($config -and $config.TokenName) { $config.TokenName } else { 'RTShell_Token' } + try { - $TokenPlainText = Get-Secret -Name 'RTShell_Token' -AsPlainText -ErrorAction Stop + $TokenPlainText = Get-Secret -Name $secretName -AsPlainText -ErrorAction Stop } catch { - throw "No token provided and could not retrieve 'RTShell_Token' from SecretManagement." + throw "No token provided and could not retrieve '$secretName' from SecretManagement." } } elseif ($PSCmdlet.ParameterSetName -eq 'SecureToken') { diff --git a/RTShell/functions/Save-RTConfiguration.ps1 b/RTShell/functions/Save-RTConfiguration.ps1 index c80435b..57b1def 100644 --- a/RTShell/functions/Save-RTConfiguration.ps1 +++ b/RTShell/functions/Save-RTConfiguration.ps1 @@ -7,7 +7,7 @@ .DESCRIPTION Saves connection details to disk so Connect-RT can be called without parameters. Config is stored in: - ~/.rtshell/config.json -- BaseUri and queue cache (no secrets) + ~/.rtshell/config.json -- BaseUri, token name, and queue cache (no secrets) Once saved, Connect-RT can be called with no parameters: Connect-RT @@ -24,6 +24,11 @@ .PARAMETER TokenPlainText API token as plain text. Useful for scripting/CI environments. + .PARAMETER TokenName + The name under which the API token is stored in the SecretManagement vault. + Defaults to 'RTShell_Token'. Change this if you prefer a different name or + manage multiple RT instances. + .EXAMPLE $tok = Read-Host -AsSecureString -Prompt 'RT API Token' Save-RTConfiguration -BaseUri 'https://rt.example.com' -Token $tok @@ -31,9 +36,9 @@ Save configuration using a secure string token. .EXAMPLE - Save-RTConfiguration -BaseUri 'https://rt.example.com' -TokenPlainText $env:RT_TOKEN + Save-RTConfiguration -BaseUri 'https://rt.example.com' -TokenPlainText $env:RT_TOKEN -TokenName 'MyRT_Token' - Save configuration using a plain text token from an environment variable. + Save configuration using a plain text token with a custom vault secret name. .OUTPUTS None. @@ -50,7 +55,11 @@ [Parameter(Mandatory, ParameterSetName = 'PlainToken')] [ValidateNotNullOrEmpty()] - [string]$TokenPlainText + [string]$TokenPlainText, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$TokenName = 'RTShell_Token' ) $BaseUri = $BaseUri.TrimEnd('/') @@ -65,6 +74,7 @@ $existing = Get-RTConfig $config = @{ BaseUri = $BaseUri + TokenName = $TokenName QueueCache = if ($existing.QueueCache) { $existing.QueueCache } else { @() } QueueCacheDate = if ($existing.QueueCacheDate) { $existing.QueueCacheDate } else { $null } } @@ -76,9 +86,9 @@ Initialize-RTSecretVault # Save the token to SecretManagement - Set-Secret -Name 'RTShell_Token' -Secret $Token -NoClobber:$false + Set-Secret -Name $TokenName -Secret $Token -NoClobber:$false Write-Information "Configuration saved." -InformationAction Continue - Write-Information " BaseUri : $BaseUri (saved to ~/.rtshell/config.json)" -InformationAction Continue - Write-Information " Token : saved to SecretManagement vault as 'RTShell_Token'" -InformationAction Continue + Write-Information " BaseUri : $BaseUri (saved to ~/.rtshell/config.json)" -InformationAction Continue + Write-Information " Token : saved to SecretManagement vault as '$TokenName'" -InformationAction Continue } From ee8baacdb64bdf662488da82049ef300f71117af Mon Sep 17 00:00:00 2001 From: Josh Dearing Date: Sat, 25 Apr 2026 22:38:51 -0500 Subject: [PATCH 3/3] Updated version numbers and changelog --- RTShell/RTShell.psd1 | 54 ++++++++++++++++++++++---------------------- changelog.md | 7 +++++- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/RTShell/RTShell.psd1 b/RTShell/RTShell.psd1 index 95b68ab..f194fc4 100644 --- a/RTShell/RTShell.psd1 +++ b/RTShell/RTShell.psd1 @@ -1,42 +1,42 @@ @{ # Script module or binary module file associated with this manifest RootModule = 'RTShell.psm1' - + # Version number of this module. - ModuleVersion = '0.1.3' - + ModuleVersion = '0.1.4' + # ID used to uniquely identify this module GUID = 'ec49fac6-1b1c-4776-bab1-f9466887fc28' - + # Author of this module Author = 'Josh Dearing' - + # Company or vendor of this module CompanyName = '' - + # Copyright statement for this module Copyright = 'Joshua Dearing Copyright (c) 2026 ' - + # Description of the functionality provided by this module Description = 'PowerShell module for Request Tracker (RT) via REST API v2. Supports API token auth, config persistence, structured ticket search, write operations, and response templates.' - + # Minimum version of the Windows PowerShell engine required by this module PowerShellVersion = '5.1' - + # Modules that must be imported into the global environment prior to importing this module RequiredModules = @('Microsoft.PowerShell.SecretManagement') - + # Assemblies that must be loaded prior to importing this module # RequiredAssemblies = @('bin\RTShell.dll') - + # Type files (.ps1xml) to be loaded when importing this module # Expensive for import time, no more than one should be used. # TypesToProcess = @('xml\RTShell.Types.ps1xml') - + # Format files (.ps1xml) to be loaded when importing this module. # Expensive for import time, no more than one should be used. # FormatsToProcess = @('xml\RTShell.Format.ps1xml') - + # Functions to export from this module FunctionsToExport = @( # Session @@ -71,43 +71,43 @@ 'Set-RTTemplate' 'Remove-RTTemplate' ) - + # Cmdlets to export from this module CmdletsToExport = '' - + # Variables to export from this module VariablesToExport = '' - + # Aliases to export from this module AliasesToExport = '' - + # List of all files packaged with this module FileList = @() - + # Private data to pass to the module specified in ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. PrivateData = @{ - + #Support for PowerShellGet galleries. PSData = @{ - + # Tags applied to this module. These help with module discovery in online galleries. Tags = @('RT', 'RequestTracker', 'Ticketing', 'ITSM') - + # A URL to the license for this module. LicenseUri = 'https://github.com/DearingDev/RTShell/blob/main/LICENSE' - + # A URL to the main website for this project. ProjectUri = 'https://github.com/DearingDev/RTShell' - + # A URL to an icon representing this module. # IconUri = '' - + # ReleaseNotes of this module ReleaseNotes = @' -0.1.3 - SearchRTTicket will now return queue name instead of queue id +0.1.4 - Date now properly displays as UTC. Added option to specify token name. '@ - + } # End of PSData hashtable - + } # End of PrivateData hashtable } diff --git a/changelog.md b/changelog.md index ccbf27a..424d105 100644 --- a/changelog.md +++ b/changelog.md @@ -10,4 +10,9 @@ ## 0.1.3 (2026-04-08) -+ Search-RTTicket now returns QueueName \ No newline at end of file ++ Search-RTTicket now returns QueueName + +## 0.1.4 (2026-04-25) + ++ Dates now properly return as UTC. ++ Added option to select a different token name and store it in the config.json \ No newline at end of file