From 94b0fcdad25ebaea617bf04eb5fe1feb259b4bc7 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 18 Mar 2026 08:25:51 -0500 Subject: [PATCH] feat(users): add API token CRUD cmdlets (closes #1) Implement Get-PveApiToken, New-PveApiToken, and Remove-PveApiToken to enable automated token management without manual Proxmox VE UI interaction. Adds PveApiToken model, UserService methods for the token endpoints, and 31 Pester unit tests covering parameters, ShouldProcess, and no-session behaviour. Co-Authored-By: Claude Sonnet 4.6 --- .../Models/Users/PveApiToken.cs | 76 +++++ src/PSProxmoxVE.Core/Services/UserService.cs | 76 +++++ .../Cmdlets/Users/GetPveApiTokenCmdlet.cs | 47 +++ .../Cmdlets/Users/NewPveApiTokenCmdlet.cs | 68 +++++ .../Cmdlets/Users/RemovePveApiTokenCmdlet.cs | 42 +++ src/PSProxmoxVE/PSProxmoxVE.psd1 | 5 + .../Users/ApiTokenCmdlets.Tests.ps1 | 270 ++++++++++++++++++ 7 files changed, 584 insertions(+) create mode 100644 src/PSProxmoxVE.Core/Models/Users/PveApiToken.cs create mode 100644 src/PSProxmoxVE/Cmdlets/Users/GetPveApiTokenCmdlet.cs create mode 100644 src/PSProxmoxVE/Cmdlets/Users/NewPveApiTokenCmdlet.cs create mode 100644 src/PSProxmoxVE/Cmdlets/Users/RemovePveApiTokenCmdlet.cs create mode 100644 tests/PSProxmoxVE.Tests/Users/ApiTokenCmdlets.Tests.ps1 diff --git a/src/PSProxmoxVE.Core/Models/Users/PveApiToken.cs b/src/PSProxmoxVE.Core/Models/Users/PveApiToken.cs new file mode 100644 index 0000000..f526436 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Users/PveApiToken.cs @@ -0,0 +1,76 @@ +using System; +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Users; + +/// +/// Represents a Proxmox VE API token as returned by the /access/users/{userid}/token endpoints. +/// +public class PveApiToken +{ + /// + /// The user ID that owns this token (e.g., "admin@pam"). + /// Not returned by the API — populated by the cmdlet from the request path. + /// + [JsonPropertyName("userid")] + [JsonProperty("userid")] + public string UserId { get; set; } = string.Empty; + + /// + /// The token identifier (the part after "!"), e.g., "automation". + /// + [JsonPropertyName("tokenid")] + [JsonProperty("tokenid")] + public string TokenId { get; set; } = string.Empty; + + /// + /// The full token identifier in "user@realm!tokenid" format, e.g., "admin@pam!automation". + /// Only populated by New-PveApiToken; not returned by the list/get endpoints. + /// + [JsonPropertyName("full-tokenid")] + [JsonProperty("full-tokenid")] + public string? FullTokenId { get; set; } + + /// + /// The token secret UUID. Only present on creation — store it immediately, + /// as it cannot be retrieved again. + /// + [JsonPropertyName("value")] + [JsonProperty("value")] + public string? Value { get; set; } + + /// + /// Optional comment or description for this token. + /// + [JsonPropertyName("comment")] + [JsonProperty("comment")] + public string? Comment { get; set; } + + /// + /// Token expiry as a Unix timestamp. 0 or null means the token never expires. + /// + [JsonPropertyName("expire")] + [JsonProperty("expire")] + public long? Expire { get; set; } + + /// + /// Whether privilege separation is enabled (1) or disabled (0). + /// When enabled, the token's permissions are the intersection of the user's ACLs and + /// any explicit ACLs granted to the token itself. + /// + [JsonPropertyName("privsep")] + [JsonProperty("privsep")] + public int? PrivilegeSeparation { get; set; } + + /// + public override string ToString() + { + var full = FullTokenId ?? $"{UserId}!{TokenId}"; + var privsep = PrivilegeSeparation is 1 ? "privsep" : "no-privsep"; + var expireStr = Expire is > 0 + ? DateTimeOffset.FromUnixTimeSeconds(Expire.Value).ToString("yyyy-MM-dd") + : "never"; + return $"Token: {full} | {privsep} | Expires: {expireStr}"; + } +} diff --git a/src/PSProxmoxVE.Core/Services/UserService.cs b/src/PSProxmoxVE.Core/Services/UserService.cs index 4191ca8..bdbe957 100644 --- a/src/PSProxmoxVE.Core/Services/UserService.cs +++ b/src/PSProxmoxVE.Core/Services/UserService.cs @@ -98,6 +98,82 @@ namespace PSProxmoxVE.Core.Services client.PutAsync($"access/users/{encodedId}", formData).GetAwaiter().GetResult(); } + // ------------------------------------------------------------------------- + // API Tokens + // ------------------------------------------------------------------------- + + /// Returns all API tokens for the specified user. + public PveApiToken[] GetApiTokens(PveSession session, string userId) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId)); + + using var client = new PveHttpClient(session); + var encodedId = Uri.EscapeDataString(userId); + var response = client.GetAsync($"access/users/{encodedId}/token").GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + var tokens = data?.ToObject() ?? Array.Empty(); + foreach (var t in tokens) + t.UserId = userId; + return tokens; + } + + /// + /// Creates a new API token for the specified user and returns the token object, + /// including the secret Value (shown only once). + /// + /// User ID in "username@realm" format. + /// Token identifier (alphanumeric and hyphens). + /// Optional description. + /// Expiry as a Unix timestamp; 0 = never. + /// + /// When true, the token's effective permissions are intersected with the user's ACLs. + /// + public PveApiToken CreateApiToken( + PveSession session, + string userId, + string tokenId, + string? comment = null, + long? expire = null, + bool? privilegeSeparation = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId)); + if (string.IsNullOrWhiteSpace(tokenId)) throw new ArgumentNullException(nameof(tokenId)); + + var formData = new Dictionary(); + if (!string.IsNullOrEmpty(comment)) formData["comment"] = comment!; + if (expire.HasValue) formData["expire"] = expire.Value.ToString(); + if (privilegeSeparation.HasValue) formData["privsep"] = privilegeSeparation.Value ? "1" : "0"; + + using var client = new PveHttpClient(session); + var encodedUser = Uri.EscapeDataString(userId); + var encodedToken = Uri.EscapeDataString(tokenId); + var response = client.PostAsync( + $"access/users/{encodedUser}/token/{encodedToken}", formData) + .GetAwaiter().GetResult(); + + var data = JObject.Parse(response)["data"]; + var token = data?.ToObject() ?? new PveApiToken(); + token.UserId = userId; + token.TokenId = tokenId; + return token; + } + + /// Removes an API token. + public void RemoveApiToken(PveSession session, string userId, string tokenId) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId)); + if (string.IsNullOrWhiteSpace(tokenId)) throw new ArgumentNullException(nameof(tokenId)); + + using var client = new PveHttpClient(session); + var encodedUser = Uri.EscapeDataString(userId); + var encodedToken = Uri.EscapeDataString(tokenId); + client.DeleteAsync($"access/users/{encodedUser}/token/{encodedToken}") + .GetAwaiter().GetResult(); + } + // ------------------------------------------------------------------------- // Roles // ------------------------------------------------------------------------- diff --git a/src/PSProxmoxVE/Cmdlets/Users/GetPveApiTokenCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/GetPveApiTokenCmdlet.cs new file mode 100644 index 0000000..3a0d320 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Users/GetPveApiTokenCmdlet.cs @@ -0,0 +1,47 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Users; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Users +{ + /// + /// Lists API tokens for a Proxmox VE user. + /// + /// Returns the API tokens associated with the specified user. When -TokenId is omitted + /// all tokens for the user are returned. Note: the token secret (Value) is never + /// returned by the GET endpoints — it is only available immediately after creation + /// via New-PveApiToken. + /// + /// + [Cmdlet(VerbsCommon.Get, "PveApiToken")] + [OutputType(typeof(PveApiToken))] + public class GetPveApiTokenCmdlet : PveCmdletBase + { + /// + /// The user ID whose tokens to list, in "username@realm" format (e.g., "admin@pam"). + /// Accepts pipeline input from Get-PveUser (PveUser.UserId). + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + public string UserId { get; set; } = string.Empty; + + /// Filter to a specific token identifier (e.g., "automation"). + [Parameter(Mandatory = false, Position = 1)] + public string? TokenId { get; set; } + + protected override void ProcessRecord() + { + var session = GetSession(); + var service = new UserService(); + var tokens = service.GetApiTokens(session, UserId); + + foreach (var token in tokens) + { + if (!string.IsNullOrEmpty(TokenId) && + !string.Equals(token.TokenId, TokenId, System.StringComparison.OrdinalIgnoreCase)) + continue; + + WriteObject(token); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Users/NewPveApiTokenCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/NewPveApiTokenCmdlet.cs new file mode 100644 index 0000000..ce4bb01 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Users/NewPveApiTokenCmdlet.cs @@ -0,0 +1,68 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Users; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Users +{ + /// + /// Creates a new API token for a Proxmox VE user. + /// + /// Generates a new API token and returns a PveApiToken object containing both the + /// FullTokenId (e.g., "admin@pam!automation") and the token secret in the Value + /// property. The secret is shown only once — save it immediately. Use the FullTokenId + /// and Value together as the API token credential for Connect-PveServer. + /// + /// + [Cmdlet(VerbsCommon.New, "PveApiToken", SupportsShouldProcess = true)] + [OutputType(typeof(PveApiToken))] + public class NewPveApiTokenCmdlet : PveCmdletBase + { + /// + /// The user ID to create the token for, in "username@realm" format (e.g., "admin@pam"). + /// Accepts pipeline input from Get-PveUser (PveUser.UserId). + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + public string UserId { get; set; } = string.Empty; + + /// The token identifier (alphanumeric, hyphens allowed; e.g., "automation"). + [Parameter(Mandatory = true, Position = 1)] + public string TokenId { get; set; } = string.Empty; + + /// Optional description for this token. + [Parameter(Mandatory = false)] + public string? Comment { get; set; } + + /// + /// Token expiry as a Unix timestamp. Use 0 or omit for no expiry. + /// + [Parameter(Mandatory = false)] + public long? Expire { get; set; } + + /// + /// When specified, privilege separation is enabled: the token's effective permissions + /// are the intersection of the user's ACLs and any explicit ACLs granted to the token. + /// When omitted, the token inherits the full permissions of its user. + /// + [Parameter(Mandatory = false)] + public SwitchParameter PrivilegeSeparation { get; set; } + + protected override void ProcessRecord() + { + var fullTokenId = $"{UserId}!{TokenId}"; + if (!ShouldProcess(fullTokenId, "Create PVE API Token")) + return; + + var session = GetSession(); + var service = new UserService(); + var token = service.CreateApiToken( + session, + UserId, + TokenId, + comment: Comment, + expire: Expire, + privilegeSeparation: PrivilegeSeparation.IsPresent ? true : (bool?)null); + + WriteObject(token); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Users/RemovePveApiTokenCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/RemovePveApiTokenCmdlet.cs new file mode 100644 index 0000000..d065f76 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Users/RemovePveApiTokenCmdlet.cs @@ -0,0 +1,42 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Users +{ + /// + /// Removes an API token from a Proxmox VE user. + /// + /// Permanently deletes the specified API token. Any automation using this token will + /// immediately lose access. This operation cannot be undone. + /// + /// + [Cmdlet(VerbsCommon.Remove, "PveApiToken", SupportsShouldProcess = true, + ConfirmImpact = ConfirmImpact.High)] + public class RemovePveApiTokenCmdlet : PveCmdletBase + { + /// + /// The user ID that owns the token, in "username@realm" format (e.g., "admin@pam"). + /// Accepts pipeline input from Get-PveApiToken (PveApiToken.UserId). + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + public string UserId { get; set; } = string.Empty; + + /// + /// The token identifier to remove (e.g., "automation"). + /// Accepts pipeline input from Get-PveApiToken (PveApiToken.TokenId). + /// + [Parameter(Mandatory = true, Position = 1, ValueFromPipelineByPropertyName = true)] + public string TokenId { get; set; } = string.Empty; + + protected override void ProcessRecord() + { + var session = GetSession(); + + if (!ShouldProcess($"{UserId}!{TokenId}", "Remove PVE API Token")) + return; + + var service = new UserService(); + service.RemoveApiToken(session, UserId, TokenId); + } + } +} diff --git a/src/PSProxmoxVE/PSProxmoxVE.psd1 b/src/PSProxmoxVE/PSProxmoxVE.psd1 index 87e5dd9..87d4f14 100644 --- a/src/PSProxmoxVE/PSProxmoxVE.psd1 +++ b/src/PSProxmoxVE/PSProxmoxVE.psd1 @@ -132,6 +132,11 @@ 'Get-PvePermission', 'Set-PvePermission', + # API Tokens + 'Get-PveApiToken', + 'New-PveApiToken', + 'Remove-PveApiToken', + # Templates 'Get-PveTemplate', 'New-PveTemplate', diff --git a/tests/PSProxmoxVE.Tests/Users/ApiTokenCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Users/ApiTokenCmdlets.Tests.ps1 new file mode 100644 index 0000000..f91e80a --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Users/ApiTokenCmdlets.Tests.ps1 @@ -0,0 +1,270 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for API token cmdlets: + Get-PveApiToken, New-PveApiToken, Remove-PveApiToken. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + $moduleRoot = Resolve-Path (Join-Path $PSScriptRoot '../../../src/PSProxmoxVE') + $dllCandidates = @( + Join-Path $moduleRoot 'bin/Debug/net9.0/PSProxmoxVE.dll' + Join-Path $moduleRoot 'bin/Release/net9.0/PSProxmoxVE.dll' + Join-Path $moduleRoot 'bin/Debug/net48/PSProxmoxVE.dll' + Join-Path $moduleRoot 'bin/Release/net48/PSProxmoxVE.dll' + ) + + $script:ModuleDll = $dllCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + + if ($null -eq $script:ModuleDll) { + throw "PSProxmoxVE.dll not found. Build the project before running Pester tests." + } + + Import-Module $script:ModuleDll -Force -ErrorAction Stop + + $allNames = @('Get-PveApiToken', 'New-PveApiToken', 'Remove-PveApiToken') + + $script:Availability = @{} + foreach ($name in $allNames) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- +Describe 'API Token cmdlets — manifest declarations' { + BeforeAll { + $manifestPath = Join-Path $moduleRoot 'PSProxmoxVE.psd1' + $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null } + } + + It " should be declared in CmdletsToExport" -TestCases @( + @{ cmdName = 'Get-PveApiToken' } + @{ cmdName = 'New-PveApiToken' } + @{ cmdName = 'Remove-PveApiToken' } + ) { + if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return } + $script:Manifest.CmdletsToExport | Should -Contain $cmdName + } +} + +# --------------------------------------------------------------------------- +# Get-PveApiToken +# --------------------------------------------------------------------------- +Describe 'Get-PveApiToken' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveApiToken' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveApiToken' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveApiToken' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have UserId as mandatory parameter' { + Skip-IfMissing 'Get-PveApiToken' + $isMandatory = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'UserId should accept pipeline input by property name' { + Skip-IfMissing 'Get-PveApiToken' + $byPropName = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.ValueFromPipelineByPropertyName } + $byPropName | Should -Not -BeNullOrEmpty + } + + It 'Should have TokenId as optional filter parameter' { + Skip-IfMissing 'Get-PveApiToken' + $script:Cmd.Parameters.ContainsKey('TokenId') | Should -BeTrue + } + + It 'TokenId should not be Mandatory' { + Skip-IfMissing 'Get-PveApiToken' + $isMandatory = $script:Cmd.Parameters['TokenId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -BeNullOrEmpty + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveApiToken' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveApiToken' + { Get-PveApiToken -UserId 'admin@pam' -ErrorAction Stop } | Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveApiToken +# --------------------------------------------------------------------------- +Describe 'New-PveApiToken' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveApiToken' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'WhatIf should not throw even without a session' { + Skip-IfMissing 'New-PveApiToken' + { New-PveApiToken -UserId 'admin@pam' -TokenId 'test' -WhatIf } | Should -Not -Throw + } + } + + Context 'Required parameters' { + It 'UserId should be Mandatory' { + Skip-IfMissing 'New-PveApiToken' + $isMandatory = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'UserId should accept pipeline input by property name' { + Skip-IfMissing 'New-PveApiToken' + $byPropName = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.ValueFromPipelineByPropertyName } + $byPropName | Should -Not -BeNullOrEmpty + } + + It 'TokenId should be Mandatory' { + Skip-IfMissing 'New-PveApiToken' + $isMandatory = $script:Cmd.Parameters['TokenId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Optional parameters' { + It 'Should have Comment parameter' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue + } + + It 'Should have Expire parameter' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd.Parameters.ContainsKey('Expire') | Should -BeTrue + } + + It 'Should have PrivilegeSeparation switch' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd.Parameters.ContainsKey('PrivilegeSeparation') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveApiToken' + { New-PveApiToken -UserId 'admin@pam' -TokenId 'test' -Confirm:$false -ErrorAction Stop } | Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveApiToken +# --------------------------------------------------------------------------- +Describe 'Remove-PveApiToken' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveApiToken' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveApiToken' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Remove-PveApiToken' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveApiToken' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveApiToken' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'UserId should be Mandatory' { + Skip-IfMissing 'Remove-PveApiToken' + $isMandatory = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'UserId should accept pipeline input by property name' { + Skip-IfMissing 'Remove-PveApiToken' + $byPropName = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.ValueFromPipelineByPropertyName } + $byPropName | Should -Not -BeNullOrEmpty + } + + It 'TokenId should be Mandatory' { + Skip-IfMissing 'Remove-PveApiToken' + $isMandatory = $script:Cmd.Parameters['TokenId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'TokenId should accept pipeline input by property name' { + Skip-IfMissing 'Remove-PveApiToken' + $byPropName = $script:Cmd.Parameters['TokenId'].ParameterSets.Values | + Where-Object { $_.ValueFromPipelineByPropertyName } + $byPropName | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveApiToken' + { Remove-PveApiToken -UserId 'admin@pam' -TokenId 'test' -Confirm:$false -ErrorAction Stop } | Should -Throw '*No active Proxmox VE session*' + } + } +}