diff --git a/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs index 384c411..b31af64 100644 --- a/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs @@ -1,56 +1,46 @@ -using System; using System.Management.Automation; using PSProxmoxVE.Core.Authentication; -using PSProxmoxVE.Core.Client; namespace PSProxmoxVE.Cmdlets.Connection { /// - /// Closes the active Proxmox VE session. + /// Discards the local Proxmox VE session. /// - /// Disconnect-PveServer invalidates the module-level session. For ticket-based - /// sessions it additionally attempts a best-effort DELETE against the - /// /access/ticket endpoint to invalidate the server-side ticket. Errors from - /// that request are silently ignored so that the local session is always cleared. + /// Disconnect-PveServer discards the module-level session from memory. PVE tickets cannot be + /// revoked server-side and expire on their own (typically two hours). /// /// [Cmdlet(VerbsCommunications.Disconnect, "PveServer", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Low)] [OutputType(typeof(void))] [Alias("dpve")] - public sealed class DisconnectPveServerCmdlet : PSCmdlet + public sealed class DisconnectPveServerCmdlet : PveCmdletBase { protected override void ProcessRecord() { - var session = ModuleState.ActiveSession; + bool explicitSessionSupplied = MyInvocation.BoundParameters.ContainsKey(nameof(Session)); + var sessionToDisconnect = explicitSessionSupplied ? Session : ModuleState.ActiveSession; - if (session is null) + if (sessionToDisconnect is null) { WriteWarning("No active Proxmox VE session to disconnect."); return; } - if (!ShouldProcess($"{session.Hostname}:{session.Port}", "Disconnect")) - return; - - // Best-effort server-side ticket invalidation for ticket-based auth. - if (session.AuthMode == PveAuthMode.Ticket) + if (!ReferenceEquals(sessionToDisconnect, ModuleState.ActiveSession)) { - try - { - using var client = new PveHttpClient(session); - client.DeleteAsync("access/ticket").GetAwaiter().GetResult(); - } - catch (Exception ex) - { - // Non-fatal — we still clear the local session below. - WriteVerbose($"Server-side ticket invalidation failed (ignored): {ex.Message}"); - } + var lifecycle = sessionToDisconnect.AuthMode == PveAuthMode.ApiToken + ? "API tokens do not expire; revoke it with Remove-PveApiToken if it is no longer needed." + : "PVE tickets cannot be revoked and expire on their own."; + WriteWarning($"The supplied session for {sessionToDisconnect.Hostname}:{sessionToDisconnect.Port} is not the module-level session; nothing was changed. Discard the variable. {lifecycle}"); + return; } - ModuleState.ActiveSession = null; + if (!ShouldProcess($"{sessionToDisconnect.Hostname}:{sessionToDisconnect.Port}", "Disconnect")) + return; - WriteVerbose($"Disconnected from {session.Hostname}:{session.Port}."); + ModuleState.ActiveSession = null; + WriteVerbose($"Disconnected from {sessionToDisconnect.Hostname}:{sessionToDisconnect.Port}."); } } } diff --git a/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1 b/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1 index e0626f4..6bc5caf 100644 --- a/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1 @@ -38,6 +38,10 @@ Describe 'Disconnect-PveServer' { # attribute is present by confirming ShouldProcess support is enabled. $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue } + + It 'Should expose -Session parameter' { + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } } Context 'Behaviour when no session is active' { @@ -53,4 +57,79 @@ Describe 'Disconnect-PveServer' { { Disconnect-PveServer -WhatIf -ErrorAction Stop } | Should -Not -Throw } } + + Context 'Active session lifecycle' { + BeforeAll { + $script:PveSessionType = [PSProxmoxVE.Core.Authentication.PveSession] + $script:ModuleStateType = [System.AppDomain]::CurrentDomain.GetAssemblies() | + Where-Object { $_.GetName().Name -eq 'PSProxmoxVE' } | + ForEach-Object { $_.GetType('PSProxmoxVE.ModuleState') } | + Select-Object -First 1 + $script:ModuleStateType | Should -Not -BeNullOrEmpty + + $script:ActiveSessionProperty = $script:ModuleStateType.GetProperty( + 'ActiveSession', + [System.Reflection.BindingFlags]'NonPublic, Static') + $script:ActiveSessionProperty | Should -Not -BeNullOrEmpty + + $script:SessionCtor = $script:PveSessionType.GetConstructor( + [System.Reflection.BindingFlags]'NonPublic, Instance', + $null, + [type[]]@([string], [int], [bool], [string]), + $null) + $script:SessionCtor | Should -Not -BeNullOrEmpty + } + + AfterEach { + $script:ActiveSessionProperty.SetValue($null, $null) + } + + It 'Should report "no session" when nothing is active' { + Disconnect-PveServer -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue + $w[0] | Should -Match 'No active Proxmox VE session' + } + + It 'Should clear the active session and warn on a second disconnect' { + $activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken')) + $script:ActiveSessionProperty.SetValue($null, $activeSession) + + Disconnect-PveServer -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue + $w | Should -BeNullOrEmpty + $script:ActiveSessionProperty.GetValue($null) | Should -BeNullOrEmpty + + Disconnect-PveServer -Confirm:$false -WarningVariable w2 -WarningAction SilentlyContinue + $w2[0] | Should -Match 'No active Proxmox VE session' + } + + It 'Should warn when disconnecting an explicit non-active session, and leave the active session untouched' { + $activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken')) + $script:ActiveSessionProperty.SetValue($null, $activeSession) + $mismatchedSession = $script:SessionCtor.Invoke(@('other.example', 8006, $false, 'othertoken')) + + Disconnect-PveServer -Session $mismatchedSession -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue + + $w[0] | Should -Match 'not the module-level session' + $w[0] | Should -Match 'Remove-PveApiToken' + [object]::ReferenceEquals($script:ActiveSessionProperty.GetValue($null), $activeSession) | Should -BeTrue + } + + It 'Should disconnect when -Session matches the active session' { + $activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken')) + $script:ActiveSessionProperty.SetValue($null, $activeSession) + + Disconnect-PveServer -Session $activeSession -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue + + $w | Should -BeNullOrEmpty + $script:ActiveSessionProperty.GetValue($null) | Should -BeNullOrEmpty + } + + It 'Should not clear the active session under -WhatIf' { + $activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken')) + $script:ActiveSessionProperty.SetValue($null, $activeSession) + + Disconnect-PveServer -WhatIf -ErrorAction Stop + + [object]::ReferenceEquals($script:ActiveSessionProperty.GetValue($null), $activeSession) | Should -BeTrue + } + } }