diff --git a/Public/Admin/New-PfbApiClient.ps1 b/Public/Admin/New-PfbApiClient.ps1 index 37141416..1c92e8f4 100644 --- a/Public/Admin/New-PfbApiClient.ps1 +++ b/Public/Admin/New-PfbApiClient.ps1 @@ -4,33 +4,55 @@ function New-PfbApiClient { Creates a new API client on the FlashBlade. .DESCRIPTION The New-PfbApiClient cmdlet creates a new API client on the connected Pure Storage - FlashBlade. API clients are used for OAuth2 authentication and require at minimum - a public key and a maximum role assignment. + FlashBlade. API clients require a public key. On REST 2.0-2.18, they also require + a maximum role assignment; from REST 2.19 onward, max_role is deprecated in favour + of access_policies. + + The typed parameters and the raw -Attributes hashtable are mutually exclusive: they + live in separate parameter sets, so PowerShell rejects a mixed invocation at bind time + rather than letting -Attributes silently override an explicitly supplied value. .PARAMETER Name The name of the API client to create. + .PARAMETER PublicKey + The public key for the API client. Required by the API client's POST body schema. + .PARAMETER MaxRole + The maximum role assignment for the API client. The API requires this on REST 2.0-2.18; + it is deprecated in favour of access_policies from REST 2.19 onward. .PARAMETER Attributes - A hashtable defining the API client properties, including the public key and role. + A raw hashtable defining the API client properties. This parameter is mutually exclusive + with the typed parameters. When using -Attributes, the caller is responsible for supplying + the required public_key (and max_role on REST 2.0-2.18). .PARAMETER Array The FlashBlade connection object. If not specified, the default connection is used. .EXAMPLE - New-PfbApiClient -Name 'automation-client' -Attributes @{ max_role = @{ name = 'storage_admin' }; public_key = $key } + New-PfbApiClient -Name 'automation-client' -PublicKey $key -MaxRole 'storage_admin' Creates a new API client with the storage_admin role and the specified public key. .EXAMPLE - New-PfbApiClient -Name 'readonly-client' -Attributes @{ max_role = @{ name = 'readonly' }; public_key = $key } + New-PfbApiClient -Name 'readonly-client' -PublicKey $key - Creates a new read-only API client. + Creates a new API client with the specified public key. On REST 2.0-2.18, supply + -MaxRole as well; from REST 2.19 onward, max_role is deprecated in favour of access_policies. .EXAMPLE New-PfbApiClient -Name 'ops-client' -Attributes @{ max_role = @{ name = 'ops_admin' }; public_key = $key } -Confirm:$false - Creates a new API client without prompting for confirmation. + Creates a new API client from a hand-rolled body without prompting for confirmation. + When using -Attributes, the caller is responsible for supplying the required public_key. #> - [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', + DefaultParameterSetName = 'Typed')] param( - [Parameter(Mandatory, Position = 0)] + [Parameter(ParameterSetName = 'Typed', Mandatory, Position = 0)] + [Parameter(ParameterSetName = 'Attributes', Mandatory, Position = 0)] [string]$Name, - [Parameter()] + [Parameter(ParameterSetName = 'Typed', Mandatory)] + [string]$PublicKey, + + [Parameter(ParameterSetName = 'Typed')] + [string]$MaxRole, + + [Parameter(ParameterSetName = 'Attributes', Mandatory)] [hashtable]$Attributes, [Parameter()] [PSCustomObject]$Array @@ -38,7 +60,18 @@ function New-PfbApiClient { Assert-PfbConnection -Array ([ref]$Array) - $body = if ($Attributes) { $Attributes } else { @{} } + if ($PSCmdlet.ParameterSetName -eq 'Attributes') { + $body = $Attributes.Clone() + } + else { + $body = @{ public_key = $PublicKey } + # max_role is a scalar reference ({id, name, resource_type}); resource_type + # is readOnly and must never be sent. + if ($PSBoundParameters.ContainsKey('MaxRole')) { + $body['max_role'] = @{ name = $MaxRole } + } + } + $queryParams = @{ 'names' = $Name } if ($PSCmdlet.ShouldProcess($Name, 'Create API client')) { diff --git a/Public/Misc/Update-PfbLegalHoldEntity.ps1 b/Public/Misc/Update-PfbLegalHoldEntity.ps1 index 517f2305..70679a32 100644 --- a/Public/Misc/Update-PfbLegalHoldEntity.ps1 +++ b/Public/Misc/Update-PfbLegalHoldEntity.ps1 @@ -3,11 +3,22 @@ function Update-PfbLegalHoldEntity { .SYNOPSIS Updates a held entity under a legal hold on the FlashBlade. .DESCRIPTION - The Update-PfbLegalHoldEntity cmdlet modifies the properties of an entity that is - subject to a legal hold on the connected Pure Storage FlashBlade. Identify the - held entity by name and supply the changed properties via Attributes. + The Update-PfbLegalHoldEntity cmdlet applies or releases a legal hold over a + file-system path on the connected Pure Storage FlashBlade. `released` is required + by the API, so -Released must always be supplied: `$true` releases the hold, + `$false` applies it. + + A held entity has no name of its own -- `LegalHoldHeldEntity` carries only + `file_system`, `legal_hold`, `path` and `status` -- so it is addressed by the + file system plus the path, together with -Recursive. Measured on a live array + (Purity//FB 4.8.2, REST 2.26): a request naming only the hold is rejected with + "Either names or ids query parameter is required", and one naming the file system + and path but omitting -Recursive is rejected with "Can't apply or release legal + holds to directories without the recursive flag provided". Both directions behave + identically here, so a release is symmetric with an apply rather than special. .PARAMETER Name - The name of the held entity to update. + The name of the legal hold. Sent as `names`, which the endpoint declares but which + does not on its own identify a held entity -- see the description above. .PARAMETER FileSystemIds The IDs of the file systems whose held entities to update. .PARAMETER FileSystemNames @@ -20,7 +31,8 @@ function Update-PfbLegalHoldEntity { If set to `true`, the update is applied recursively to the specified path or file system. .PARAMETER Released - If set to `true`, the held entity is released from its legal hold. + Required by the API on REST 2.17 and later. `$true` releases the held entity from its + legal hold; `$false` applies or keeps the legal hold. .PARAMETER Attributes A hashtable of attributes to update on the held entity. `PATCH /legal-holds/held-entities` accepts no request body, so nothing supplied here is sent @@ -28,17 +40,21 @@ function Update-PfbLegalHoldEntity { .PARAMETER Array The FlashBlade connection object. If not specified, the default connection is used. .EXAMPLE - Update-PfbLegalHoldEntity -Name "fs1" -Released $true + Update-PfbLegalHoldEntity -Name 'pslivetest-hold-1' -FileSystemNames 'pslivetest-fs-1' -Paths '/' -Recursive $true -Released $true - Releases the held entity named "fs1" from its legal hold. + Releases the held entity named "pslivetest-hold-1" from its legal hold. The file-system + name, path, and recursive flag are all required together for this release request. .EXAMPLE - Update-PfbLegalHoldEntity -Name "bucket1" -Recursive $false + Update-PfbLegalHoldEntity -Name 'pslivetest-hold-1' -FileSystemNames 'pslivetest-fs-1' -Paths '/' -Recursive $true -Released $false - Updates the held entity named "bucket1" without applying the change recursively. + Applies the legal hold to the same path. This is the release example with -Released + flipped -- the two directions take the same arguments. .EXAMPLE - Update-PfbLegalHoldEntity -Name "fs1" -Attributes @{ hold_type = 'litigation' } + Update-PfbLegalHoldEntity -Name 'pslivetest-hold-1' -FileSystemIds '10314f42-020d-7080-8013-000ddt400090' -Paths '/' -Recursive $true -Released $true - Updates the held entity using a raw attributes hashtable. + Releases the hold identifying the file system by ID instead of by name. Note that + `file_system_ids` refers to the file system, which has an ID; the held entity itself + does not, so -Ids is not a substitute for this form. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( @@ -60,8 +76,8 @@ function Update-PfbLegalHoldEntity { [Parameter()] [Nullable[bool]]$Recursive, - [Parameter()] - [Nullable[bool]]$Released, + [Parameter(Mandatory)] + [bool]$Released, [Parameter()] [hashtable]$Attributes, @@ -88,7 +104,9 @@ function Update-PfbLegalHoldEntity { if ($PSBoundParameters.ContainsKey('Ids')) { $queryParams['ids'] = $Ids -join ',' } if ($PSBoundParameters.ContainsKey('Paths')) { $queryParams['paths'] = $Paths -join ',' } if ($PSBoundParameters.ContainsKey('Recursive')) { $queryParams['recursive'] = $Recursive } - if ($PSBoundParameters.ContainsKey('Released')) { $queryParams['released'] = $Released } + # Unlike optional parameters, released is required by the spec, so no legal request omits it; + # the usual ContainsKey distinction between omitted and supplied as false cannot arise here. + $queryParams['released'] = $Released if ($PSCmdlet.ShouldProcess($Name, 'Update legal hold entity')) { Invoke-PfbApiRequest -Array $Array -Method PATCH -Endpoint 'legal-holds/held-entities' -Body $body -QueryParams $queryParams diff --git a/Reports/PfbApiDriftReport.json b/Reports/PfbApiDriftReport.json index ba62a4ce..d4b99fa5 100644 --- a/Reports/PfbApiDriftReport.json +++ b/Reports/PfbApiDriftReport.json @@ -9267,9 +9267,9 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Admin/New-PfbApiClient.ps1", - "paramBlockLine": 36, + "paramBlockLine": 58, "payloadVariable": "body", - "assignmentStyle": "unknown", + "assignmentStyle": "literal", "hasAttributes": true } }, @@ -9284,9 +9284,9 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Admin/New-PfbApiClient.ps1", - "paramBlockLine": 36, + "paramBlockLine": 58, "payloadVariable": "body", - "assignmentStyle": "unknown", + "assignmentStyle": "literal", "hasAttributes": true } }, @@ -9301,43 +9301,9 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Admin/New-PfbApiClient.ps1", - "paramBlockLine": 36, - "payloadVariable": "body", - "assignmentStyle": "unknown", - "hasAttributes": true - } - }, - { - "name": "max_role", - "type": null, - "format": null, - "specRequired": false, - "synopsis": "Deprecated.", - "suggestedPowerShellType": "[object]", - "enumValues": [], - "enumStatus": "no-spec-enum-found", - "target": { - "file": "Public/Admin/New-PfbApiClient.ps1", - "paramBlockLine": 36, - "payloadVariable": "body", - "assignmentStyle": "unknown", - "hasAttributes": true - } - }, - { - "name": "public_key", - "type": "string", - "format": null, - "specRequired": true, - "synopsis": "The API client's PEM formatted (Base64 encoded) RSA public key.", - "suggestedPowerShellType": "[string]", - "enumValues": [], - "enumStatus": "no-spec-enum-found", - "target": { - "file": "Public/Admin/New-PfbApiClient.ps1", - "paramBlockLine": 36, + "paramBlockLine": 58, "payloadVariable": "body", - "assignmentStyle": "unknown", + "assignmentStyle": "literal", "hasAttributes": true } } @@ -15532,17 +15498,6 @@ ], "annotations": [] }, - { - "name": "max_role", - "endpointCount": 2, - "queryEndpointCount": 0, - "bodyEndpointCount": 2, - "endpoints": [ - "PATCH /api-clients", - "POST /api-clients" - ], - "annotations": [] - }, { "name": "member_sids", "endpointCount": 2, @@ -15686,17 +15641,6 @@ ], "annotations": [] }, - { - "name": "public_key", - "endpointCount": 2, - "queryEndpointCount": 0, - "bodyEndpointCount": 2, - "endpoints": [ - "POST /api-clients", - "POST /public-keys" - ], - "annotations": [] - }, { "name": "qos_configurations", "endpointCount": 2, @@ -16679,6 +16623,16 @@ ], "annotations": [] }, + { + "name": "max_role", + "endpointCount": 1, + "queryEndpointCount": 0, + "bodyEndpointCount": 1, + "endpoints": [ + "PATCH /api-clients" + ], + "annotations": [] + }, { "name": "max_session_duration", "endpointCount": 1, @@ -16929,6 +16883,16 @@ ], "annotations": [] }, + { + "name": "public_key", + "endpointCount": 1, + "queryEndpointCount": 0, + "bodyEndpointCount": 1, + "endpoints": [ + "POST /public-keys" + ], + "annotations": [] + }, { "name": "purity_defined", "endpointCount": 1, @@ -19246,6 +19210,14 @@ "Remove-PfbFileSystemSession" ] }, + { + "name": "public_key", + "cmdletCount": 2, + "cmdlets": [ + "New-PfbApiClient", + "Update-PfbAdmin" + ] + }, { "name": "recursive", "cmdletCount": 2, @@ -19558,6 +19530,13 @@ "Update-PfbAdmin" ] }, + { + "name": "max_role", + "cmdletCount": 1, + "cmdlets": [ + "New-PfbApiClient" + ] + }, { "name": "max_session_duration", "cmdletCount": 1, @@ -19663,13 +19642,6 @@ "Get-PfbArrayPerformance" ] }, - { - "name": "public_key", - "cmdletCount": 1, - "cmdlets": [ - "Update-PfbAdmin" - ] - }, { "name": "rdma_enabled", "cmdletCount": 1, @@ -20072,11 +20044,6 @@ "cmdletCount": 0, "cmdlets": [] }, - { - "name": "max_role", - "cmdletCount": 0, - "cmdlets": [] - }, { "name": "member_sids", "cmdletCount": 0, diff --git a/Reports/PfbApiDriftReport.md b/Reports/PfbApiDriftReport.md index d4fa6994..4e282b91 100644 --- a/Reports/PfbApiDriftReport.md +++ b/Reports/PfbApiDriftReport.md @@ -22,7 +22,7 @@ This report accepts **false positives in order to eliminate false negatives**. A - Uncovered endpoints: 95 - Endpoints with parameter gaps: 439 -- Missing body properties (addable): 426 +- Missing body properties (addable): 424 - Missing query parameters (addable): 880 - Read-only body fields (not addable -- see the Read-only fields section below): 384 - Phantom fields silently excluded (accumulated in the capability map, absent from the newest analysed spec): 40 @@ -414,7 +414,7 @@ Endpoints an existing cmdlet already calls, where the capability map knows of a | `POST /admins/api-tokens` | New-PfbApiToken | context_names | | `high` | | | `POST /admins/management-access-policies` | New-PfbAdminManagementAccessPolicy | context_names | | `high` | POST/PATCH/DELETE return 403 regardless of account; not an implementation bug | | `POST /admins/ssh-certificate-authority-policies` | New-PfbAdminSshCaPolicy | context_names | | `high` | | -| `POST /api-clients` | New-PfbApiClient | | access_policies, access_token_ttl_in_ms, issuer, max_role, public_key | `high` | | +| `POST /api-clients` | New-PfbApiClient | | access_policies, access_token_ttl_in_ms, issuer | `high` | | | `POST /array-connections` | New-PfbArrayConnection | context_names | | `high` | | | `POST /arrays/erasures` | New-PfbArrayErasure | eradicate_all_data, preserve_configuration_data, skip_phonehome_check | | `high` | | | `POST /arrays/ssh-certificate-authority-policies` | New-PfbArraySshCaPolicy | context_names | | `high` | | diff --git a/Reports/PfbDeadKeyReport.json b/Reports/PfbDeadKeyReport.json index 1cf937c3..9d17494b 100644 --- a/Reports/PfbDeadKeyReport.json +++ b/Reports/PfbDeadKeyReport.json @@ -1,13 +1,13 @@ { "specVersion": "2.28", "counts": { - "parametersInventoried": 2165, + "parametersInventoried": 2167, "keysEvaluated": 1747, "ok": 1664, "deadKey": 83, "skipReasons": { "wire name unresolved": 126, - "body property": 278, + "body property": 280, "endpoint/method ambiguous": 14, "endpoint/verb absent from spec": 0 } diff --git a/Reports/PfbFieldCmdletMap.json b/Reports/PfbFieldCmdletMap.json index a702b993..3794bb0c 100644 --- a/Reports/PfbFieldCmdletMap.json +++ b/Reports/PfbFieldCmdletMap.json @@ -11247,6 +11247,16 @@ "stableSinceOldestVersion": null, "recommendation": null }, + { + "cmdlet": "New-PfbApiClient", + "parameter": "MaxRole", + "wireName": "max_role", + "status": "no-spec-enum-found", + "matchedKey": null, + "specValues": null, + "stableSinceOldestVersion": null, + "recommendation": null + }, { "cmdlet": "New-PfbApiClient", "parameter": "Name", @@ -11257,6 +11267,16 @@ "stableSinceOldestVersion": null, "recommendation": null }, + { + "cmdlet": "New-PfbApiClient", + "parameter": "PublicKey", + "wireName": "public_key", + "status": "no-spec-enum-found", + "matchedKey": null, + "specValues": null, + "stableSinceOldestVersion": null, + "recommendation": null + }, { "cmdlet": "New-PfbApiToken", "parameter": "Id", diff --git a/Reports/PfbFieldCmdletMapping.md b/Reports/PfbFieldCmdletMapping.md index cff432fe..a0e3e999 100644 --- a/Reports/PfbFieldCmdletMapping.md +++ b/Reports/PfbFieldCmdletMapping.md @@ -9,7 +9,7 @@ Reporting only -- no `Public/` cmdlet is edited by this script. Every `matched` - matched: 2 - collision: 1 - not-found-in-resource: 29 -- no-spec-enum-found: 1972 +- no-spec-enum-found: 1974 | Cmdlet | Parameter | Wire name | Status | Spec values | Recommendation | |---|---|---|---|---|---| diff --git a/Reports/PfbPipelineSelectorMap.json b/Reports/PfbPipelineSelectorMap.json index f3261157..f314a139 100644 --- a/Reports/PfbPipelineSelectorMap.json +++ b/Reports/PfbPipelineSelectorMap.json @@ -15,16 +15,16 @@ "findingPairs": 101, "confirmationRate": 0.4197, "controlLeakage": 0, - "assistedRows": 213 + "assistedRows": 212 }, "outcomeBreakdown": [ { "Outcome": "BindError", - "Count": 2 + "Count": 4 }, { "Outcome": "Bound", - "Count": 578 + "Count": 577 }, { "Outcome": "CmdletError", @@ -44,7 +44,7 @@ }, { "Outcome": "Unbindable", - "Count": 234 + "Count": 233 } ], "gateBreakdown": [ @@ -42765,11 +42765,11 @@ "IsCandidate": false, "Gate": "Matched", "ValueFromPipeline": false, - "Outcome": "Bound", - "Evidence": "names=PROBE-name (from property 'name')", - "BoundWireKey": "names", - "BoundValue": "PROBE-name", - "ErrorKind": null, + "Outcome": "BindError", + "Evidence": "would prompt for an unbound mandatory parameter -- __AllParameterSets: Released", + "BoundWireKey": null, + "BoundValue": null, + "ErrorKind": "HarnessRefusal", "FilledParameter": [], "ProbeProperties": [ "description", @@ -42795,14 +42795,12 @@ "IsCandidate": true, "Gate": "Candidate", "ValueFromPipeline": false, - "Outcome": "Unbindable", - "Evidence": "The input object cannot be bound to any parameters for the command either because the command does not take pipeline input or the input and its properties do not match any of the parameters that take pipeline input.", + "Outcome": "BindError", + "Evidence": "would prompt for an unbound mandatory parameter -- __AllParameterSets: Name, Released", "BoundWireKey": null, "BoundValue": null, - "ErrorKind": "InputObjectNotBound", - "FilledParameter": [ - "Name" - ], + "ErrorKind": "HarnessRefusal", + "FilledParameter": [], "ProbeProperties": [ "file_system", "legal_hold", diff --git a/Reports/PfbPipelineSelectorMap.md b/Reports/PfbPipelineSelectorMap.md index 4023d3e7..d607852a 100644 --- a/Reports/PfbPipelineSelectorMap.md +++ b/Reports/PfbPipelineSelectorMap.md @@ -18,7 +18,7 @@ no request leaves the machine, and nothing here is inferred from pattern-matchin | `findingPairs` | 101 | | `confirmationRate` | 0.4197 | | `controlLeakage` | 0 | -| `assistedRows` | 213 | +| `assistedRows` | 212 | `findings` counts probe ROWS; `findingPairs` counts distinct (cmdlet, parameter) pairs. One defect appears once per producing endpoint, so rows always exceed pairs. @@ -27,13 +27,13 @@ defect appears once per producing endpoint, so rows always exceed pairs. | Outcome | Rows | Finding? | |---|---:|---| -| `BindError` | 2 | triage -- the harness never invoked, the only unmeasured outcome | -| `Bound` | 578 | no -- the selector bound as intended | +| `BindError` | 4 | triage -- the harness never invoked, the only unmeasured outcome | +| `Bound` | 577 | no -- the selector bound as intended | | `CmdletError` | 6 | no -- the cmdlet threw before any request was built | | `Coerced` | 264 | **yes** -- a stringified object reached the wire | | `Guarded` | 116 | no -- a #64/#90 coercion guard fired | | `NoSelector` | 47 | no -- reported observation | -| `Unbindable` | 234 | no -- PowerShell declined to bind this probe object at all. Note that pass 4 is ByPropertyName WITH coercion, so a ByPropertyName-only parameter whose alias matches an object-valued property CAN still coerce; this outcome is not a structural immunity | +| `Unbindable` | 233 | no -- PowerShell declined to bind this probe object at all. Note that pass 4 is ByPropertyName WITH coercion, so a ByPropertyName-only parameter whose alias matches an object-valued property CAN still coerce; this outcome is not a structural immunity | ## Findings diff --git a/Tests/Build-PfbPipelineSelectorMap.Tests.ps1 b/Tests/Build-PfbPipelineSelectorMap.Tests.ps1 index b3e3bf4e..215f18ed 100644 --- a/Tests/Build-PfbPipelineSelectorMap.Tests.ps1 +++ b/Tests/Build-PfbPipelineSelectorMap.Tests.ps1 @@ -112,8 +112,15 @@ Describe 'Build-PfbPipelineSelectorMap' { # Unbindable and CmdletError are VERDICTS -- PowerShell declining to bind at all, and # the cmdlet throwing before the shim. Collapsing them into BindError is what made 8 # measured pairs look like blind spots in the first revision of the audit. + # + # RE-BASELINED 2 -> 4 alongside the twin pin in PfbPipelineSelectorRail.Tests.ps1, which + # carries the full account: making Update-PfbLegalHoldEntity -Released mandatory (#106 + # Part 2) means the probe can no longer construct a call for that cmdlet, so its two rows + # move from Bound/Unbindable to a HarnessRefusal BindError. Unlike its twin, this + # assertion reads the COMMITTED report instead of regenerating, so it is not PS7-gated + # and reds on Windows PowerShell 5.1 too. $report = Get-Content $script:reportPath -Raw | ConvertFrom-Json - @($report.results | Where-Object Outcome -eq 'BindError').Count | Should -Be 2 + @($report.results | Where-Object Outcome -eq 'BindError').Count | Should -Be 4 @($report.results | Where-Object Outcome -eq 'Unbindable').Count | Should -BeGreaterThan 0 } diff --git a/Tests/New-PfbApiClient.Tests.ps1 b/Tests/New-PfbApiClient.Tests.ps1 new file mode 100644 index 00000000..e9e9c26e --- /dev/null +++ b/Tests/New-PfbApiClient.Tests.ps1 @@ -0,0 +1,88 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +BeforeAll { + $moduleRoot = Split-Path -Parent $PSScriptRoot + $manifest = Join-Path $moduleRoot 'PureStorageFlashBladePowerShell.psd1' + . (Join-Path $PSScriptRoot 'PfbTestModule.ps1') + $null = Import-PfbTestModule + + $script:fakeArray = [PSCustomObject]@{ Endpoint = 'fb.example.test'; ApiVersion = '2.0'; AuthToken = 'x' } +} + +Describe 'New-PfbApiClient - typed body parameters (#106)' { + + BeforeEach { + Mock -ModuleName PureStorageFlashBladePowerShell Assert-PfbConnection { } + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest { } + } + + Context 'typed parameters build the body' { + It 'sends -PublicKey in the body and -Name as names' { + New-PfbApiClient -Name 'automation-client' -PublicKey 'public-key' -Confirm:$false -Array $fakeArray + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'POST' -and $Endpoint -eq 'api-clients' -and + $QueryParams['names'] -eq 'automation-client' -and + $Body['public_key'] -eq 'public-key' + } + } + + It 'sends -MaxRole as a nested reference without resource_type' { + New-PfbApiClient -Name 'automation-client' -PublicKey 'public-key' -MaxRole 'storage_admin' -Confirm:$false -Array $fakeArray + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { + $Body['max_role'] -is [hashtable] -and + $Body['max_role']['name'] -eq 'storage_admin' -and + -not $Body['max_role'].ContainsKey('resource_type') + } + } + + It 'omits max_role when -MaxRole is not supplied' { + New-PfbApiClient -Name 'automation-client' -PublicKey 'public-key' -Confirm:$false -Array $fakeArray + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { + $Body.ContainsKey('public_key') -and -not $Body.ContainsKey('max_role') + } + } + } + + Context '-Attributes remains supported as a raw body' { + It 'sends the raw hashtable body' { + $attributes = @{ public_key = 'raw-key'; max_role = @{ name = 'storage_admin' } } + + New-PfbApiClient -Name 'automation-client' -Attributes $attributes -Confirm:$false -Array $fakeArray + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { + $Body['public_key'] -eq 'raw-key' -and + $Body['max_role']['name'] -eq 'storage_admin' + } + } + + It 'does not mutate the caller hashtable' { + $attributes = @{ public_key = 'raw-key'; custom = 'value' } + $originalCount = $attributes.Count + + New-PfbApiClient -Name 'automation-client' -Attributes $attributes -Confirm:$false -Array $fakeArray + + $attributes.Count | Should -Be $originalCount + } + + It 'rejects -Attributes combined with typed parameters at bind time' { + { New-PfbApiClient -Name 'x' -PublicKey 'k' -Attributes @{} -Confirm:$false -Array $fakeArray } | + Should -Throw + } + } + + Context 'typed parameter metadata' { + It 'makes -PublicKey mandatory in the default Typed parameter set' { + $publicKeyParameter = (Get-Command New-PfbApiClient).Parameters['PublicKey'] + $typedParameterAttribute = $publicKeyParameter.Attributes | + Where-Object { + $_ -is [System.Management.Automation.ParameterAttribute] -and + $_.ParameterSetName -eq 'Typed' + } + + $typedParameterAttribute.Mandatory | Should -BeTrue + } + } +} diff --git a/Tests/PfbPipelineSelectorRail.Tests.ps1 b/Tests/PfbPipelineSelectorRail.Tests.ps1 index efad14e8..32746382 100644 --- a/Tests/PfbPipelineSelectorRail.Tests.ps1 +++ b/Tests/PfbPipelineSelectorRail.Tests.ps1 @@ -244,17 +244,34 @@ Describe 'Rail A - no unwaived selector coercion' -Skip:($PSVersionTable.PSVersi $stale -join ', ' | Should -BeNullOrEmpty } - It 'still measures every pair it used to: BindError stays at 2 rows' { + It 'still measures every pair it used to: BindError stays at 4 rows' { # Only BindError means UNMEASURED -- Unbindable (PowerShell declined to bind at all) # and CmdletError (the cmdlet threw before the shim) are verdicts carrying evidence. # A rise here means the harness has started refusing cmdlets it used to measure, which # is precisely the regression that produced the original 33-pair blind spot. # - # BindError is an OUTCOME, not an ErrorKind. ErrorKind is non-null on 358 rows (234 - # InputObjectNotBound, 122 CmdletError, 2 ParameterBindingError); only the 2 - # ParameterBindingError rows classify as the BindError outcome, and only that outcome - # means unmeasured. Asserting on ErrorKind -eq 'BindError' matches nothing at all. - @($script:measured | Where-Object { $_.Outcome -eq 'BindError' }).Count | Should -Be 2 + # BindError is an OUTCOME, not an ErrorKind. ErrorKind is non-null on 359 rows (233 + # InputObjectNotBound, 122 CmdletError, 2 ParameterBindingError, 2 HarnessRefusal); + # only the ParameterBindingError and HarnessRefusal rows classify as the BindError + # outcome, and only that outcome means unmeasured. Asserting on + # ErrorKind -eq 'BindError' matches nothing at all. + # + # RE-BASELINED 2 -> 4, with the movement accounted for rather than absorbed. The two + # new rows are both Update-PfbLegalHoldEntity/Name, refused as "would prompt for an + # unbound mandatory parameter" once -Released became mandatory (the required-query-key + # fix for dmann000/fb-powershell#106 Part 2). They were Bound and Unbindable before. + # + # This is the honest cost of that fix and not a harness regression: the probe supplies + # a selector and nothing else, so a REQUIRED parameter it does not know to supply + # cannot be bound, and the row it replaces measured `names=PROBE-name` on a key that + # cannot identify a held entity anyway (#139). + # + # It does generalise, which is what a future reader needs to know: any fix that + # correctly makes a required parameter mandatory converts that cmdlet's + # pipeline-selector coverage into a triage row. If this number climbs again for that + # reason, the answer is probably to teach the probe generator to satisfy mandatory + # parameters outside the selector under test -- not to keep re-baselining. + @($script:measured | Where-Object { $_.Outcome -eq 'BindError' }).Count | Should -Be 4 } } diff --git a/Tests/Update-PfbLegalHoldEntity.Tests.ps1 b/Tests/Update-PfbLegalHoldEntity.Tests.ps1 index a95d4841..f112e838 100644 --- a/Tests/Update-PfbLegalHoldEntity.Tests.ps1 +++ b/Tests/Update-PfbLegalHoldEntity.Tests.ps1 @@ -18,7 +18,7 @@ Describe 'Update-PfbLegalHoldEntity - query parameters (#31)' { Context 'existing -Name selector (regression, unchanged)' { It 'still sends -Name as names' { - Update-PfbLegalHoldEntity -Name 'fs1' -Confirm:$false -Array $fakeArray + Update-PfbLegalHoldEntity -Name 'fs1' -Released $true -Confirm:$false -Array $fakeArray Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { $Method -eq 'PATCH' -and $Endpoint -eq 'legal-holds/held-entities' -and @@ -29,7 +29,7 @@ Describe 'Update-PfbLegalHoldEntity - query parameters (#31)' { Context 'new query parameters' { It 'joins -FileSystemIds and -FileSystemNames with commas' { - Update-PfbLegalHoldEntity -Name 'fs1' -FileSystemIds 'fsid-1', 'fsid-2' -FileSystemNames 'fs1', 'fs2' -Confirm:$false -Array $fakeArray + Update-PfbLegalHoldEntity -Name 'fs1' -FileSystemIds 'fsid-1', 'fsid-2' -FileSystemNames 'fs1', 'fs2' -Released $true -Confirm:$false -Array $fakeArray Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { $QueryParams['file_system_ids'] -eq 'fsid-1,fsid-2' -and @@ -38,7 +38,7 @@ Describe 'Update-PfbLegalHoldEntity - query parameters (#31)' { } It 'joins -Ids and -Paths with commas' { - Update-PfbLegalHoldEntity -Name 'fs1' -Ids 'id-1', 'id-2' -Paths '/dir1', '/dir2' -Confirm:$false -Array $fakeArray + Update-PfbLegalHoldEntity -Name 'fs1' -Ids 'id-1', 'id-2' -Paths '/dir1', '/dir2' -Released $true -Confirm:$false -Array $fakeArray Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { $QueryParams['ids'] -eq 'id-1,id-2' -and @@ -47,7 +47,7 @@ Describe 'Update-PfbLegalHoldEntity - query parameters (#31)' { } It 'sends an EMPTY array for -Ids @() so the query key still reaches the wire (constraint 2)' { - Update-PfbLegalHoldEntity -Name 'fs1' -Ids @() -Confirm:$false -Array $fakeArray + Update-PfbLegalHoldEntity -Name 'fs1' -Ids @() -Released $true -Confirm:$false -Array $fakeArray Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { $QueryParams.ContainsKey('ids') -and @($QueryParams['ids'] -split ',' | Where-Object { $_ }).Count -eq 0 @@ -55,7 +55,7 @@ Describe 'Update-PfbLegalHoldEntity - query parameters (#31)' { } It 'sends an explicit -Recursive:$false (ContainsKey semantics, not truthiness)' { - Update-PfbLegalHoldEntity -Name 'fs1' -Recursive $false -Confirm:$false -Array $fakeArray + Update-PfbLegalHoldEntity -Name 'fs1' -Recursive $false -Released $true -Confirm:$false -Array $fakeArray Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { $QueryParams.ContainsKey('recursive') -and $QueryParams['recursive'] -eq $false @@ -70,18 +70,37 @@ Describe 'Update-PfbLegalHoldEntity - query parameters (#31)' { } } - It 'omits recursive and released entirely when not supplied' { - Update-PfbLegalHoldEntity -Name 'fs1' -Confirm:$false -Array $fakeArray + It 'omits recursive when not supplied but always sends released' { + Update-PfbLegalHoldEntity -Name 'fs1' -Released $true -Confirm:$false -Array $fakeArray + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { + -not $QueryParams.ContainsKey('recursive') -and $QueryParams.ContainsKey('released') + } + } + + It 'sends an explicit -Released:$false so a hold can be applied' { + Update-PfbLegalHoldEntity -Name 'fs1' -Released $false -Confirm:$false -Array $fakeArray Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { - -not $QueryParams.ContainsKey('recursive') -and -not $QueryParams.ContainsKey('released') + $QueryParams.ContainsKey('released') -and $QueryParams['released'] -eq $false + } + } + + It 'requires -Released as a mandatory boolean parameter' { + $parameter = (Get-Command Update-PfbLegalHoldEntity).Parameters['Released'] + $attribute = $parameter.Attributes | Where-Object { + $_ -is [System.Management.Automation.ParameterAttribute] } + + $attribute.Mandatory | Should -BeTrue + $parameter.ParameterType | Should -Be ([bool]) + $parameter.ParameterType | Should -Not -Be ([System.Nullable[bool]]) } } Context 'endpoint accepts no request body' { It 'sends an empty body when -Attributes is not supplied' { - Update-PfbLegalHoldEntity -Name 'fs1' -Confirm:$false -Array $fakeArray + Update-PfbLegalHoldEntity -Name 'fs1' -Released $true -Confirm:$false -Array $fakeArray Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { $Body.Count -eq 0