Skip to content

Commit 29b2c22

Browse files
tablackburnclaude
andauthored
feat: Add Update-PatServerToken command and CI token validation (#31)
* feat: Add Update-PatServerToken command and CI token validation Add a single command to refresh expired Plex server tokens without removing and re-adding the server. Split CI integration test step into three steps that fail fast with actionable error messages when the token is expired. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Address PR review feedback - Move ShouldProcess guard before Connect-PatAccount to prevent interactive auth during -WhatIf dry runs - Rename CI output key from secrets-configured to secrets_configured for GitHub Actions dot notation compatibility - Add guard for null server entry before mutating configuration - Add WhatIf test for interactive authentication path Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Address remaining PR review comments - Fix empty related links in docs to use relative markdown paths - Use localUri for token verification when preferLocal is configured Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: Add coverage for serverEntry null guard and localUri verification Add two tests to cover the remaining uncovered lines: - Server entry missing from configuration after lookup (null guard) - Verification uses localUri when preferLocal is configured Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: Add coverage for existing tokenInVault update during vault storage Cover the branch where a server already has a tokenInVault property and vault storage succeeds again, updating the existing property. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Address remaining PR review comments from CodeRabbit Add null-check guards for module manifest lookup in CI workflow to produce clear error messages instead of confusing null argument errors. Strengthen test assertions by capturing and verifying warning output during verification failure. Add test confirming WhatIf returns no output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9f4ecf9 commit 29b2c22

5 files changed

Lines changed: 824 additions & 5 deletions

File tree

.github/workflows/CI.yaml

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,27 +158,86 @@ jobs:
158158
shell: pwsh
159159
run: ./build.ps1 -Task Build -Bootstrap
160160

161-
- name: Run Integration Tests
161+
- name: Check Integration Secrets
162+
id: check-secrets
162163
shell: pwsh
163164
env:
164165
PLEX_SERVER_URI: ${{ secrets.PLEX_SERVER_URI }}
165166
PLEX_TOKEN: ${{ secrets.PLEX_TOKEN }}
166-
PLEX_TEST_SECTION_ID: ${{ secrets.PLEX_TEST_SECTION_ID }}
167-
PLEX_TEST_SECTION_NAME: ${{ secrets.PLEX_TEST_SECTION_NAME }}
168-
PLEX_TEST_LIBRARY_PATH: ${{ secrets.PLEX_TEST_LIBRARY_PATH }}
169-
PLEX_ALLOW_LIBRARY_REFRESH: ${{ secrets.PLEX_ALLOW_LIBRARY_REFRESH }}
170167
run: |
171168
if (-not $env:PLEX_SERVER_URI -or -not $env:PLEX_TOKEN) {
172169
Write-Host "::warning::Integration tests skipped - PLEX_SERVER_URI or PLEX_TOKEN secrets not configured"
170+
"secrets_configured=false" >> $env:GITHUB_OUTPUT
173171
exit 0
174172
}
173+
"secrets_configured=true" >> $env:GITHUB_OUTPUT
174+
175+
- name: Validate Plex Token
176+
if: steps.check-secrets.outputs.secrets_configured == 'true'
177+
shell: pwsh
178+
env:
179+
PLEX_SERVER_URI: ${{ secrets.PLEX_SERVER_URI }}
180+
PLEX_TOKEN: ${{ secrets.PLEX_TOKEN }}
181+
run: |
182+
# Import the built module
183+
$moduleManifest = Get-ChildItem -Path Output/PlexAutomationToolkit -Filter '*.psd1' -Recurse |
184+
Select-Object -First 1
185+
if (-not $moduleManifest) {
186+
Write-Host "::error::Module manifest not found in Output/PlexAutomationToolkit"
187+
exit 1
188+
}
189+
Import-Module $moduleManifest.FullName -Force
190+
191+
# Add a temporary server for validation
192+
$validationServerName = 'CI-TokenValidation'
193+
Add-PatServer -Name $validationServerName -ServerUri $env:PLEX_SERVER_URI -Token $env:PLEX_TOKEN -SkipValidation -Confirm:$false
194+
195+
try {
196+
$testResult = Test-PatServer -Name $validationServerName
197+
198+
if (-not $testResult.IsAuthenticated) {
199+
$errorLines = @(
200+
"PLEX_TOKEN secret is expired or invalid (401)."
201+
"To fix:"
202+
" 1) Run 'Update-PatServerToken' locally to get a fresh token"
203+
" 2) Update the PLEX_TOKEN secret in GitHub repo Settings > Secrets and variables > Actions"
204+
)
205+
Write-Host "::error::$($errorLines -join ' ')"
206+
exit 1
207+
}
208+
209+
if (-not $testResult.IsConnected) {
210+
Write-Host "::warning::Plex server at $env:PLEX_SERVER_URI is not reachable. Integration tests may fail."
211+
}
212+
else {
213+
Write-Host "Token validation successful: $($testResult.FriendlyName) v$($testResult.Version)"
214+
}
215+
}
216+
finally {
217+
Remove-PatServer -Name $validationServerName -Confirm:$false -ErrorAction 'SilentlyContinue'
218+
}
175219
220+
- name: Run Integration Tests
221+
if: steps.check-secrets.outputs.secrets_configured == 'true'
222+
shell: pwsh
223+
env:
224+
PLEX_SERVER_URI: ${{ secrets.PLEX_SERVER_URI }}
225+
PLEX_TOKEN: ${{ secrets.PLEX_TOKEN }}
226+
PLEX_TEST_SECTION_ID: ${{ secrets.PLEX_TEST_SECTION_ID }}
227+
PLEX_TEST_SECTION_NAME: ${{ secrets.PLEX_TEST_SECTION_NAME }}
228+
PLEX_TEST_LIBRARY_PATH: ${{ secrets.PLEX_TEST_LIBRARY_PATH }}
229+
PLEX_ALLOW_LIBRARY_REFRESH: ${{ secrets.PLEX_ALLOW_LIBRARY_REFRESH }}
230+
run: |
176231
# Set up build environment (required for persistence tests)
177232
Set-BuildEnvironment -Force
178233
179234
# Import the built module
180235
$moduleManifest = Get-ChildItem -Path Output/PlexAutomationToolkit -Filter '*.psd1' -Recurse |
181236
Select-Object -First 1
237+
if (-not $moduleManifest) {
238+
Write-Host "::error::Module manifest not found in Output/PlexAutomationToolkit"
239+
exit 1
240+
}
182241
Write-Host "Importing module from: $($moduleManifest.FullName)"
183242
Import-Module $moduleManifest.FullName -Force
184243

PlexAutomationToolkit/PlexAutomationToolkit.psd1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ FunctionsToExport = @(
106106
'Test-PatLibraryPath'
107107
'Test-PatServer'
108108
'Update-PatLibrary'
109+
'Update-PatServerToken'
109110
'Wait-PatLibraryScan'
110111
)
111112

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
function Update-PatServerToken {
2+
<#
3+
.SYNOPSIS
4+
Refreshes the authentication token for a stored Plex server.
5+
6+
.DESCRIPTION
7+
Updates the Plex authentication token for a stored server configuration.
8+
This is the recommended way to fix expired or invalid tokens without
9+
removing and re-adding the server.
10+
11+
When called without -Token, performs interactive PIN authentication via
12+
Connect-PatAccount. When -Token is provided, uses the supplied token
13+
directly (useful for automation or CI scenarios).
14+
15+
After storing the new token, verifies it by calling the Plex API root
16+
endpoint and reports the result.
17+
18+
.PARAMETER Name
19+
The name of the stored server to update. If not specified, uses the
20+
default server configured via Add-PatServer -Default.
21+
22+
.PARAMETER Token
23+
A Plex authentication token to use directly. When provided, skips the
24+
interactive PIN authentication flow. Obtain a token via Connect-PatAccount
25+
or from Plex account settings.
26+
27+
.PARAMETER TimeoutSeconds
28+
Maximum time to wait for interactive PIN authorization in seconds
29+
(default: 300 / 5 minutes). Only applies when -Token is not provided.
30+
31+
.PARAMETER Force
32+
Suppresses interactive prompts during PIN authentication. When specified,
33+
automatically opens the browser to the Plex authentication page.
34+
35+
.EXAMPLE
36+
Update-PatServerToken
37+
38+
Refreshes the token for the default server using interactive PIN
39+
authentication. Opens a browser to plex.tv/link for authorization.
40+
41+
.EXAMPLE
42+
Update-PatServerToken -Name 'MyServer'
43+
44+
Refreshes the token for the server named 'MyServer' using interactive
45+
PIN authentication.
46+
47+
.EXAMPLE
48+
Update-PatServerToken -Name 'MyServer' -Token $newToken
49+
50+
Updates the token for 'MyServer' using a pre-obtained token, skipping
51+
the interactive authentication flow.
52+
53+
.EXAMPLE
54+
Update-PatServerToken -Force
55+
56+
Refreshes the default server token non-interactively, automatically
57+
opening the browser for PIN authorization.
58+
59+
.OUTPUTS
60+
PSCustomObject
61+
Returns an object with the following properties:
62+
- ServerName: The name of the updated server
63+
- TokenUpdated: Whether the token was successfully stored
64+
- Verified: Whether the new token was verified against the Plex API
65+
- StorageType: Where the token is stored ('Vault' or 'Inline')
66+
67+
.NOTES
68+
If Microsoft.PowerShell.SecretManagement is installed with a registered
69+
vault, the new token is stored securely in the vault. Otherwise, the
70+
token is stored in plaintext in servers.json.
71+
72+
.LINK
73+
Connect-PatAccount
74+
75+
.LINK
76+
Test-PatServer
77+
78+
.LINK
79+
Add-PatServer
80+
#>
81+
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
82+
[OutputType([PSCustomObject])]
83+
param (
84+
[Parameter(Mandatory = $false, Position = 0)]
85+
[ValidateNotNullOrEmpty()]
86+
[string]
87+
$Name,
88+
89+
[Parameter(Mandatory = $false)]
90+
[ValidateNotNullOrEmpty()]
91+
[string]
92+
$Token,
93+
94+
[Parameter(Mandatory = $false)]
95+
[ValidateRange(1, 1800)]
96+
[int]
97+
$TimeoutSeconds = 300,
98+
99+
[Parameter(Mandatory = $false)]
100+
[switch]
101+
$Force
102+
)
103+
104+
try {
105+
# Resolve target server
106+
if ($Name) {
107+
$server = Get-PatStoredServer -Name $Name -ErrorAction 'Stop'
108+
}
109+
else {
110+
$server = Get-PatStoredServer -Default -ErrorAction 'Stop'
111+
}
112+
113+
$serverName = $server.name
114+
Write-Verbose "Updating token for server '$serverName'"
115+
116+
if ($PSCmdlet.ShouldProcess($serverName, 'Update authentication token')) {
117+
# Obtain token (inside ShouldProcess to avoid interactive auth during -WhatIf)
118+
$newToken = $Token
119+
if (-not $newToken) {
120+
Write-Verbose "No token provided, starting interactive PIN authentication"
121+
$newToken = Connect-PatAccount -TimeoutSeconds $TimeoutSeconds -Force:$Force
122+
}
123+
124+
# Store the new token
125+
$storageResult = Set-PatServerToken -ServerName $serverName -Token $newToken
126+
127+
# Update the server configuration entry
128+
$configuration = Get-PatServerConfiguration -ErrorAction 'Stop'
129+
$serverEntry = $configuration.servers | Where-Object { $_.name -eq $serverName }
130+
131+
if (-not $serverEntry) {
132+
throw "Server entry '$serverName' was not found in configuration."
133+
}
134+
135+
if ($storageResult.StorageType -eq 'Vault') {
136+
# Remove inline token if present, set vault flag
137+
if ($serverEntry.PSObject.Properties['token']) {
138+
$serverEntry.PSObject.Properties.Remove('token')
139+
}
140+
if ($serverEntry.PSObject.Properties['tokenInVault']) {
141+
$serverEntry.tokenInVault = $true
142+
}
143+
else {
144+
$serverEntry | Add-Member -NotePropertyName 'tokenInVault' -NotePropertyValue $true
145+
}
146+
}
147+
else {
148+
# Store inline token, remove vault flag if present
149+
if ($serverEntry.PSObject.Properties['token']) {
150+
$serverEntry.token = $storageResult.Token
151+
}
152+
else {
153+
$serverEntry | Add-Member -NotePropertyName 'token' -NotePropertyValue $storageResult.Token
154+
}
155+
if ($serverEntry.PSObject.Properties['tokenInVault']) {
156+
$serverEntry.PSObject.Properties.Remove('tokenInVault')
157+
}
158+
}
159+
160+
Set-PatServerConfiguration -Configuration $configuration -ErrorAction 'Stop'
161+
Write-Verbose "Token stored successfully (StorageType: $($storageResult.StorageType))"
162+
163+
# Verify the new token works (honor localUri/preferLocal if configured)
164+
$verified = $false
165+
try {
166+
$verificationBaseUri = $server.uri
167+
if ($server.PSObject.Properties['preferLocal'] -and $server.preferLocal -and
168+
$server.PSObject.Properties['localUri'] -and $server.localUri) {
169+
$verificationBaseUri = $server.localUri
170+
}
171+
$verificationUri = Join-PatUri -BaseUri $verificationBaseUri -Endpoint '/'
172+
$verificationHeaders = @{ Accept = 'application/json' }
173+
$verificationHeaders['X-Plex-Token'] = $newToken
174+
$null = Invoke-PatApi -Uri $verificationUri -Headers $verificationHeaders -ErrorAction 'Stop'
175+
$verified = $true
176+
Write-Verbose "Token verification successful for server '$serverName'"
177+
}
178+
catch {
179+
Write-Warning "Token was stored but verification failed: $($_.Exception.Message)"
180+
}
181+
182+
[PSCustomObject]@{
183+
ServerName = $serverName
184+
TokenUpdated = $true
185+
Verified = $verified
186+
StorageType = $storageResult.StorageType
187+
}
188+
}
189+
}
190+
catch {
191+
throw "Failed to update server token: $($_.Exception.Message)"
192+
}
193+
}

0 commit comments

Comments
 (0)