From 36cefd5f8a6763adc4dd797163aa6a894bd22312 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Tue, 4 Aug 2026 15:34:45 -0700 Subject: [PATCH 01/53] docs(fusion): spec for Phase 1 context injection The user-facing context surface, kept whole: the PfbContext object, connection context state, central injection in Invoke-PfbApiRequest, the three client-side gates (capability / cardinality / kind-vs-scope), Set-PfbContext, Clear-PfbContext, Invoke-PfbInContext, and -AllArrays. Depends on Phase 0 -- both the component resolver (#74) and the contextScope map field are load-bearing. Rebase onto main after Phase 0 merges rather than stacking. Six of the design doc's seven open questions are resolved here; OQ7 ships the client-side throw and stays open pending confirmation that one-fleet-per-array is guaranteed rather than a current limitation. Records that the design doc's line citations for Invoke-PfbApiRequest are stale -- the Assert-PfbApiCapability call is at :46, not :41 -- since injecting after it silently disables the version gate this design leans on. Refs #25 Co-Authored-By: Claude Opus 5 --- docs/design/fusion-context-phase-1-spec.md | 574 +++++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 docs/design/fusion-context-phase-1-spec.md diff --git a/docs/design/fusion-context-phase-1-spec.md b/docs/design/fusion-context-phase-1-spec.md new file mode 100644 index 00000000..abd5ad12 --- /dev/null +++ b/docs/design/fusion-context-phase-1-spec.md @@ -0,0 +1,574 @@ +# Spec: Fusion context support, Phase 1 (context injection) + +Status: proposed +Design doc: `docs/design/fusion-context-injection.md` (rev 4) +Tracking: #25 (Fusion `context_names` injection) +Depends on: Phase 0 (`feat/fusion-context-phase-0`) — **must merge first** +Branch: `feat/fusion-context-phase-1` + +--- + +## Purpose + +Phase 1 makes `context_names` work: a caller can target another array, a fleet, or every array +in a fleet or topology group, and every one of the module's ~520 cmdlets inherits that without +a signature change. + +It ships the whole user-facing control surface at once — the context object, connection state, +central injection, the three client-side gates, and the two cmdlets plus one helper that drive +them. It is kept whole deliberately: the gates and the escape hatches are the same feature. +Shipping the hard throw without `Invoke-PfbInContext` would ship a gate with no key. + +### Dependency on Phase 0 + +Phase 1 cannot be built on `main` as it stands. Two Phase 0 items are load-bearing: + +| Phase 0 item | What Phase 1 needs it for | +|---|---| +| `Private/Resolve-PfbParameterComponent` (#74) | Produces the `-ContextComponent` input to `Test-PfbContextMultiValueCapable`. Without it the cardinality rule gets silently reimplemented inside `Private/`, giving the codebase two copies of a rule whose entire premise is one declared home | +| `contextScope` in the capability map (`schemaVersion` 2) | Kind-vs-scope validation and every scope-aware error message are map lookups. Not implementable before the field exists | + +**Rebase this branch onto `main` after Phase 0 merges.** Both branches currently sit on +`ad00aae`; without the rebase, Phase 1's PR diff would carry Phase 0's commits. + +--- + +## Already closed prerequisites + +The design's single-choke-point premise now holds. It did not when the doc was first drafted: +five public cmdlets called `Invoke-RestMethod` directly. Three are connection lifecycle +(`Connect-PfbArray`, `Disconnect-PfbArray`, `Get-PfbApiVersion`) and legitimately sit outside +the choke point — none takes a context. The other two were ordinary writes that bypassed it: + +| Cmdlet | Status | +|---|---| +| `Set-PfbPresetWorkload` | Closed — folded onto the shared path (#76); `Invoke-PfbApiRequest` gained `PUT` | +| `Set-PfbWorkloadTag` | Closed — folded onto the shared path (#77 / PR #81, merged); `Invoke-PfbApiRequest` and `Assert-PfbApiCapability` gained array request bodies | + +This mattered more than tidiness: a cmdlet bypassing the choke point gets no injection and does +so **silently**, since the injection path cannot warn about a caller it never sees. Verify both +closures are on `main` before starting — if either regresses, "every request funnels through +`Invoke-PfbApiRequest`" has a counterexample again. + +--- + +## Scope + +### 1. The `PfbContext` object + +A context is not a bare `string[]`. + +| Field | Meaning | +|---|---| +| `Entries` | one or more context entries | +| `AllowErrors` | tri-state; **reserved in Phase 1, surfaced in Phase 2** | + +Each entry: + +| Field | Meaning | +|---|---| +| `Name` | the context name | +| `Kind` | `Array` (default) \| `Fleet` \| `TopologyGroup` | +| `Form` | `Object` (default) \| `AllArrays` | + +**`Kind` is per-entry, not one scalar for the whole context.** Mixed-kind lists are on the near +roadmap — with Fleet Users and Fleet Audits, `context_names` will accept `ArrayA,ArrayB,myFleet` +in a single `GET`, and the Fleet-audits design documents exactly that shape. A single `Kind` +cannot express it. The field costs nothing now and is a breaking change to add later, so Phase 1 +reserves the shape while only ever populating entries of one kind. + +**`Form` is an enum, not a boolean.** The suffix vocabulary is already known to be open; +`.arrays` is the only form the server accepts today, but an all-sub-groups equivalent and +realm-as-context would each need their own flag. Two booleans can also encode a meaningless +state (both set), which an enum makes unrepresentable. Retrofitting is a breaking change to a +published parameter, since a `-AllArrays` switch and a `-Form` parameter cannot coexist cleanly. + +Wire composition — **note the two invalid combinations, both rejected client-side**: + +| `Kind` | `Form` | Wire value | +|---|---|---| +| `Array` | `Object` | bare array name | +| `Fleet` | `Object` | bare fleet name — addresses the fleet-level object | +| `Fleet` / `TopologyGroup` | `AllArrays` | `.arrays` | +| `Array` | `AllArrays` | **invalid** — an array has no members | +| `TopologyGroup` | `Object` | **invalid** — no endpoint accepts a bare group name | + +The second invalid row is measured, not inferred: a bare topology-group name is rejected on +every endpoint probed, including the topology-group endpoints themselves (`code 13` there, +`code 42` on array-scoped resources). A group is reachable as a context **only** through +`.arrays`, and that suffix is case-sensitive. Fleet and group are asymmetric — a fleet is +addressable as an object *and* as a membership, a group only as a membership. + +### 2. Context state on the connection object + +Two properties, two mutation policies: + +- **`.DefaultContext`** — the durable session default. Set at connect or via + `Set-PfbContext` / `Clear-PfbContext`, which are **copy-on-write**: they return a *new* + connection object and never mutate the caller's. +- **`.ContextOverride`** — the ambient, block-scoped value `Invoke-PfbInContext` sets. Mutable, + but scoped by construction and restored in `finally`. + +State on the object rather than `$script:` module scope fixes three failure modes structurally: +cross-connection leakage disappears; `$using:`-passed parallel work sees the right context +(`ForEach-Object -Parallel` / `Start-ThreadJob` share the live instance, `Start-Job` CliXml-clones +at fork time, which is what a fan-out wants); and nesting works without an explicit stack. + +Copy-on-write for `.DefaultContext` is not inconsistent with the module writing `AuthToken` / +`TokenExpiresAt` back onto `$Array` during auto-reconnect: token refresh is a *transparent* +mutation, context is a *targeting* mutation that changes which array a write hits. Different +risk classes, so a different policy. + +**Residual caveat to document:** concurrent workers mutating `.ContextOverride` on the same +shared object still race. Guidance — set context before forking parallel work; do not push +ambient overrides from inside concurrent workers on a shared connection. + +### 3. Resolution and injection in `Invoke-PfbApiRequest` + +Resolve once at the top of the choke point: + +``` +explicit -QueryParams['context_names'] > $Array.ContextOverride > $Array.DefaultContext > (none) +``` + +**Tri-state "none" is required.** Distinguish *unset* (`$null`) from *explicit no-context* +(`[string[]]@()`). Both inject nothing, but the empty-array form is a deliberate "run this one +call locally" — so `[AllowEmptyCollection()]` and a `-ne $null` check, never a truthiness check. +Only a **non-empty** resolved context is subject to the hard-throw gate, which is what makes +`Invoke-PfbInContext -Context @()` the escape hatch for an endpoint that does not support +`context_names`. + +Injection / gating decision table: + +| Condition | Action | +|---|---| +| No context (`$null` or explicit `@()`) | No injection, no check. Unchanged behaviour | +| Context set **and** the map entry lists `context_names` | Inject; let `Assert-PfbApiCapability` catch "recorded but array too old" | +| Context set, no map entry for `Method Endpoint` **or** entry lacks `context_names`, **and** the array's version is *within* the map's scanned range | **Throw.** Name the endpoint; do not send | +| Same, but the array's version *exceeds* the scanned range | Do **not** throw. Proceed permissively | +| Context is multi-value **and** the endpoint is not multi-context-capable | **Throw**, tell the caller to narrow to one | +| Context kind incompatible with the endpoint's `contextScope` | **Throw**, one uniform message | + +The third and fourth rows **must mirror each other exactly**. The likeliest real staleness case +is an endpoint that exists today and *gains* `context_names` later — entry present, parameter +absent — not an endpoint missing from the map. + +**Apply the throw uniformly across all verbs, including `GET`.** Softening reads to +`Write-Warning` does not hold up: `-WarningAction SilentlyContinue` is routine in exactly the +automation most likely to set a read-scoped context; a wrong-scoped read inside a loop over +fleet members corrupts a result set invisibly; and a `GET`'s output routinely feeds a subsequent +mutating call. + +**Local context is still a context.** A context naming the local array, on an endpoint that does +not support `context_names`, **still throws**. This deliberately diverges from the server, which +short-circuits a local context before validating anything. A cmdlet that works only *some* of +the time, depending on which array the context happens to name, is a worse contract than one +that fails consistently. A caller wanting the local system should `Clear-PfbContext` or +`Invoke-PfbInContext -Context @()` and say so. + +#### Why the gate is client-side and not "send it and let the array error" + +Because in the case that matters there is no error to surface. An endpoint that never supported +`context_names` (`/alert-watchers`) **silently accepts** it — HTTP 200, real mutations applied, +zero mention of the parameter. That is the majority behaviour across GET endpoints that never +recorded it, and it includes the fleet-management surface itself (`/fleets`, `/fleets/members`). + +More broadly the array performs **no query-parameter validation at all** on reads: an entirely +invented parameter returns 200, and `allow_errors=not_a_boolean` returns 200 even on an endpoint +that genuinely declares `allow_errors`. **Accepting a parameter is not evidence an endpoint +supports it** — which is why every capability decision here is made client-side from the map +rather than by probing. + +The converse also holds: an endpoint can process `context_names` with no scanned spec version +recording it. `GET /snmp-managers/test` does exactly that. + +### 4. Three implementation-ordering requirements + +These are the non-obvious ones. Each has a silent failure mode. + +1. **Inject before the existing `Assert-PfbApiCapability` call.** In the current file that call + is at `Private/Invoke-PfbApiRequest.ps1:46` — **not** line 41 as the design doc states; the + doc's line numbers are stale and should not be trusted for placement. Do **not** inject + "immediately before the request is built," which is near query-string construction at `:84`, + after Assert has already run. If `context_names` lands in `$QueryParams` after Assert + executes, Assert never sees it and the version check this design leans on never fires. +2. **Mutate `$QueryParams`, never the built query string.** The `-AutoPaginate` loop rebuilds + the query from `$QueryParams` on every page (`:240`). Appending to the first page's URI drops + the context from page 2 onward. Not hypothetical — `Get-PfbArraySpace` already paginates. +3. **Consume Phase 0's `Resolve-PfbParameterComponent`** rather than resolving components + locally. The three-step contract (override key-present-but-`null`, override key-absent, then + the default) has one declared home. Note that key-present-`null` and key-absent both return + `$null`; the distinction is only whether the defaults table is consulted, which is exactly why + a local reimplementation goes subtly wrong. + +### 5. Three client-side gates + +All three exist to convert an obscure server code into an actionable message. None is a security +boundary — see "Authorization" below. + +**(a) Capability gate.** Rows 3-4 of the decision table. Keyed on the map's `generatedFrom`, so +absence within the scanned range is *confirmed* absence and absence beyond it is *no evidence*. + +**(b) Cardinality gate.** The rule, already shipped as +`Private/Test-PfbContextMultiValueCapable.ps1` (PR #73) and currently inert with zero runtime +callers: + +> An endpoint is multi-context-capable **iff** its `context_names` parameter resolves to +> component `Context_names_get` **AND** the endpoint also declares `allow_errors`. + +Phase 1's job is to *call* it, with `-ContextComponent` from Phase 0's resolver. Against the +committed map (`generatedFrom` 2.0-2.28) it yields 135 capable endpoints of the 139 referencing +the multi-value component. + +**The HTTP-verb rule is falsified — do not reintroduce it.** Rev 2 proposed GET multi-value / +mutations size-1. Four fleet-scoped GETs reject any two-name context with +`400 code 15 "Multiple location contexts are not allowed."`: `GET /presets/workload`, +`GET /topology-groups`, `GET /topology-groups/arrays`, `GET /topology-groups/members`. `code 15` +fires *before* the cross-array authorization gate, so it is structural to the endpoint, not a +permission artifact. A fleet-scoped endpoint has exactly one meaningful context, so multi-value +there is not merely restricted, it is meaningless. The verb survives **only** as a fallback for +an endpoint with no component signal at all, and that fallback throws on a method it has no +verdict for rather than assuming `$false`. + +**(c) Kind-vs-scope gate.** Reject a bare fleet or group name on an array-scoped endpoint and +vice versa, with one uniform message, reading `contextScope` from the map. Entries marked +`unknown` (19 operations) **suppress this check** and leave today's behaviour — the gate must +degrade, not throw, on absent metadata. + +### 6. Authorization-model precondition + +**Every `context_names` call targeting anything other than the connected array's own local +context** — a single-value switch, a multi-array list, or an `.arrays` context — fails with +`code 20 "Operation not permitted."` as a static-model admin, and succeeds for an +LDAP-authenticated one. There is no single-vs-multi distinction. + +This is **not** "`pureuser` vs everyone": since 4.5.0 admins can create additional named local +users with the same privileges, and the 4.8.1 service-account admin type is also local. All +three — `pureuser`, custom local users, service accounts — are `authorization_model: static`. +Only LDAP/SAML remote admins are `dynamic`. `GET /admins` reports the model per admin, which is +what makes the check implementable. + +Check client-side at `Connect-PfbArray` / `Set-PfbContext` and throw when a static-model admin +sets or uses any cross-array context: + +> targeting a context other than the local array requires a dynamic-authorization-model +> (LDAP/SAML) admin; static-model admins, including `pureuser` and other local accounts, are +> not permitted. + +**This earns its keep for diagnosis as much as ergonomics.** As a static-model admin, *every* +call to a fleet-scoped endpoint returns `code 20`, read and write alike, whatever the context +value — indistinguishable from the endpoint being unsupported on the platform. That exact +confusion produced a wrong conclusion twice during design testing ("presets are unsupported on +FB-A"). Converting it into a statement about the admin's authorization model, before the call, +is the difference between a diagnosable failure and a dead end. + +### 7. `Set-PfbContext` / `Clear-PfbContext` + +```powershell +Set-PfbContext + [-Array] # ordinary param; defaults to the current default connection + [-Context] # binds by property name + [-Kind ] # Array (default) | Fleet | TopologyGroup + [-AllArrays] # switch -> Form = AllArrays (.arrays suffix) + [-AllowErrors] # tri-state; reserved, Phase 2 + -> always returns the new connection object + +Clear-PfbContext + [-Array] + -> always returns the new connection object +``` + +- **Copy, not mutate.** A helper frame, an outer scope, or a loop iteration holding the old + `$fb` keeps its original scope; only the caller capturing the return value sees the change. +- **Always return the new object; no `-PassThru`.** The output *is* the effect. +- **Must swap the cache pointers.** The module tracks connections in `$script:PfbArrays` and + `$script:PfbDefaultArray`. Both cmdlets must repoint these at the new copy, or callers using + the implicit default connection keep hitting the old object after the cmdlet "succeeded." +- **`Clear-PfbContext` is its own cmdlet**, matching the `Set-`/`Clear-PfbCredential` precedent, + and because `@()` must keep its distinct meaning at the `Invoke-PfbInContext` layer. +- **Neither cmdlet makes a network call.** See "`-AllArrays`" below. + +#### Pipeline binding + +```powershell +$fb = Get-PfbFleetMember -FleetName 'fleet-prod' | Set-PfbContext +$fb = Get-PfbFleet | Set-PfbContext -AllArrays # -> cc-test-fleet.arrays +$fb = Get-PfbTopologyGroup | Set-PfbContext -AllArrays # -> region-1.arrays +``` + +```powershell +[Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] +[Alias('MemberName','Name')] +[string[]]$Context +``` + +- `-Array` becomes an ordinary parameter defaulting to the current default connection, giving + the pipeline slot to the context. +- Because `Set-PfbContext` is copy-on-write it **must accumulate in `process{}` and emit exactly + one connection in `end{}`**, scoped to the union — otherwise N piped members yield N + connection objects. +- **`$fb | Set-PfbContext` is dropped as redundant.** `Set-PfbContext -Array $fb -Context 'b'` + and the implicit-default form already cover it, and it is the only thing standing between us + and the far more valuable `Get-PfbFleetMember | Set-PfbContext`. +- Phase 0 supplies `MemberName` on `Get-PfbFleetMember`. **`Get-PfbTopologyGroup` does not exist + yet** — it is #38's, under a binding contract to emit a top-level `Name`. Until it lands, the + `-AllArrays` group form is exercised against a fleet and against hand-constructed input. +- **Piping many members yields a multi-value context**, valid for fan-out-capable GETs and + rejected elsewhere. Document "pipe all members into a durable context" as a *read-scoping* + ergonomic. + +### 8. `Invoke-PfbInContext` + +```powershell +function Invoke-PfbInContext { + param( + [Parameter(Mandatory)][PSCustomObject]$Array, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Context, + [Parameter(Mandatory, Position=0)][scriptblock]$ScriptBlock + ) + $previous = $Array.ContextOverride + $Array.ContextOverride = $Context + try { & $ScriptBlock } + finally { $Array.ContextOverride = $previous } +} +``` + +- **Nesting works with no explicit stack** — each invocation captures its own `$previous`, so + the call stack provides push/pop discipline and the inner block restores the *outer* value + rather than clearing it. +- **Exception-safe via `finally`**, at every nesting level. +- **Non-pipeable by design** — its pipeline payload would have to be the scriptblock, which no + cmdlet emits. The blessed form for it is + `-Context (Get-PfbFleetMember -FleetName 'x').member.name`. + +### 9. `-AllArrays` + +`-AllArrays` emits the single `.arrays` context and lets the server resolve membership, rather +than enumerating names client-side. + +**This is not sugar for piping members.** For topology groups the two are not equivalent: + +- **A group's members may themselves be groups.** `GET /topology-groups/members` returns direct + members, which can be sub-groups. Piping them yields group names where array names are + required. `.arrays` is transitive over the whole sub-tree; enumerating members is not. + Measured on the standing nested fixtures: `parent.arrays` → 2 arrays, reaching `FB-B` *through* + the sub-group, while the member list returns a group plus one array. +- **Membership drifts.** A client-side name list is a snapshot; `.arrays` is re-resolved + server-side on every request, so a durable context stays correct as arrays join or leave. +- **One request, not N.** Enumerating costs a round trip per level of nesting. + +`Get-PfbTopologyGroupMember | Set-PfbContext` remains correct for "scope to these specific +members," but is not how to express "the whole group." + +**The name is not resolved before the context is stored, and no network call is made.** This is +a change from rev 3, which specified a validating round trip. The wire rejects a bad name +loudly at first use: + +| Context | Result | +|---|---| +| `zz-no-such-group.arrays` | `code 42 "Executor not found for zz-no-such-group.arrays"` | +| `cc-test-fleeet.arrays` | `code 42 "Executor not found for cc-test-fleeet.arrays"` | +| `.array` / `.ARRAYS` | `code 42 "Cannot find array in fleet"` — the suffix is case-sensitive | +| `FB-B.arrays` (array + suffix) | `code 42 "Cannot specify parameter FB-B.arrays ..."` | + +The `.arrays` forms **quote the offending value verbatim**, so the failure names its own cause +and arrives on the very next call. Rev 3's justification — a mistyped name stored and then +silently misdirecting every subsequent call — is empirically false. Validating would buy only +failing one call earlier, at the cost of a hidden network call on every context set and a +resolution path differing by kind (`GET /fleets?names=` for a fleet, +`GET /topology-groups/arrays?topology_group_names=` for a group, there being no `/fleets/arrays`), +where the group call is the weaker diagnostic of the two. + +**No `-NoValidate` escape hatch ships**, because there is no validation to escape. + +`-AllArrays` does still validate **locally**: `Kind = Array` with `-AllArrays` is rejected, as +is `Kind = TopologyGroup` with `Form = Object`. + +### 10. Error annotation + +The array returns `code 42 "Cannot find array in fleet"` for an unresolvable context name. As +drafted that flows through `ConvertTo-PfbApiError` as a bare "FlashBlade API error: Cannot find +array in fleet" — with no indication of *which* name caused it or that it came from a session +default set several calls earlier. + +**The injection layer annotates context-targeting failures with the active context name(s).** +With `contextScope` in hand the annotation names the required context *kind* and the cmdlet that +sets it, not merely the value that failed: + +> `Set-PfbPresetWorkload` targets a fleet-scoped resource, which requires a fleet context. The +> current context is the local array. Set one with `Set-PfbContext -Fleet `, or run this +> call in a fleet context with `Invoke-PfbInContext -Fleet { ... }`. Get the fleet name +> from `Get-PfbFleet`. + +and the inverse: + +> `Get-PfbFileSystem` targets an array-scoped resource; a fleet name is not a valid context for +> it. Use a member array name, or `.arrays` to target every array in the fleet. + +Every affected cmdlet's comment-based help also gains a `.NOTES` line stating its context +requirement, generated from the same field so it cannot drift from the validation. + +### 11. Per-item attribution + +A fanned-out response carries a **`context`** field on every item, naming its source array. +(Measured — the field is `context`, not `_context` as some upstream material suggests.) Without +surfacing it, fanned-out items are indistinguishable. Phase 1 must not strip it: the response +layer currently reads only `items` (`:219`), `total_item_count` (`:230`), and +`continuation_token` (`:236`) — everything else on the response body is discarded. + +The `errors`-array and HTTP 207 half of fan-out is **Phase 2** — Phase 1 has no 207 branch, and +the status code is currently inspected only in failure paths (`:148`, `:352`). + +--- + +## Resolved open questions + +The design doc left seven open. Six are settled: + +| # | Question | Resolution | +|---|---|---| +| 1 | Keep the explicit `-QueryParams['context_names']` precedence tier, which nothing in the public surface can populate? | **Keep** as defensive layering | +| 2 | Object-model surfacing of `Kind` / `Form` | Settled: explicit `-Kind` plus `-AllArrays` over an enum `Form`. **Do not ship `-NoValidate`** — rev 4 removes the validation it would have escaped | +| 3 | Annotate fleet-membership failures with the context name | **Yes** — section 10 | +| 4 | Multi-value mutating writes | **Throw, narrow-to-one**, first pass | +| 5 | Cross-platform (FlashArray) context in the same fleet | **No support.** Remains a non-goal; the module neither supports nor blocks it. Note `Get-PfbFleetMember` will happily return FlashArrays for piping into `Set-PfbContext`, so this pipeline must not be documented as safe for mixed-platform fleets | +| 6 | Fail-open on the no-signal verb fallback (unmapped `GET` treated as multi-value-capable) | **No work** — keep as is. One live instance, `GET /snmp-managers/test` | + +**Open Question 7 remains open**: what a fleet-scoped endpoint should do with no context set. +Phase 1 **ships the client-side throw** with a message naming the requirement and the cmdlet +that satisfies it, and revisits later. + +Default-to-fleet is no longer blocked on cost — an array belongs to at most one fleet (the API +states it at `POST /fleets/members/batch`, the endpoint family is singular throughout, and +`GET /fleets` returns exactly one entry even on a coordinator, measured), so the fleet name is +unambiguous and resolvable once at connect. What remains open is whether one-fleet membership is +a **guaranteed property of the model or a current limitation** — pending confirmation from Wes. +That answer decides whether the module may rely on it; until then, synthesizing a context the +caller never asked for is not justified. + +--- + +## Out of scope + +- **Phase 2** — `allow_errors` end-to-end: surfacing, default rules, HTTP 207 recognition, and + the `errors` branch with per-array non-terminating errors keyed by `location_context`. + Phase 1 reserves `AllowErrors` on the object and injects nothing for it. +- **Phase 3** — `context_ids`; an explicit multi-value mutating fan-out helper; display of the + active context in `Get-PfbArrayConnection`; `Realm` as a context kind when the API ships it. +- **#38** — topology-group and fleet/realm object-management cmdlets, including + `Get-PfbTopologyGroup` and `Get-PfbTopologyGroupMember`. Phase 1 owes #38 exactly one thing: + the binding contract that `Get-PfbTopologyGroup` emit a top-level `Name`. +- **Module version bump and CHANGELOG** — the maintainer's own decision, not part of feature PRs. + +--- + +## Testing + +Per this repo's rule: **scope every run, both PowerShell editions**, via +`.claude/skills/run-pester-tests/scripts/Invoke-ScopedPester.ps1 -Path `. Read the +`Container` column, not the counts. Never the aggregate suite as a completion check. + +`[Parameter(Mandatory)]` tested by `Should -Throw` alone prompts and hangs under +`-NonInteractive` — use an optional parameter with an explicit throw. This applies directly to +`Invoke-PfbInContext`, which has three mandatory parameters. + +Unit coverage: + +- **Three-level precedence resolution**, including the empty-array "explicit none" case. +- **Capability-gated injection on both paths** — allow and hard-throw. +- **Version gate** — `Assert-PfbApiCapability` verified to run *after* injection so it actually + sees the parameter. A test that supplies `context_names` in `-QueryParams` itself **cannot + detect the ordering bug** — an early live test "confirmed" the gate that way and exercised a + path the shipped code would not take. Drive it through the injection path. +- **Staleness** — within-range absence throws; beyond-range absence stays permissive. +- **`Invoke-PfbInContext` exception safety** — override restored when the scriptblock throws + partway through, not only on the happy path. +- **Nested `Invoke-PfbInContext`** — the inner block restores the *outer* value, including when + the inner block throws. +- **Cross-connection isolation** — an override set via `$fb1` must not affect `$fb2`. +- **Copy-on-write** — the original object's `.DefaultContext` untouched while the cache and + default pointers move to the new copy. +- **Pipeline accumulation** — N piped members produce exactly one connection scoped to the union. +- **Pagination** — `context_names` persists across pages. +- **Cardinality** — multi-value on a non-capable endpoint throws; a capable one injects. Include + one of the four fleet-scoped GETs as a fixture, since they are the case a verb-shaped rule + gets wrong. +- **Local context is not special-cased** — a context naming the local array still throws on an + unsupported endpoint. +- **Kind-vs-scope** — bare fleet or group name rejected on array-scoped endpoints and vice + versa, one uniform error; `contextScope = unknown` suppresses the check rather than throwing. +- **`-AllArrays` composition** — `Form = AllArrays` renders `.arrays` and not the bare + name; `Kind = Array` with `-AllArrays` rejected; `Kind = TopologyGroup` with `Form = Object` + rejected; and **no network call is made** when a context is set. +- **`authorization_model` gate** — a static-model admin setting or using any cross-array context + throws client-side. +- **Fleet-scoped mutation with no context** — the OQ7 throw is asserted rather than left to the + endpoint. A `New-`/`Set-`/`Remove-PfbPresetWorkload` call with no context must not reach the + wire only to come back `code 13`. +- **Name-scoped read on a fleet-scoped endpoint** — `?names=` with no context must behave like a + mutation, not an unfiltered list. This is the case a verb-shaped test misses. +- **Per-item `context` attribution survives** the response layer. + +Test files in scope — existing: `Tests/Invoke-PfbApiRequest.*.Tests.ps1`, +`Tests/Assert-PfbApiCapability.Tests.ps1`, `Tests/PfbContextRuleTools.Tests.ps1`, +`Tests/Connect-PfbArray.*.Tests.ps1`, `Tests/ModuleManifest.Tests.ps1`. New: +`Tests/Set-PfbContext.Tests.ps1`, `Tests/Clear-PfbContext.Tests.ps1`, +`Tests/Invoke-PfbInContext.Tests.ps1`, `Tests/PfbContext.Tests.ps1`, and +`Tests/Test-PfbContextMultiValueCapable.Tests.ps1` — the predicate shipped in #73 with **no +dedicated test file**; its coverage today is indirect, via `PfbContextRuleTools.Tests.ps1`. +Phase 1 is the first runtime caller, so it owes the predicate direct tests. + +Restoring a module `$script:` variable between tests (`$script:PfbDefaultArray`, +`$script:PfbArrays`) must pass the value through `param()` — a `.GetNewClosure()` against a +module scope fails under `StrictMode` and silently leaves state leaked. + +--- + +## Live verification + +Mandatory before this branch opens a PR. Two preconditions, each of which produces confident +wrong conclusions when violated: + +1. **Probe from a remote member, never the local array.** A self-context test passes for the + wrong reason — the server short-circuits a local context before validating anything. +2. **Run as a dynamic-authorization-model admin** (FSA `juemerson`), never static `pureuser`, + which fails every probe with `code 20`. Multi-value context was once recorded as `code 20` + and used as a cardinality contrast; that was a static-credential artifact. As a dynamic + admin, `GET /file-systems?context_names=FB-B,FB-C` returns 200 with 2 items. `code 15` is + the real cardinality signal and is independent of the authorization model. + +Target FB-A (`cc-test-fleet` coordinator, Purity//FB 4.8.2, REST 2.26) with FB-B and FB-C as +members. Standing nested fixtures exist and are **retained deliberately**: + +| Group | Direct members | +|---|---| +| `zz-claude-tg-parent` | `zz-claude-tg-child` (a group), `FB-C` | +| `zz-claude-tg-child` | `FB-B` | + +`FB-A` is in neither. **A `.arrays` test must assert `FB-A`'s absence, not just the item +count** — a correct 2-array result and a silent fallback to local execution differ only in +which arrays come back. Transitivity cannot be tested against a flat hierarchy at all; a +single-level fixture passes a test that proves nothing. + +Verify live, at minimum: single-array context switch; multi-value fan-out on a capable GET; +`code 15` on one of the four fleet-scoped GETs; `.arrays` transitivity through the sub-group; +the kind-vs-scope throw against `/presets/workload`; the OQ7 throw; and `context_names` +persisting across a paginated call. + +⚠ `GET /topology-groups/arrays` cannot be probed bare — its own parameter validation runs +*before* the context check and returns `code 24`. A bare `code 24` from it is not evidence +either way. + +--- + +## Risks + +| Risk | Handling | +|---|---| +| Phase 0 does not merge first | Hard dependency. Rebase before opening the PR; do not stack | +| Injection placed after `Assert-PfbApiCapability` | The version gate silently never fires. Covered by a test that drives injection rather than supplying the parameter | +| Context lost on page 2+ | Covered by a pagination test against an endpoint that genuinely paginates | +| Cardinality rule reimplemented instead of called | Phase 0's resolver plus the existing predicate; assert there is exactly one `Context_names_get` comparison in `Private/` | +| Concurrent workers race on `.ContextOverride` | Documented limitation with explicit guidance; not fixed in Phase 1 | +| A static-model lab session makes a real capability look unsupported | The authorization gate converts it to a clear message; live testing uses the dynamic admin | +| `Get-PfbTopologyGroup` absent, so the group `-AllArrays` path has no source cmdlet | Exercise via fleet and hand-constructed input; the contract on #38 covers the eventual pipeline | From 3f2c1e70051b84b1d9add2c22e6334543daef478 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 10:33:55 -0700 Subject: [PATCH 02/53] fix(data): record GET /presets/workload as fleet-scoped The x-pure-remote-execution-context-domains-override declares ARRAY|FLEET, but the ARRAY half is satisfied only by the universal local-context short-circuit; measured from a remote member, a remote array name and .arrays both return code 13 and only the bare fleet name is accepted. Recording 'array' made the Phase 1 kind-vs-scope gate throw on the only working context and permit one that silently targets the local replica. The operation also carries x-pure-block-remote-execution AND x-pure-incomplete-gre alongside the override -- block=true is itself falsified by a fleet context returning 200, and incomplete-gre is upstream's own statement that this annotation is unfinished. Co-Authored-By: Claude Opus 5 --- Data/PfbCapabilityMap.json | 2 +- Tests/Build-PfbCapabilityMap.Tests.ps1 | 42 +++++++++++++++++++++++--- tools/Build-PfbCapabilityMap.ps1 | 23 +++++++++----- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/Data/PfbCapabilityMap.json b/Data/PfbCapabilityMap.json index 72c0c52b..463df518 100644 --- a/Data/PfbCapabilityMap.json +++ b/Data/PfbCapabilityMap.json @@ -11534,7 +11534,7 @@ }, "bodyProperties": {}, "contextScope": { - "scope": "array", + "scope": "fleet", "provenance": "declared" }, "parameterComponentOverrides": { diff --git a/Tests/Build-PfbCapabilityMap.Tests.ps1 b/Tests/Build-PfbCapabilityMap.Tests.ps1 index ef83cfc4..7bcceff6 100644 --- a/Tests/Build-PfbCapabilityMap.Tests.ps1 +++ b/Tests/Build-PfbCapabilityMap.Tests.ps1 @@ -707,6 +707,12 @@ Describe 'Build-PfbCapabilityMap: contextScope' -Skip:($PSVersionTable.PSVersion "/api/2.28/some-uncurated-thing": { "get": { "x-pure-incomplete-gre": true, "responses": { "200": { "description": "ok" } } } }, + "/api/2.28/array-only-thing": { + "get": { + "x-pure-remote-execution-context-domains-override": ["ARRAY"], + "responses": { "200": { "description": "ok" } } + } + }, "/api/2.28/file-systems": { "get": { "responses": { "200": { "description": "ok" } } } } @@ -756,12 +762,20 @@ Describe 'Build-PfbCapabilityMap: contextScope' -Skip:($PSVersionTable.PSVersion # absence here. Reordering the ladder's branches fails this and nothing else. $get = $script:csMap.endpoints.'GET /presets/workload'.contextScope $get.provenance | Should -Be 'declared' -Because 'the override is present, so the flag is irrelevant' - $get.scope | Should -Be 'array' - } - - It 'trusts a declared override: ARRAY+FLEET is array-scoped, FLEET-only is fleet-scoped' { + $get.scope | Should -Be 'fleet' + } + + It 'a declared FLEET domain wins over an accompanying ARRAY' { + # ARRAY|FLEET occurs on exactly one real operation, GET /presets/workload, and its + # ARRAY half is satisfied only by the middleware short-circuit that resolves a LOCAL + # context before any scope validation -- which every endpoint does, so it carries no + # scope information. Measured from a remote member: a remote array name and + # .arrays both return code 13, and only the bare fleet name is accepted. + # Recording 'array' would make Phase 1's kind-vs-scope gate throw on the only context + # that works AND permit a local-array context that quietly reads the local replica + # instead of the fleet object. $get = $script:csMap.endpoints.'GET /presets/workload'.contextScope - $get.scope | Should -Be 'array' + $get.scope | Should -Be 'fleet' $get.provenance | Should -Be 'declared' $put = $script:csMap.endpoints.'PUT /presets/workload'.contextScope @@ -769,6 +783,24 @@ Describe 'Build-PfbCapabilityMap: contextScope' -Skip:($PSVersionTable.PSVersion $put.provenance | Should -Be 'declared' } + It 'records array for an override declaring ARRAY alone' { + # Unreachable against today's specs -- the only override-bearing operations are the + # five preset ones, four FLEET-only and one ARRAY|FLEET -- so without this fixture the + # ARRAY branch has no coverage at all once ARRAY|FLEET resolves to fleet. + $cs = $script:csMap.endpoints.'GET /array-only-thing'.contextScope + $cs.scope | Should -Be 'array' + $cs.provenance | Should -Be 'declared' + } + + It 'the COMMITTED map records GET /presets/workload as fleet-scoped' { + # The tests above run the generator over a synthetic fixture; this one asserts the + # SHIPPED artifact, because that is what the Phase 1 runtime gates actually read. + $committed = Get-Content (Join-Path $script:csRepoRoot 'Data/PfbCapabilityMap.json') -Raw | ConvertFrom-Json -Depth 20 + $committed.endpoints.'GET /presets/workload'.contextScope.scope | Should -Be 'fleet' + @($committed.endpoints.PSObject.Properties | + Where-Object { $_.Value.contextScope.scope -eq 'fleet' }).Count | Should -Be 8 + } + It 'applies the curated value for a flagged, curated endpoint' { $tg = $script:csMap.endpoints.'GET /topology-groups'.contextScope $tg.scope | Should -Be 'fleet' diff --git a/tools/Build-PfbCapabilityMap.ps1 b/tools/Build-PfbCapabilityMap.ps1 index 7a9a7bd0..a3f05366 100644 --- a/tools/Build-PfbCapabilityMap.ps1 +++ b/tools/Build-PfbCapabilityMap.ps1 @@ -226,9 +226,9 @@ foreach ($entry in $specFiles) { $scopeValue = 'array' $scopeProvenance = 'default' if ($scopeRecord -and @($scopeRecord.DomainsOverride).Count -gt 0) { - # Declared domains are authoritative. FLEET-only means fleet-scoped; anything - # that also accepts ARRAY is usable array-scoped, which is what Phase 1 needs - # to know. + # Declared domains are authoritative for WHICH scope, but FLEET takes precedence + # when both are declared -- see the branch comment below for why the ARRAY half of + # an ARRAY|FLEET declaration carries no scope information. # # An unrecognised domain token falls to unknown/unknown rather than to 'fleet'. # Only ARRAY and FLEET occur across all 29 cached specs today (measured), so this @@ -237,12 +237,21 @@ foreach ($entry in $specFiles) { # reads as "upstream told us" to Phase 1. Recording ignorance is the honest # failure mode, and it is the same one the flagged-but-uncurated case uses. $declaredDomains = @($scopeRecord.DomainsOverride | ForEach-Object { "$_".ToUpperInvariant() }) - if ($declaredDomains -contains 'ARRAY') { - $scopeValue = 'array' + # FLEET wins over an accompanying ARRAY. `ARRAY|FLEET` occurs on exactly one + # operation (GET /presets/workload) and its ARRAY half is satisfied only by the + # middleware short-circuit that resolves a LOCAL context before any scope + # validation -- which every endpoint in the API does, so it carries no scope + # information. Measured: a remote array name on that operation returns + # `code 13 "Invalid context."` while a bare fleet name returns 200 + # (rev 4 Appendix A). Recording 'array' would make Phase 1's kind-vs-scope gate + # throw on the only context that works AND permit a local-array context that + # quietly reads the local replica instead of the fleet object. + if ($declaredDomains -contains 'FLEET') { + $scopeValue = 'fleet' $scopeProvenance = 'declared' } - elseif ($declaredDomains -contains 'FLEET') { - $scopeValue = 'fleet' + elseif ($declaredDomains -contains 'ARRAY') { + $scopeValue = 'array' $scopeProvenance = 'declared' } else { From 92524ce202dd6e48e3bad95ef66ea4b7f046e472 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 10:56:00 -0700 Subject: [PATCH 03/53] feat(context): add the PfbContext object with Kind/Form composition rules Co-Authored-By: Claude Opus 5 --- Private/PfbContext.ps1 | 64 ++++++++++++++++++++++++++ Tests/PfbContext.Tests.ps1 | 94 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 Private/PfbContext.ps1 create mode 100644 Tests/PfbContext.Tests.ps1 diff --git a/Private/PfbContext.ps1 b/Private/PfbContext.ps1 new file mode 100644 index 00000000..7ac53a47 --- /dev/null +++ b/Private/PfbContext.ps1 @@ -0,0 +1,64 @@ +# The Fusion context object. See docs/design/fusion-context-phase-1-spec.md section 1 for +# why Kind is per-entry and Form is an enum rather than two booleans. + +$script:PfbContextKinds = @('Array', 'Fleet', 'TopologyGroup') +$script:PfbContextForms = @('Object', 'AllArrays') +$script:PfbAllArraysSuffix = '.arrays' # case-sensitive on the wire; measured + +function New-PfbContextEntry { + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)][string]$Name, + [ValidateSet('Array', 'Fleet', 'TopologyGroup')][string]$Kind = 'Array', + [ValidateSet('Object', 'AllArrays')][string]$Form = 'Object' + ) + [PSCustomObject]@{ Name = $Name; Kind = $Kind; Form = $Form } +} + +function New-PfbContext { + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Entries, + # Tri-state: $null means "not specified". Reserved in Phase 1, surfaced in Phase 2. + [AllowNull()][object]$AllowErrors = $null + ) + [PSCustomObject]@{ Entries = @($Entries); AllowErrors = $AllowErrors } +} + +function Assert-PfbContextEntryComposition { + [CmdletBinding()] + param([Parameter(Mandatory)]$Entry) + + if ($Entry.Kind -eq 'Array' -and $Entry.Form -eq 'AllArrays') { + throw "Context '$($Entry.Name)' is an array with -AllArrays, which is not a valid combination: an array has no members. Drop -AllArrays, or name a fleet or topology group instead." + } + # Measured on every endpoint probed, including the topology-group endpoints themselves: + # a bare group name is rejected (code 13 there, code 42 on array-scoped resources). A + # group is reachable as a context only through .arrays. + if ($Entry.Kind -eq 'TopologyGroup' -and $Entry.Form -eq 'Object') { + throw "Context '$($Entry.Name)' is a topology group addressed as an object, which no endpoint accepts. Use -AllArrays to target '.arrays' instead." + } +} + +function ConvertTo-PfbContextWireValue { + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)]$Entry) + + Assert-PfbContextEntryComposition -Entry $Entry + if ($Entry.Form -eq 'AllArrays') { return "$($Entry.Name)$($script:PfbAllArraysSuffix)" } + return $Entry.Name +} + +function ConvertTo-PfbContextEntryList { + [CmdletBinding()] + [OutputType([PSCustomObject[]])] + param( + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Name, + [ValidateSet('Array', 'Fleet', 'TopologyGroup')][string]$Kind = 'Array', + [ValidateSet('Object', 'AllArrays')][string]$Form = 'Object' + ) + @($Name | ForEach-Object { New-PfbContextEntry -Name $_ -Kind $Kind -Form $Form }) +} diff --git a/Tests/PfbContext.Tests.ps1 b/Tests/PfbContext.Tests.ps1 new file mode 100644 index 00000000..09a86082 --- /dev/null +++ b/Tests/PfbContext.Tests.ps1 @@ -0,0 +1,94 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +BeforeAll { + Import-Module "$PSScriptRoot/../PureStorageFlashBladePowerShell.psd1" -Force +} + +Describe 'PfbContext object' { + Context 'wire composition' { + It 'renders a bare name for Array/Object' { + InModuleScope PureStorageFlashBladePowerShell { + $e = New-PfbContextEntry -Name 'FB-B' -Kind 'Array' -Form 'Object' + ConvertTo-PfbContextWireValue -Entry $e | Should -Be 'FB-B' + } + } + It 'renders a bare name for Fleet/Object' { + InModuleScope PureStorageFlashBladePowerShell { + $e = New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet' -Form 'Object' + ConvertTo-PfbContextWireValue -Entry $e | Should -Be 'cc-test-fleet' + } + } + It 'appends .arrays for Fleet/AllArrays' { + InModuleScope PureStorageFlashBladePowerShell { + $e = New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet' -Form 'AllArrays' + ConvertTo-PfbContextWireValue -Entry $e | Should -Be 'cc-test-fleet.arrays' + } + } + It 'appends .arrays for TopologyGroup/AllArrays' { + InModuleScope PureStorageFlashBladePowerShell { + $e = New-PfbContextEntry -Name 'region-1' -Kind 'TopologyGroup' -Form 'AllArrays' + ConvertTo-PfbContextWireValue -Entry $e | Should -Be 'region-1.arrays' + } + } + It 'uses a lower-case .arrays suffix, which is case-sensitive on the wire' { + InModuleScope PureStorageFlashBladePowerShell { + $e = New-PfbContextEntry -Name 'x' -Kind 'Fleet' -Form 'AllArrays' + ConvertTo-PfbContextWireValue -Entry $e | Should -MatchExactly '\.arrays$' + } + } + } + Context 'invalid compositions' { + It 'rejects Array + AllArrays because an array has no members' { + InModuleScope PureStorageFlashBladePowerShell { + $e = New-PfbContextEntry -Name 'FB-B' -Kind 'Array' -Form 'AllArrays' + { Assert-PfbContextEntryComposition -Entry $e } | Should -Throw -ExpectedMessage '*an array has no members*' + } + } + It 'rejects TopologyGroup + Object because no endpoint accepts a bare group name' { + InModuleScope PureStorageFlashBladePowerShell { + $e = New-PfbContextEntry -Name 'region-1' -Kind 'TopologyGroup' -Form 'Object' + { Assert-PfbContextEntryComposition -Entry $e } | Should -Throw -ExpectedMessage '*.arrays*' + } + } + It 'accepts every valid pair' { + InModuleScope PureStorageFlashBladePowerShell { + foreach ($pair in @(@('Array','Object'), @('Fleet','Object'), @('Fleet','AllArrays'), @('TopologyGroup','AllArrays'))) { + $e = New-PfbContextEntry -Name 'n' -Kind $pair[0] -Form $pair[1] + { Assert-PfbContextEntryComposition -Entry $e } | Should -Not -Throw + } + } + } + } + Context 'defaults and shape' { + It 'defaults Kind to Array and Form to Object' { + InModuleScope PureStorageFlashBladePowerShell { + $e = New-PfbContextEntry -Name 'FB-B' + $e.Kind | Should -Be 'Array' + $e.Form | Should -Be 'Object' + } + } + It 'keeps Kind per-entry, not one scalar for the context' { + InModuleScope PureStorageFlashBladePowerShell { + $c = New-PfbContext -Entries @( + (New-PfbContextEntry -Name 'FB-B' -Kind 'Array'), + (New-PfbContextEntry -Name 'f' -Kind 'Fleet') + ) + @($c.Entries).Count | Should -Be 2 + $c.Entries[0].Kind | Should -Be 'Array' + $c.Entries[1].Kind | Should -Be 'Fleet' + } + } + It 'reserves AllowErrors as tri-state, defaulting to null (Phase 2 surfaces it)' { + InModuleScope PureStorageFlashBladePowerShell { + (New-PfbContext -Entries @((New-PfbContextEntry -Name 'x'))).AllowErrors | Should -BeNullOrEmpty + } + } + It 'normalises a string[] into entries of one kind' { + InModuleScope PureStorageFlashBladePowerShell { + $entries = ConvertTo-PfbContextEntryList -Name @('FB-B','FB-C') -Kind 'Array' -Form 'Object' + @($entries).Count | Should -Be 2 + $entries[1].Name | Should -Be 'FB-C' + } + } + } +} From 3b98012d668ba05a67fa50ef06f7940578bc5ea9 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 11:21:39 -0700 Subject: [PATCH 04/53] feat(context): add DefaultContext/ContextOverride state and copy-on-write helpers --- Private/Copy-PfbConnection.ps1 | 29 ++++++ Public/Connection/Connect-PfbArray.ps1 | 39 ++++++- Tests/Connect-PfbArray.Context.Tests.ps1 | 123 +++++++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 Private/Copy-PfbConnection.ps1 create mode 100644 Tests/Connect-PfbArray.Context.Tests.ps1 diff --git a/Private/Copy-PfbConnection.ps1 b/Private/Copy-PfbConnection.ps1 new file mode 100644 index 00000000..ed27b67b --- /dev/null +++ b/Private/Copy-PfbConnection.ps1 @@ -0,0 +1,29 @@ +# Copy-on-write for the connection object. Set-/Clear-PfbContext return a NEW connection and +# never mutate the caller's: context is a TARGETING mutation, unlike the transparent +# AuthToken/TokenExpiresAt writes the auto-reconnect path makes in place. See spec section 2. + +function Copy-PfbConnection { + [CmdletBinding()] + [OutputType([PSCustomObject])] + param([Parameter(Mandatory)][PSCustomObject]$Array) + + # .PSObject.Copy() is a shallow clone that PRESERVES PSTypeNames. Rebuilding from a + # [PSCustomObject]@{} literal would drop 'PureStorage.FlashBlade.Connection'. + # Deliberately does NOT touch $script:PfbArrays/$script:PfbDefaultArray -- repointing the + # caches is Update-PfbConnectionCache's single responsibility. + $Array.PSObject.Copy() +} + +function Update-PfbConnectionCache { + [CmdletBinding()] + param([Parameter(Mandatory)][PSCustomObject]$Array) + + # Both pointers must move or callers using the implicit default connection keep hitting + # the OLD object after the cmdlet "succeeded". Same idiom as the OAuth2 refresh path. + if ($script:PfbDefaultArray -and $script:PfbDefaultArray.Endpoint -eq $Array.Endpoint) { + $script:PfbDefaultArray = $Array + } + if ($script:PfbArrays -and $script:PfbArrays.ContainsKey($Array.Endpoint)) { + $script:PfbArrays[$Array.Endpoint] = $Array + } +} diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index 7e033ce0..34ead1fa 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -70,6 +70,15 @@ function Connect-PfbArray { Bypass SSL certificate validation. Common for lab environments with self-signed certs. .PARAMETER HttpTimeout HTTP request timeout in milliseconds. Default is 30000 (30 seconds). + .PARAMETER Context + One or more Fusion context names to use as the durable session default for this + connection. Names are not resolved at connect time; an invalid name is rejected by + the array, verbatim, on first use. + .PARAMETER Kind + What the -Context names refer to: 'Array' (default), 'Fleet', or 'TopologyGroup'. + .PARAMETER AllArrays + Target every array that is a member of the -Context names rather than the named + objects themselves. Not valid with -Kind Array, which has no members. .NOTES Certificate/OAuth2 sessions retain -ClientId, -Issuer, -KeyId, -PrivateKeyFile, and -PrivateKeyPassword (as a SecureString) on the connection object for the @@ -144,7 +153,17 @@ function Connect-PfbArray { [switch]$IgnoreCertificateError, [Parameter()] - [int]$HttpTimeout = 30000 + [int]$HttpTimeout = 30000, + + [Parameter()] + [string[]]$Context, + + [Parameter()] + [ValidateSet('Array', 'Fleet', 'TopologyGroup')] + [string]$Kind = 'Array', + + [Parameter()] + [switch]$AllArrays ) # Force TLS 1.2 on PowerShell 5.1 unconditionally -- independent of certificate @@ -426,6 +445,14 @@ function Connect-PfbArray { PrivateKeyPassword = $PrivateKeyPassword TokenExpiresAt = $tokenExpiresAt TokenTtlSeconds = $tokenTtlSeconds + # Fusion context state. $null means "unset"; an empty entry list means "explicitly no + # context" -- two distinct states, so never test these with if ($x). DefaultContext is + # durable for the session; ContextOverride is block-scoped (Invoke-PfbInContext). + DefaultContext = $null + ContextOverride = $null + # Reserved: populated in a later phase. Declared here so every connection object has a + # uniform shape. + AuthorizationModel = $null } # Hide secrets from default display. Sensitive fields (ApiToken, AuthToken, @@ -445,5 +472,15 @@ function Connect-PfbArray { $script:PfbDefaultArray = $connection $script:PfbArrays[$Endpoint] = $connection + # A context supplied at connect is the durable session default. Composition is validated + # locally; no network call resolves the name -- the wire rejects a bad one loudly and + # verbatim on first use (spec section 9). + if ($PSBoundParameters.ContainsKey('Context')) { + $form = if ($AllArrays) { 'AllArrays' } else { 'Object' } + $entries = ConvertTo-PfbContextEntryList -Name $Context -Kind $Kind -Form $form + foreach ($entry in $entries) { Assert-PfbContextEntryComposition -Entry $entry } + $connection.DefaultContext = New-PfbContext -Entries $entries + } + return $connection } diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 new file mode 100644 index 00000000..7b4882cb --- /dev/null +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -0,0 +1,123 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +BeforeAll { + Import-Module "$PSScriptRoot/../PureStorageFlashBladePowerShell.psd1" -Force + + # Source path used by the declaration tests below. The three context properties live in a + # [PSCustomObject]@{} literal that only a real connect could produce, so the presence of + # the state is asserted against the source rather than against a live array. + $script:ConnectSourcePath = Join-Path $PSScriptRoot '../Public/Connection/Connect-PfbArray.ps1' +} + +Describe 'connection context state' { + It 'copies without mutating the original and preserves the type name' { + InModuleScope PureStorageFlashBladePowerShell { + $fake = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example' + ApiVersion = '2.26' + DefaultContext = $null + ContextOverride = $null + AuthorizationModel = $null + } + $copy = Copy-PfbConnection -Array $fake + $copy.DefaultContext = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $fake.DefaultContext | Should -BeNullOrEmpty + $copy.PSObject.TypeNames | Should -Contain 'PureStorage.FlashBlade.Connection' + [object]::ReferenceEquals($copy, $fake) | Should -BeFalse + } + } + + It 'repoints both caches at the copy' { + InModuleScope PureStorageFlashBladePowerShell { + $fake = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example' + ApiVersion = '2.26' + } + $originalArrays = $script:PfbArrays + $originalDefault = $script:PfbDefaultArray + try { + $script:PfbArrays = @{ 'fb.example' = $fake } + $script:PfbDefaultArray = $fake + $copy = Copy-PfbConnection -Array $fake + Update-PfbConnectionCache -Array $copy + [object]::ReferenceEquals($script:PfbArrays['fb.example'], $copy) | Should -BeTrue + [object]::ReferenceEquals($script:PfbDefaultArray, $copy) | Should -BeTrue + } + finally { + # param()-passed restore: .GetNewClosure() against a module scope fails under + # StrictMode and silently leaks state. + & { param($a, $d) $script:PfbArrays = $a; $script:PfbDefaultArray = $d } $originalArrays $originalDefault + } + } + } + + It 'leaves a different endpoint in the cache alone' { + InModuleScope PureStorageFlashBladePowerShell { + $fake = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example' + ApiVersion = '2.26' + } + $originalArrays = $script:PfbArrays + $originalDefault = $script:PfbDefaultArray + try { + $other = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'other.example' } + $script:PfbArrays = @{ 'other.example' = $other } + $script:PfbDefaultArray = $other + Update-PfbConnectionCache -Array (Copy-PfbConnection -Array $fake) + [object]::ReferenceEquals($script:PfbDefaultArray, $other) | Should -BeTrue + $script:PfbArrays.ContainsKey('fb.example') | Should -BeFalse + } + finally { + & { param($a, $d) $script:PfbArrays = $a; $script:PfbDefaultArray = $d } $originalArrays $originalDefault + } + } + } + + It 'does not repoint any cache from Copy-PfbConnection itself' { + InModuleScope PureStorageFlashBladePowerShell { + $fake = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example' + ApiVersion = '2.26' + } + $originalArrays = $script:PfbArrays + $originalDefault = $script:PfbDefaultArray + try { + $script:PfbArrays = @{ 'fb.example' = $fake } + $script:PfbDefaultArray = $fake + $copy = Copy-PfbConnection -Array $fake + [object]::ReferenceEquals($script:PfbArrays['fb.example'], $fake) | Should -BeTrue + [object]::ReferenceEquals($script:PfbDefaultArray, $fake) | Should -BeTrue + $copy | Should -Not -BeNullOrEmpty + } + finally { + & { param($a, $d) $script:PfbArrays = $a; $script:PfbDefaultArray = $d } $originalArrays $originalDefault + } + } + } +} + +Describe 'Connect-PfbArray context properties' { + It 'declares the three context parameters' { + $cmd = Get-Command Connect-PfbArray + foreach ($p in 'Context', 'Kind', 'AllArrays') { $cmd.Parameters.Keys | Should -Contain $p } + } + + It 'constrains -Kind to the three valid context kinds' { + $cmd = Get-Command Connect-PfbArray + $validate = $cmd.Parameters['Kind'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $validate | Should -Not -BeNullOrEmpty + $validate.ValidValues | Should -Be @('Array', 'Fleet', 'TopologyGroup') + } + + It 'initializes the three context state properties on the connection object' { + $source = Get-Content -Path $script:ConnectSourcePath -Raw + foreach ($prop in 'DefaultContext', 'ContextOverride', 'AuthorizationModel') { + $source | Should -Match "(?m)^\s+$prop\s+=\s+\`$null\s*$" + } + } +} From 880830b3d42d04d05871fa657fc9e6ed3184df7c Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 12:30:19 -0700 Subject: [PATCH 05/53] Remove shadowed Kind/Form constants, add ValidateSet drift meta-test $script:PfbContextKinds and $script:PfbContextForms were never read: the live vocabulary is the ValidateSet literals on the Kind/Form parameters. Editing the constants changed no behavior, making them a fake source of truth. ValidateSet cannot take a variable, so the literals must stay duplicated. A new meta-test discovers every Kind/Form ValidateSet by parsing the AST of all .ps1 under Private/ and Public/, classifies each set by its own contents ('Fleet' => Kind, 'AllArrays' => Form), and asserts each category agrees. It also asserts at least one site of each was found, so a no-match scan cannot pass silently. New sites added by later tasks are covered with no edit to the test. Co-Authored-By: Claude Opus 5 --- Private/PfbContext.ps1 | 5 ++- Tests/PfbContext.Tests.ps1 | 80 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/Private/PfbContext.ps1 b/Private/PfbContext.ps1 index 7ac53a47..6b000daf 100644 --- a/Private/PfbContext.ps1 +++ b/Private/PfbContext.ps1 @@ -1,8 +1,9 @@ # The Fusion context object. See docs/design/fusion-context-phase-1-spec.md section 1 for # why Kind is per-entry and Form is an enum rather than two booleans. -$script:PfbContextKinds = @('Array', 'Fleet', 'TopologyGroup') -$script:PfbContextForms = @('Object', 'AllArrays') +# The Kind/Form vocabularies live only in the ValidateSet literals below (and on the +# public cmdlets that surface them), because ValidateSet cannot take a variable. A +# meta-test in Tests/PfbContext.Tests.ps1 asserts every site agrees. $script:PfbAllArraysSuffix = '.arrays' # case-sensitive on the wire; measured function New-PfbContextEntry { diff --git a/Tests/PfbContext.Tests.ps1 b/Tests/PfbContext.Tests.ps1 index 09a86082..8f329a85 100644 --- a/Tests/PfbContext.Tests.ps1 +++ b/Tests/PfbContext.Tests.ps1 @@ -92,3 +92,83 @@ Describe 'PfbContext object' { } } } + +Describe 'PfbContext Kind/Form ValidateSet vocabulary' { + # ValidateSet cannot take a variable in PowerShell, so the Kind and Form vocabularies + # must be duplicated as literals at every parameter that surfaces them (2 private sites + # today, plus one per public cmdlet added later). This meta-test is what keeps those + # copies honest, and it must cover new sites with no edit here -- so it discovers them + # by parsing the module's sources rather than from any hardcoded list of files or + # functions. + # + # Scope: every .ps1 under Private/ and Public/ -- i.e. all shipped module source. + # Tests/ and tools/ are excluded: they are not loaded by the module and a fixture or + # generator is allowed its own unrelated sets. + # + # Discriminator: the module has many unrelated ValidateSets, so each discovered set is + # classified by its own contents, not by the file or parameter name (a parameter could + # be renamed, and a single file can legitimately hold both a Kind and a Form site). + # A set containing 'Fleet' is a Kind set; a set containing 'AllArrays' is a Form set. + # Those two tokens appear in no other vocabulary in the module, and classifying + # per-attribute means a file holding both is handled naturally. Any set matching + # neither token is ignored. + BeforeAll { + $moduleRoot = (Resolve-Path "$PSScriptRoot/..").Path + $sourceFiles = @( + 'Private', 'Public' | ForEach-Object { + $dir = Join-Path $moduleRoot $_ + if (Test-Path $dir) { Get-ChildItem -LiteralPath $dir -Filter '*.ps1' -Recurse -File } + } + ) + + $script:KindSites = @() + $script:FormSites = @() + + foreach ($file in $sourceFiles) { + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $file.FullName, [ref]$tokens, [ref]$errors) + if (-not $ast) { continue } + + $attrs = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.AttributeAst] -and + $n.TypeName.Name -match '^ValidateSet(Attribute)?$' + }, $true) + + foreach ($attr in $attrs) { + $values = @( + $attr.PositionalArguments | + Where-Object { $_ -is [System.Management.Automation.Language.StringConstantExpressionAst] } | + ForEach-Object { $_.Value } + ) + if ($values.Count -eq 0) { continue } + $site = [PSCustomObject]@{ + File = $file.FullName.Substring($moduleRoot.Length).TrimStart('\', '/') + Line = $attr.Extent.StartLineNumber + Values = $values + } + if ($values -contains 'Fleet') { $script:KindSites += $site } + elseif ($values -contains 'AllArrays') { $script:FormSites += $site } + } + } + } + + It 'finds at least one Kind site and one Form site (a silent no-match scan would assert nothing)' { + # Mandatory guard: without it, a scanner that matched nothing would pass forever. + @($script:KindSites).Count | Should -BeGreaterThan 0 -Because 'the scan must actually locate the Kind ValidateSet literals' + @($script:FormSites).Count | Should -BeGreaterThan 0 -Because 'the scan must actually locate the Form ValidateSet literals' + } + + It 'has every Kind ValidateSet in agreement' { + $distinct = @($script:KindSites | ForEach-Object { ($_.Values | Sort-Object) -join ',' } | Sort-Object -Unique) + $detail = ($script:KindSites | ForEach-Object { "$($_.File):$($_.Line) => $(($_.Values | Sort-Object) -join ',')" }) -join "`n" + @($distinct).Count | Should -Be 1 -Because "all Kind ValidateSets must list the same values:`n$detail" + } + + It 'has every Form ValidateSet in agreement' { + $distinct = @($script:FormSites | ForEach-Object { ($_.Values | Sort-Object) -join ',' } | Sort-Object -Unique) + $detail = ($script:FormSites | ForEach-Object { "$($_.File):$($_.Line) => $(($_.Values | Sort-Object) -join ',')" }) -join "`n" + @($distinct).Count | Should -Be 1 -Because "all Form ValidateSets must list the same values:`n$detail" + } +} From ad23a6c4e585831bf8608d08739dffd144ad199e Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 12:41:59 -0700 Subject: [PATCH 06/53] fix(context): validate context composition before authentication and cover -Context behaviourally --- Public/Connection/Connect-PfbArray.ps1 | 28 +++++--- Tests/Connect-PfbArray.Context.Tests.ps1 | 87 +++++++++++++++++++++--- 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index 34ead1fa..d955964b 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -170,6 +170,20 @@ function Connect-PfbArray { # validation bypass, which is a separate concern. Set-PfbTlsProtocol + # Context composition is a pure parameter check -- no network, no $connection. Validated + # here so a bad -Kind/-AllArrays pair fails before a login is attempted and before any + # cache is repointed. The name itself is NOT resolved locally: the wire rejects a bad one + # loudly and verbatim on first use (spec section 9). + $contextRequested = $PSBoundParameters.ContainsKey('Context') + $contextEntries = @() + if ($contextRequested) { + $form = if ($AllArrays) { 'AllArrays' } else { 'Object' } + # The @(...) wrapper is load-bearing: without it a call emitting nothing assigns $null + # instead of an empty array, collapsing explicit-empty into unset. + $contextEntries = @(ConvertTo-PfbContextEntryList -Name $Context -Kind $Kind -Form $form) + foreach ($entry in $contextEntries) { Assert-PfbContextEntryComposition -Entry $entry } + } + # Handle SSL bypass if ($IgnoreCertificateError) { Set-PfbCertificatePolicy @@ -472,14 +486,12 @@ function Connect-PfbArray { $script:PfbDefaultArray = $connection $script:PfbArrays[$Endpoint] = $connection - # A context supplied at connect is the durable session default. Composition is validated - # locally; no network call resolves the name -- the wire rejects a bad one loudly and - # verbatim on first use (spec section 9). - if ($PSBoundParameters.ContainsKey('Context')) { - $form = if ($AllArrays) { 'AllArrays' } else { 'Object' } - $entries = ConvertTo-PfbContextEntryList -Name $Context -Kind $Kind -Form $form - foreach ($entry in $entries) { Assert-PfbContextEntryComposition -Entry $entry } - $connection.DefaultContext = New-PfbContext -Entries $entries + # A context supplied at connect is the durable session default. Already validated above, + # before authentication. The gate is $contextRequested -- never a truthiness or $null test + # on $contextEntries, which is what keeps $null (unset) distinct from @() (explicit + # no-context). + if ($contextRequested) { + $connection.DefaultContext = New-PfbContext -Entries $contextEntries } return $connection diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index 7b4882cb..1ab1b397 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -2,11 +2,6 @@ BeforeAll { Import-Module "$PSScriptRoot/../PureStorageFlashBladePowerShell.psd1" -Force - - # Source path used by the declaration tests below. The three context properties live in a - # [PSCustomObject]@{} literal that only a real connect could produce, so the presence of - # the state is asserted against the source rather than against a live array. - $script:ConnectSourcePath = Join-Path $PSScriptRoot '../Public/Connection/Connect-PfbArray.ps1' } Describe 'connection context state' { @@ -88,10 +83,9 @@ Describe 'connection context state' { try { $script:PfbArrays = @{ 'fb.example' = $fake } $script:PfbDefaultArray = $fake - $copy = Copy-PfbConnection -Array $fake + Copy-PfbConnection -Array $fake | Out-Null [object]::ReferenceEquals($script:PfbArrays['fb.example'], $fake) | Should -BeTrue [object]::ReferenceEquals($script:PfbDefaultArray, $fake) | Should -BeTrue - $copy | Should -Not -BeNullOrEmpty } finally { & { param($a, $d) $script:PfbArrays = $a; $script:PfbDefaultArray = $d } $originalArrays $originalDefault @@ -114,10 +108,83 @@ Describe 'Connect-PfbArray context properties' { $validate.ValidValues | Should -Be @('Array', 'Fleet', 'TopologyGroup') } - It 'initializes the three context state properties on the connection object' { - $source = Get-Content -Path $script:ConnectSourcePath -Raw +} + +Describe 'Connect-PfbArray -Context behaviour' { + # Fully mocked connect -- no array involved. Same harness as + # Tests/Connect-PfbArray.CapabilityMapStaleness.Tests.ps1, which proves a real connection + # object can be produced from mocks alone. + BeforeEach { + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'tok' } } + } + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + [PSCustomObject]@{ versions = @('2.26') } + } -ParameterFilter { $Uri -like '*api_version*' } + Mock -ModuleName PureStorageFlashBladePowerShell Get-PfbCapabilityMap { + [PSCustomObject]@{ schemaVersion = 2; generatedFrom = @('2.0', '2.26') } + } + } + + It 'exposes the three context state properties on the connection object' { + $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' foreach ($prop in 'DefaultContext', 'ContextOverride', 'AuthorizationModel') { - $source | Should -Match "(?m)^\s+$prop\s+=\s+\`$null\s*$" + $conn.PSObject.Properties.Name | Should -Contain $prop + } + } + + It 'leaves DefaultContext at $null -- not an empty list -- when no -Context is supplied' { + $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' + # -BeNullOrEmpty cannot tell $null (unset) from @() (explicit no-context); that + # distinction is the whole point of the tri-state, so test the reference directly. + $null -eq $conn.DefaultContext | Should -BeTrue + $null -eq $conn.ContextOverride | Should -BeTrue + $null -eq $conn.AuthorizationModel | Should -BeTrue + } + + It 'stores a single Array/Object entry for -Context with no -Kind or -AllArrays' { + $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context 'FB-B' + $null -eq $conn.DefaultContext | Should -BeFalse + @($conn.DefaultContext.Entries).Count | Should -Be 1 + $conn.DefaultContext.Entries[0].Name | Should -Be 'FB-B' + $conn.DefaultContext.Entries[0].Kind | Should -Be 'Array' + $conn.DefaultContext.Entries[0].Form | Should -Be 'Object' + } + + It 'maps -AllArrays to the AllArrays form' { + $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context 'flt' -Kind Fleet -AllArrays + @($conn.DefaultContext.Entries).Count | Should -Be 1 + $conn.DefaultContext.Entries[0].Kind | Should -Be 'Fleet' + $conn.DefaultContext.Entries[0].Form | Should -Be 'AllArrays' + } + + It 'rejects an invalid Kind/Form composition' { + # -Context/-Kind/-AllArrays are all optional, so Should -Throw cannot trigger a + # mandatory-parameter prompt here. + { Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context 'g' -Kind TopologyGroup } | + Should -Throw -ExpectedMessage '*.arrays*' + } + + It 'does not repoint either cache when the composition is invalid' { + # Validation must happen BEFORE authentication and before the caches are repointed. + # Otherwise the caller gets an error while every later context-less cmdlet silently + # succeeds against an array with none of the targeting they asked for. + InModuleScope PureStorageFlashBladePowerShell { + $originalArrays = $script:PfbArrays + $originalDefault = $script:PfbDefaultArray + try { + $sentinel = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'sentinel' } + $script:PfbArrays = @{ 'sentinel' = $sentinel } + $script:PfbDefaultArray = $sentinel + + { Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context 'g' -Kind TopologyGroup } | Should -Throw + + [object]::ReferenceEquals($script:PfbDefaultArray, $sentinel) | Should -BeTrue + $script:PfbArrays.ContainsKey('fb.test') | Should -BeFalse + } + finally { + & { param($a, $d) $script:PfbArrays = $a; $script:PfbDefaultArray = $d } $originalArrays $originalDefault + } } } } From f4b816dd15c40293d912b33ba19123391d5979e1 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 13:12:42 -0700 Subject: [PATCH 07/53] feat(context): add Set-PfbContext and Clear-PfbContext (copy-on-write) Both cmdlets return a NEW connection object rather than mutating the caller's, and make no network call. Set-PfbContext accumulates piped names across process{} and emits exactly one connection in end{}, so N piped fleet members yield one union-scoped connection instead of N copies that each lose the others' entries. A missing -Context raises an explicit throw rather than using [Parameter(Mandatory)], which would prompt and hang under -NonInteractive. Also, from review: - Fix the Kind/Form ValidateSet drift meta-test's discriminator. It classified each discovered site by its CONTENTS ('Fleet' => Kind, 'AllArrays' => Form), which silently left a site that DROPPED a token unclassified and therefore never compared -- missing exactly the drift the test exists to catch. It now classifies by the name of the parameter the attribute decorates, read from the AST. The scan stays fully dynamic, still asserts it located at least one site of each kind, and still compares Kind only against Kind. - Add [ValidateNotNull()] to Connect-PfbArray's -Context. $null previously flowed into ConvertTo-PfbContextEntryList -Name, which is [Parameter(Mandatory)] -- the interactive-prompt/hang risk. Not added to Set-PfbContext's -Context: that is the ValueFromPipeline slot, where $null must keep falling through to the friendly throw. Co-Authored-By: Claude Opus 5 --- Public/Connection/Connect-PfbArray.ps1 | 5 ++ Public/Context/Clear-PfbContext.ps1 | 26 ++++++++ Public/Context/Set-PfbContext.ps1 | 85 ++++++++++++++++++++++++ PureStorageFlashBladePowerShell.psd1 | 2 + Tests/Clear-PfbContext.Tests.ps1 | 18 +++++ Tests/Connect-PfbArray.Context.Tests.ps1 | 12 ++++ Tests/PfbContext.Tests.ps1 | 44 ++++++++---- Tests/Set-PfbContext.Tests.ps1 | 72 ++++++++++++++++++++ 8 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 Public/Context/Clear-PfbContext.ps1 create mode 100644 Public/Context/Set-PfbContext.ps1 create mode 100644 Tests/Clear-PfbContext.Tests.ps1 create mode 100644 Tests/Set-PfbContext.Tests.ps1 diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index d955964b..3371138c 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -155,7 +155,12 @@ function Connect-PfbArray { [Parameter()] [int]$HttpTimeout = 30000, + # ValidateNotNull, not ValidateNotNullOrEmpty: @() must stay bindable so an explicit + # empty context remains distinguishable from an unset one. $null, however, would flow + # into ConvertTo-PfbContextEntryList -Name $null, whose -Name is Mandatory -- which + # prompts and hangs under -NonInteractive instead of failing. [Parameter()] + [ValidateNotNull()] [string[]]$Context, [Parameter()] diff --git a/Public/Context/Clear-PfbContext.ps1 b/Public/Context/Clear-PfbContext.ps1 new file mode 100644 index 00000000..1caea773 --- /dev/null +++ b/Public/Context/Clear-PfbContext.ps1 @@ -0,0 +1,26 @@ +function Clear-PfbContext { + <# + .SYNOPSIS + Removes the durable Fusion context from a connection, returning a NEW connection. + .DESCRIPTION + Its own cmdlet rather than a -Clear switch, matching the Set-/Clear-PfbCredential + precedent, and because @() must keep its distinct "run this one call locally" meaning + at the Invoke-PfbInContext layer. Copy-on-write, like Set-PfbContext. No network call. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [PSCustomObject]$Array + ) + + $target = if ($Array) { $Array } else { $script:PfbDefaultArray } + if (-not $target) { + throw "Clear-PfbContext requires a connection: pass -Array, or connect first with Connect-PfbArray." + } + + $copy = Copy-PfbConnection -Array $target + $copy.DefaultContext = $null + Update-PfbConnectionCache -Array $copy + $copy +} diff --git a/Public/Context/Set-PfbContext.ps1 b/Public/Context/Set-PfbContext.ps1 new file mode 100644 index 00000000..6b2c9e0c --- /dev/null +++ b/Public/Context/Set-PfbContext.ps1 @@ -0,0 +1,85 @@ +function Set-PfbContext { + <# + .SYNOPSIS + Sets the durable Fusion context on a connection, returning a NEW connection object. + .DESCRIPTION + Copy-on-write: the caller's connection is never mutated, so a helper frame, an outer + scope, or a loop iteration holding the old object keeps its original scope. Only the + caller capturing the return value sees the change. The output IS the effect -- there + is no -PassThru. + + No network call is made. The context name is not resolved: the wire rejects a bad one + loudly and verbatim on first use (code 42, quoting the offending value), so validating + here would buy only failing one call earlier at the cost of a hidden round trip. + Composition IS validated locally -- see section 9 of the design. + .NOTES + Mixed-platform fleets: Get-PfbFleetMember will happily return FlashArrays. Piping + those in is not supported -- cross-platform context is a non-goal (open question 5). + .EXAMPLE + $fb = Get-PfbFleetMember -FleetName 'fleet-prod' | Set-PfbContext + .EXAMPLE + $fb = Get-PfbFleet | Set-PfbContext -AllArrays + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + # Ordinary parameter, NOT the pipeline slot: the pipeline belongs to -Context so + # Get-PfbFleetMember | Set-PfbContext works. Defaults to the current default + # connection. + [Parameter()] + [PSCustomObject]$Array, + + # Deliberately NO [ValidateNotNull()] here, unlike Connect-PfbArray's -Context: this + # is the ValueFromPipeline slot, and $null | Set-PfbContext must fall through to the + # friendly explicit throw in end{} rather than surface a raw binding error. + [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('MemberName', 'Name')] + [string[]]$Context, + + [Parameter()] + [ValidateSet('Array', 'Fleet', 'TopologyGroup')] + [string]$Kind = 'Array', + + [Parameter()] + [switch]$AllArrays, + + # Tri-state, reserved for Phase 2. Accepted and stored; nothing is injected for it. + [Parameter()] + [switch]$AllowErrors + ) + + begin { + # Accumulate across process{} and emit ONE connection in end{}. Emitting per-item + # would yield N connection objects for N piped members, each a copy of the same + # original -- so N-1 of them silently lose the others' entries. + $names = [System.Collections.Generic.List[string]]::new() + } + + process { + if ($Context) { foreach ($name in $Context) { $names.Add($name) } } + } + + end { + # An explicit throw, not [Parameter(Mandatory)]: a mandatory parameter prompts and + # hangs under -NonInteractive, which is how CI and every test run invokes this. + if ($names.Count -eq 0) { + throw "Set-PfbContext requires -Context (or piped input binding to it). To remove a context, use Clear-PfbContext." + } + + $target = if ($Array) { $Array } else { $script:PfbDefaultArray } + if (-not $target) { + throw "Set-PfbContext requires a connection: pass -Array, or connect first with Connect-PfbArray." + } + + $form = if ($AllArrays) { 'AllArrays' } else { 'Object' } + $entries = ConvertTo-PfbContextEntryList -Name $names.ToArray() -Kind $Kind -Form $form + foreach ($entry in $entries) { Assert-PfbContextEntryComposition -Entry $entry } + + $allowErrors = if ($PSBoundParameters.ContainsKey('AllowErrors')) { [bool]$AllowErrors } else { $null } + + $copy = Copy-PfbConnection -Array $target + $copy.DefaultContext = New-PfbContext -Entries $entries -AllowErrors $allowErrors + Update-PfbConnectionCache -Array $copy + $copy + } +} diff --git a/PureStorageFlashBladePowerShell.psd1 b/PureStorageFlashBladePowerShell.psd1 index e149bf6d..3aa5adba 100644 --- a/PureStorageFlashBladePowerShell.psd1 +++ b/PureStorageFlashBladePowerShell.psd1 @@ -9,6 +9,7 @@ PowerShellVersion = '5.1' FunctionsToExport = @( + 'Clear-PfbContext', 'Clear-PfbCredential', 'Connect-PfbArray', 'Disconnect-PfbArray', @@ -413,6 +414,7 @@ 'Remove-PfbTarget', 'Remove-PfbTlsPolicy', 'Remove-PfbWormPolicy', + 'Set-PfbContext', 'Set-PfbCredential', 'Test-PfbActiveDirectory', 'Test-PfbAlertWatcher', diff --git a/Tests/Clear-PfbContext.Tests.ps1 b/Tests/Clear-PfbContext.Tests.ps1 new file mode 100644 index 00000000..7b91c480 --- /dev/null +++ b/Tests/Clear-PfbContext.Tests.ps1 @@ -0,0 +1,18 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +BeforeAll { + Import-Module "$PSScriptRoot/../PureStorageFlashBladePowerShell.psd1" -Force +} + +Describe 'Clear-PfbContext' { + It 'returns a new connection with no default context, original untouched' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'fb.example' + DefaultContext = ([PSCustomObject]@{ Entries = @([PSCustomObject]@{ Name = 'FB-B'; Kind = 'Array'; Form = 'Object' }); AllowErrors = $null }) + ContextOverride = $null + } + $new = Clear-PfbContext -Array $fb + $new.DefaultContext | Should -BeNullOrEmpty + $fb.DefaultContext.Entries | Should -Not -BeNullOrEmpty + } +} diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index 1ab1b397..970ca447 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -133,6 +133,18 @@ Describe 'Connect-PfbArray -Context behaviour' { } } + It 'rejects -Context $null at the binder, naming -Context, rather than reaching a mandatory downstream parameter' { + # Without [ValidateNotNull()] on -Context, $null flows into + # ConvertTo-PfbContextEntryList -Name $null, whose -Name is [Parameter(Mandatory)] -- + # the interactive-prompt/hang risk under -NonInteractive. Asserting on the parameter + # NAME is what makes this discriminating: unvalidated, the failure surfaces as + # "Cannot bind argument to parameter 'Name'"; validated, it is "Cannot validate + # argument on parameter 'Context'". -Context is optional, so Should -Throw here + # cannot itself trigger a prompt. + { Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context $null } | + Should -Throw -ExpectedMessage "*parameter 'Context'*" + } + It 'leaves DefaultContext at $null -- not an empty list -- when no -Context is supplied' { $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' # -BeNullOrEmpty cannot tell $null (unset) from @() (explicit no-context); that diff --git a/Tests/PfbContext.Tests.ps1 b/Tests/PfbContext.Tests.ps1 index 8f329a85..b115bc1b 100644 --- a/Tests/PfbContext.Tests.ps1 +++ b/Tests/PfbContext.Tests.ps1 @@ -106,12 +106,18 @@ Describe 'PfbContext Kind/Form ValidateSet vocabulary' { # generator is allowed its own unrelated sets. # # Discriminator: the module has many unrelated ValidateSets, so each discovered set is - # classified by its own contents, not by the file or parameter name (a parameter could - # be renamed, and a single file can legitimately hold both a Kind and a Form site). - # A set containing 'Fleet' is a Kind set; a set containing 'AllArrays' is a Form set. - # Those two tokens appear in no other vocabulary in the module, and classifying - # per-attribute means a file holding both is handled naturally. Any set matching - # neither token is ignored. + # classified by the NAME OF THE PARAMETER IT DECORATES -- $Kind is a Kind site, $Form is + # a Form site -- read from the AST by walking the attribute up to its parent parameter. + # Classifying per-attribute (rather than per-file) means a file holding both a Kind and a + # Form site is handled naturally. + # + # It deliberately does NOT classify by contents. A contents-based rule ('Fleet' => Kind, + # 'AllArrays' => Form) has a hole that defeats the whole point of the test: a site that + # DROPS a token, e.g. ValidateSet('Array','TopologyGroup'), matches neither marker, is + # left unclassified, and is therefore never compared -- silently missing exactly the drift + # this test exists to catch. Verified at the time of writing: no unrelated parameter named + # Kind or Form anywhere under Private/ or Public/ carries a ValidateSet, so parameter name + # is an exact discriminator here. Any parameter with another name is ignored. BeforeAll { $moduleRoot = (Resolve-Path "$PSScriptRoot/..").Path $sourceFiles = @( @@ -143,13 +149,25 @@ Describe 'PfbContext Kind/Form ValidateSet vocabulary' { ForEach-Object { $_.Value } ) if ($values.Count -eq 0) { continue } + + # Walk up to the parameter this attribute decorates and take its variable + # name. The immediate parent is normally the ParameterAst, but walk the chain + # so a nested arrangement cannot silently drop the site. + $node = $attr.Parent + while ($node -and -not ($node -is [System.Management.Automation.Language.ParameterAst])) { + $node = $node.Parent + } + if (-not $node) { continue } + $paramName = $node.Name.VariablePath.UserPath + $site = [PSCustomObject]@{ - File = $file.FullName.Substring($moduleRoot.Length).TrimStart('\', '/') - Line = $attr.Extent.StartLineNumber - Values = $values + File = $file.FullName.Substring($moduleRoot.Length).TrimStart('\', '/') + Line = $attr.Extent.StartLineNumber + Parameter = $paramName + Values = $values } - if ($values -contains 'Fleet') { $script:KindSites += $site } - elseif ($values -contains 'AllArrays') { $script:FormSites += $site } + if ($paramName -eq 'Kind') { $script:KindSites += $site } + elseif ($paramName -eq 'Form') { $script:FormSites += $site } } } } @@ -162,13 +180,13 @@ Describe 'PfbContext Kind/Form ValidateSet vocabulary' { It 'has every Kind ValidateSet in agreement' { $distinct = @($script:KindSites | ForEach-Object { ($_.Values | Sort-Object) -join ',' } | Sort-Object -Unique) - $detail = ($script:KindSites | ForEach-Object { "$($_.File):$($_.Line) => $(($_.Values | Sort-Object) -join ',')" }) -join "`n" + $detail = ($script:KindSites | ForEach-Object { "$($_.File):$($_.Line) `$$($_.Parameter) => $(($_.Values | Sort-Object) -join ',')" }) -join "`n" @($distinct).Count | Should -Be 1 -Because "all Kind ValidateSets must list the same values:`n$detail" } It 'has every Form ValidateSet in agreement' { $distinct = @($script:FormSites | ForEach-Object { ($_.Values | Sort-Object) -join ',' } | Sort-Object -Unique) - $detail = ($script:FormSites | ForEach-Object { "$($_.File):$($_.Line) => $(($_.Values | Sort-Object) -join ',')" }) -join "`n" + $detail = ($script:FormSites | ForEach-Object { "$($_.File):$($_.Line) `$$($_.Parameter) => $(($_.Values | Sort-Object) -join ',')" }) -join "`n" @($distinct).Count | Should -Be 1 -Because "all Form ValidateSets must list the same values:`n$detail" } } diff --git a/Tests/Set-PfbContext.Tests.ps1 b/Tests/Set-PfbContext.Tests.ps1 new file mode 100644 index 00000000..e3f4cc1f --- /dev/null +++ b/Tests/Set-PfbContext.Tests.ps1 @@ -0,0 +1,72 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +BeforeAll { + Import-Module "$PSScriptRoot/../PureStorageFlashBladePowerShell.psd1" -Force +} + +Describe 'Set-PfbContext' { + BeforeEach { + $script:fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + } + } + It 'returns a new connection and leaves the original untouched' { + $new = Set-PfbContext -Array $script:fb -Context 'FB-B' + $new.DefaultContext.Entries[0].Name | Should -Be 'FB-B' + $script:fb.DefaultContext | Should -BeNullOrEmpty + [object]::ReferenceEquals($new, $script:fb) | Should -BeFalse + } + It 'emits exactly ONE connection for N piped members, scoped to the union' { + $result = @('FB-B', 'FB-C') | Set-PfbContext -Array $script:fb + @($result).Count | Should -Be 1 + @($result.DefaultContext.Entries).Count | Should -Be 2 + $result.DefaultContext.Entries.Name | Should -Be @('FB-B', 'FB-C') + } + It 'binds MemberName by property name, so Get-PfbFleetMember pipes straight in' { + $piped = @( + [PSCustomObject]@{ MemberName = 'FB-B' }, + [PSCustomObject]@{ MemberName = 'FB-C' } + ) + $result = $piped | Set-PfbContext -Array $script:fb + $result.DefaultContext.Entries.Name | Should -Be @('FB-B', 'FB-C') + } + It 'binds Name by property name, for the eventual Get-PfbTopologyGroup contract (#38)' { + $result = [PSCustomObject]@{ Name = 'region-1' } | Set-PfbContext -Array $script:fb -Kind 'TopologyGroup' -AllArrays + $result.DefaultContext.Entries[0].Name | Should -Be 'region-1' + $result.DefaultContext.Entries[0].Form | Should -Be 'AllArrays' + } + It 'rejects Array + -AllArrays' { + { Set-PfbContext -Array $script:fb -Context 'FB-B' -AllArrays } | + Should -Throw -ExpectedMessage '*an array has no members*' + } + It 'rejects TopologyGroup without -AllArrays' { + { Set-PfbContext -Array $script:fb -Context 'region-1' -Kind 'TopologyGroup' } | + Should -Throw -ExpectedMessage '*.arrays*' + } + It 'throws its own error for a missing -Context rather than prompting' { + # NOT [Parameter(Mandatory)] + Should -Throw: that hangs on the interactive prompt + # under -NonInteractive. + { Set-PfbContext -Array $script:fb } | Should -Throw -ExpectedMessage '*-Context*' + } + It 'makes no network call' { + Mock -CommandName Invoke-PfbApiRequest -ModuleName 'PureStorageFlashBladePowerShell' -MockWith {} + Mock -CommandName Invoke-RestMethod -ModuleName 'PureStorageFlashBladePowerShell' -MockWith {} + Set-PfbContext -Array $script:fb -Context 'no-such-array-at-all' | Out-Null + Should -Invoke -CommandName Invoke-PfbApiRequest -ModuleName 'PureStorageFlashBladePowerShell' -Times 0 + Should -Invoke -CommandName Invoke-RestMethod -ModuleName 'PureStorageFlashBladePowerShell' -Times 0 + } + It 'repoints the module caches at the copy' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $originalArrays = $script:PfbArrays; $originalDefault = $script:PfbDefaultArray + try { + $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null } + $script:PfbArrays = @{ 'fb.example' = $fb }; $script:PfbDefaultArray = $fb + $new = Set-PfbContext -Array $fb -Context 'FB-B' + [object]::ReferenceEquals($script:PfbDefaultArray, $new) | Should -BeTrue + } + finally { & { param($a, $d) $script:PfbArrays = $a; $script:PfbDefaultArray = $d } $originalArrays $originalDefault } + } + } +} From ce5df0df58bf8877a84dd8aad79a8b24f988728e Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 13:31:57 -0700 Subject: [PATCH 08/53] refactor(context): extract Resolve-PfbContextForm; tighten context tests Review fixes for Set-PfbContext/Clear-PfbContext. Important 1 -- the switch->Form mapping `if ($AllArrays) { 'AllArrays' } else { 'Object' }` existed in both Connect-PfbArray and Set-PfbContext with no test over either copy. Extracted to a private Resolve-PfbContextForm beside the other context primitives and called from both, so adding a third Form value cannot update one copy and silently leave the other emitting 'Object'. No ValidateSet on it -- it takes a switch, so it adds no site to the vocabulary drift scan. One test per branch. Verified both call sites actually route through it by mutating the helper to return 'Object' unconditionally: the -AllArrays tests of BOTH cmdlets went red. Important 2 -- a comment stating a falsified claim Connect-PfbArray's -Context comment claimed $null into a mandatory parameter "prompts and hangs under -NonInteractive". Measurement disproved that: PowerShell prompts only when the argument is ABSENT; an explicitly-supplied $null binds and is then rejected. Rewritten to the true rationale -- the guard is about error ATTRIBUTION, moving the failure to the binder naming -Context instead of a downstream -Name the caller never typed. The ValidateNotNull-vs-NotNullOrEmpty sentence is kept. Same correction applied to the matching test comment. Important 3 -- the tri-state assertion Clear-PfbContext's test used Should -BeNullOrEmpty, which passes for $null, for @(), and for a context object with empty Entries -- so a change emitting @() would not red, even though @() has a different documented meaning at the Invoke-PfbInContext layer. Now asserts $null -eq directly, matching the idiom in the Connect-PfbArray tests. Also: - Comment on Set-PfbContext's `if ($Context)` recording that the truthiness test is deliberate: $null -ne $Context would admit '' and mint an entry with an empty name. Behaviour unchanged. - Cache-repointing test for Clear-PfbContext, closing the asymmetry with Set-PfbContext's equivalent. - Test that Connect-PfbArray -Context @() yields a NON-null DefaultContext with zero entries -- the headline tri-state at the connect layer, and the regression guard proving the new ValidateNotNull still admits @(). Co-Authored-By: Claude Opus 5 --- Private/PfbContext.ps1 | 12 ++++++++++++ Public/Connection/Connect-PfbArray.ps1 | 11 +++++++---- Public/Context/Set-PfbContext.ps1 | 5 ++++- Tests/Clear-PfbContext.Tests.ps1 | 23 +++++++++++++++++++++- Tests/Connect-PfbArray.Context.Tests.ps1 | 25 +++++++++++++++++------- Tests/PfbContext.Tests.ps1 | 10 ++++++++++ 6 files changed, 73 insertions(+), 13 deletions(-) diff --git a/Private/PfbContext.ps1 b/Private/PfbContext.ps1 index 6b000daf..8fac71d2 100644 --- a/Private/PfbContext.ps1 +++ b/Private/PfbContext.ps1 @@ -17,6 +17,18 @@ function New-PfbContextEntry { [PSCustomObject]@{ Name = $Name; Kind = $Kind; Form = $Form } } +# The single place the -AllArrays switch becomes a Form token. Every public cmdlet surfacing +# context spells Form as a switch rather than a $Form parameter, so without this the mapping +# gets copy-pasted per cmdlet and a third Form value would update some copies and not others -- +# the exact drift the ValidateSet meta-test guards for the vocabulary itself. Deliberately NO +# ValidateSet here: it takes a switch, so it adds no site to that scan. +function Resolve-PfbContextForm { + [CmdletBinding()] + [OutputType([string])] + param([switch]$AllArrays) + if ($AllArrays) { 'AllArrays' } else { 'Object' } +} + function New-PfbContext { [CmdletBinding()] [OutputType([PSCustomObject])] diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index 3371138c..095aa7b1 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -156,9 +156,12 @@ function Connect-PfbArray { [int]$HttpTimeout = 30000, # ValidateNotNull, not ValidateNotNullOrEmpty: @() must stay bindable so an explicit - # empty context remains distinguishable from an unset one. $null, however, would flow - # into ConvertTo-PfbContextEntryList -Name $null, whose -Name is Mandatory -- which - # prompts and hangs under -NonInteractive instead of failing. + # empty context remains distinguishable from an unset one. The guard exists for error + # ATTRIBUTION, not to prevent a hang: measured, an explicitly-supplied $null binds and + # is then rejected downstream by ConvertTo-PfbContextEntryList blaming -Name, a + # parameter the caller never typed. (PowerShell prompts for a mandatory parameter only + # when the argument is ABSENT, never when $null was passed.) Validating here fails at + # the binder naming -Context, the parameter they did type. [Parameter()] [ValidateNotNull()] [string[]]$Context, @@ -182,7 +185,7 @@ function Connect-PfbArray { $contextRequested = $PSBoundParameters.ContainsKey('Context') $contextEntries = @() if ($contextRequested) { - $form = if ($AllArrays) { 'AllArrays' } else { 'Object' } + $form = Resolve-PfbContextForm -AllArrays:$AllArrays # The @(...) wrapper is load-bearing: without it a call emitting nothing assigns $null # instead of an empty array, collapsing explicit-empty into unset. $contextEntries = @(ConvertTo-PfbContextEntryList -Name $Context -Kind $Kind -Form $form) diff --git a/Public/Context/Set-PfbContext.ps1 b/Public/Context/Set-PfbContext.ps1 index 6b2c9e0c..cbc47c98 100644 --- a/Public/Context/Set-PfbContext.ps1 +++ b/Public/Context/Set-PfbContext.ps1 @@ -56,6 +56,9 @@ function Set-PfbContext { } process { + # Truthiness is deliberate, not sloppy: it skips $null AND @() AND a piped empty + # string, all of which correctly land on the "requires -Context" throw in end{}. + # $null -ne $Context would instead let '' through and mint an entry with an empty name. if ($Context) { foreach ($name in $Context) { $names.Add($name) } } } @@ -71,7 +74,7 @@ function Set-PfbContext { throw "Set-PfbContext requires a connection: pass -Array, or connect first with Connect-PfbArray." } - $form = if ($AllArrays) { 'AllArrays' } else { 'Object' } + $form = Resolve-PfbContextForm -AllArrays:$AllArrays $entries = ConvertTo-PfbContextEntryList -Name $names.ToArray() -Kind $Kind -Form $form foreach ($entry in $entries) { Assert-PfbContextEntryComposition -Entry $entry } diff --git a/Tests/Clear-PfbContext.Tests.ps1 b/Tests/Clear-PfbContext.Tests.ps1 index 7b91c480..a5955779 100644 --- a/Tests/Clear-PfbContext.Tests.ps1 +++ b/Tests/Clear-PfbContext.Tests.ps1 @@ -12,7 +12,28 @@ Describe 'Clear-PfbContext' { ContextOverride = $null } $new = Clear-PfbContext -Array $fb - $new.DefaultContext | Should -BeNullOrEmpty + # -BeNullOrEmpty cannot tell $null (unset) from @() (explicit no-context), and @() has + # a DIFFERENT documented meaning at the Invoke-PfbInContext layer -- so a change making + # this cmdlet emit @() must red. Test the reference directly. + $null -eq $new.DefaultContext | Should -BeTrue $fb.DefaultContext.Entries | Should -Not -BeNullOrEmpty } + + It 'repoints the module caches at the copy' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $originalArrays = $script:PfbArrays; $originalDefault = $script:PfbDefaultArray + try { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'fb.example' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) + ContextOverride = $null + } + $script:PfbArrays = @{ 'fb.example' = $fb }; $script:PfbDefaultArray = $fb + $new = Clear-PfbContext -Array $fb + [object]::ReferenceEquals($script:PfbDefaultArray, $new) | Should -BeTrue + [object]::ReferenceEquals($script:PfbArrays['fb.example'], $new) | Should -BeTrue + } + finally { & { param($a, $d) $script:PfbArrays = $a; $script:PfbDefaultArray = $d } $originalArrays $originalDefault } + } + } } diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index 970ca447..0ea7048f 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -133,18 +133,29 @@ Describe 'Connect-PfbArray -Context behaviour' { } } - It 'rejects -Context $null at the binder, naming -Context, rather than reaching a mandatory downstream parameter' { + It 'rejects -Context $null at the binder, naming -Context, not a downstream parameter' { # Without [ValidateNotNull()] on -Context, $null flows into - # ConvertTo-PfbContextEntryList -Name $null, whose -Name is [Parameter(Mandatory)] -- - # the interactive-prompt/hang risk under -NonInteractive. Asserting on the parameter - # NAME is what makes this discriminating: unvalidated, the failure surfaces as - # "Cannot bind argument to parameter 'Name'"; validated, it is "Cannot validate - # argument on parameter 'Context'". -Context is optional, so Should -Throw here - # cannot itself trigger a prompt. + # ConvertTo-PfbContextEntryList -Name $null and is rejected there, blaming -Name -- a + # parameter the caller never typed. Asserting on the parameter NAME is what makes this + # discriminating: unvalidated the message is "Cannot bind argument to parameter 'Name'"; + # validated it is "Cannot validate argument on parameter 'Context'". Measured, not + # assumed. -Context is optional, so Should -Throw cannot trigger a prompt here. { Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context $null } | Should -Throw -ExpectedMessage "*parameter 'Context'*" } + It 'treats -Context @() as an explicit no-context: DefaultContext set, zero entries' { + # The headline tri-state at the connect layer -- @() is NOT the same state as unset, and + # it carries its own meaning ("run this one call locally") at the Invoke-PfbInContext + # layer. Works because ConvertTo-PfbContextEntryList's -Name carries + # [AllowEmptyCollection()]. Doubles as the regression guard proving -Context's + # [ValidateNotNull()] deliberately still admits @(): ValidateNotNullOrEmpty there would + # collapse the tri-state. + $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context @() + $null -ne $conn.DefaultContext | Should -BeTrue + @($conn.DefaultContext.Entries).Count | Should -Be 0 + } + It 'leaves DefaultContext at $null -- not an empty list -- when no -Context is supplied' { $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' # -BeNullOrEmpty cannot tell $null (unset) from @() (explicit no-context); that diff --git a/Tests/PfbContext.Tests.ps1 b/Tests/PfbContext.Tests.ps1 index b115bc1b..17c1550b 100644 --- a/Tests/PfbContext.Tests.ps1 +++ b/Tests/PfbContext.Tests.ps1 @@ -83,6 +83,16 @@ Describe 'PfbContext object' { (New-PfbContext -Entries @((New-PfbContextEntry -Name 'x'))).AllowErrors | Should -BeNullOrEmpty } } + It 'resolves the -AllArrays switch to the AllArrays form' { + InModuleScope PureStorageFlashBladePowerShell { + Resolve-PfbContextForm -AllArrays | Should -Be 'AllArrays' + } + } + It 'resolves an absent -AllArrays switch to the Object form' { + InModuleScope PureStorageFlashBladePowerShell { + Resolve-PfbContextForm | Should -Be 'Object' + } + } It 'normalises a string[] into entries of one kind' { InModuleScope PureStorageFlashBladePowerShell { $entries = ConvertTo-PfbContextEntryList -Name @('FB-B','FB-C') -Kind 'Array' -Form 'Object' From bb97dd41fa5bc2a8af7f23d4236ec1c2571e9c85 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 13:48:03 -0700 Subject: [PATCH 09/53] feat(context): add Invoke-PfbInContext with nesting and exception safety Also restores module connection-cache state after every mocked-connect test in Tests/Connect-PfbArray.Context.Tests.ps1, and adds a non-null guard so the unset-DefaultContext test cannot pass vacuously. Co-Authored-By: Claude Opus 5 --- Public/Context/Invoke-PfbInContext.ps1 | 65 ++++++++++++++++++++++++ PureStorageFlashBladePowerShell.psd1 | 1 + Tests/Connect-PfbArray.Context.Tests.ps1 | 19 +++++++ Tests/Invoke-PfbInContext.Tests.ps1 | 56 ++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 Public/Context/Invoke-PfbInContext.ps1 create mode 100644 Tests/Invoke-PfbInContext.Tests.ps1 diff --git a/Public/Context/Invoke-PfbInContext.ps1 b/Public/Context/Invoke-PfbInContext.ps1 new file mode 100644 index 00000000..f805848c --- /dev/null +++ b/Public/Context/Invoke-PfbInContext.ps1 @@ -0,0 +1,65 @@ +function Invoke-PfbInContext { + <# + .SYNOPSIS + Runs a scriptblock with an ambient, block-scoped Fusion context. + .DESCRIPTION + Nesting works with no explicit stack: each invocation captures its own $previous, so + the call stack provides push/pop discipline and an inner block restores the OUTER + value rather than clearing it. Exception-safe at every nesting level via finally. + + -Context @() is the deliberate "run this one call locally" escape hatch, and is what + unblocks an endpoint that does not support context_names while a session default is + set. It is DISTINCT from no context at all. + + Non-pipeable by design: its pipeline payload would have to be the scriptblock, which + no cmdlet emits. The blessed form is + -Context (Get-PfbFleetMember -FleetName 'x').member.name + .NOTES + Concurrency: .ContextOverride lives on the shared connection object, so concurrent + workers pushing overrides on the SAME connection race. Set the context before forking + parallel work; do not push ambient overrides from inside concurrent workers. + #> + [CmdletBinding()] + param( + # None of the three is [Parameter(Mandatory)]: a mandatory parameter prompts and hangs + # under -NonInteractive. Each is checked explicitly below instead. + [Parameter()] + [PSCustomObject]$Array, + + [Parameter()] + [AllowEmptyCollection()] + [string[]]$Context, + + [Parameter(Position = 0)] + [scriptblock]$ScriptBlock, + + [Parameter()] + [ValidateSet('Array', 'Fleet', 'TopologyGroup')] + [string]$Kind = 'Array', + + [Parameter()] + [switch]$AllArrays + ) + + if (-not $Array) { throw "Invoke-PfbInContext requires -Array: pass a connection object from Connect-PfbArray." } + if (-not $ScriptBlock) { throw "Invoke-PfbInContext requires -ScriptBlock: pass the block to run in the context." } + # -ne $null, never truthiness: @() is falsy but meaningful. + if ($null -eq $Context) { throw "Invoke-PfbInContext requires -Context. Pass @() to run the block against the local array." } + + $form = Resolve-PfbContextForm -AllArrays:$AllArrays + # @(...) around the call is load-bearing for the -Context @() escape hatch: a command that + # emits nothing assigns $null, not an empty array, which would then be rejected downstream. + $entries = @(ConvertTo-PfbContextEntryList -Name $Context -Kind $Kind -Form $form) + foreach ($entry in $entries) { Assert-PfbContextEntryComposition -Entry $entry } + + # Deliberately mutated IN PLACE on the shared connection, unlike Set-PfbContext's + # copy-on-write: the override is ambient, so a copy no caller holds would be invisible. + $previous = $Array.ContextOverride + # The [object[]] cast is load-bearing for the -Context @() escape hatch: passing an empty + # array through as a bare variable makes the binder see $null and reject the mandatory + # -Entries, in spite of its [AllowEmptyCollection()]. The cast preserves the empty + # collection. Measured on both PowerShell editions. + $Array.ContextOverride = New-PfbContext -Entries ([object[]]$entries) + try { & $ScriptBlock } + finally { $Array.ContextOverride = $previous } +} diff --git a/PureStorageFlashBladePowerShell.psd1 b/PureStorageFlashBladePowerShell.psd1 index 3aa5adba..c67f03c2 100644 --- a/PureStorageFlashBladePowerShell.psd1 +++ b/PureStorageFlashBladePowerShell.psd1 @@ -208,6 +208,7 @@ 'Get-PfbUsageUser', 'Get-PfbWormPolicy', 'Get-PfbWormPolicyMember', + 'Invoke-PfbInContext', 'Invoke-PfbNetworkPing', 'Invoke-PfbNetworkTrace', 'New-PfbActiveDirectory', diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index 0ea7048f..041aaba0 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -124,6 +124,22 @@ Describe 'Connect-PfbArray -Context behaviour' { Mock -ModuleName PureStorageFlashBladePowerShell Get-PfbCapabilityMap { [PSCustomObject]@{ schemaVersion = 2; generatedFrom = @('2.0', '2.26') } } + + # A successful mocked connect repoints $script:PfbDefaultArray and + # $script:PfbArrays['fb.test'] at a fake connection. Without a restore, that state + # outlives this file and is available to poison any later one, so it is captured here + # and restored in AfterEach for every test in this block. + $script:originalState = InModuleScope PureStorageFlashBladePowerShell { + @{ Arrays = $script:PfbArrays; Default = $script:PfbDefaultArray } + } + } + + AfterEach { + # param()-passed restore: .GetNewClosure() against a module scope fails under StrictMode + # and silently leaks state. + InModuleScope PureStorageFlashBladePowerShell -Parameters @{ state = $script:originalState } { + & { param($a, $d) $script:PfbArrays = $a; $script:PfbDefaultArray = $d } $state.Arrays $state.Default + } } It 'exposes the three context state properties on the connection object' { @@ -160,6 +176,9 @@ Describe 'Connect-PfbArray -Context behaviour' { $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' # -BeNullOrEmpty cannot tell $null (unset) from @() (explicit no-context); that # distinction is the whole point of the tri-state, so test the reference directly. + # Guard against passing vacuously: without this, a Connect-PfbArray that returned + # nothing at all would satisfy every assertion below. + $null -ne $conn | Should -BeTrue $null -eq $conn.DefaultContext | Should -BeTrue $null -eq $conn.ContextOverride | Should -BeTrue $null -eq $conn.AuthorizationModel | Should -BeTrue diff --git a/Tests/Invoke-PfbInContext.Tests.ps1 b/Tests/Invoke-PfbInContext.Tests.ps1 new file mode 100644 index 00000000..703e3cac --- /dev/null +++ b/Tests/Invoke-PfbInContext.Tests.ps1 @@ -0,0 +1,56 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +BeforeAll { + Import-Module "$PSScriptRoot/../PureStorageFlashBladePowerShell.psd1" -Force +} + +Describe 'Invoke-PfbInContext' { + BeforeEach { + $script:fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'fb.example' + DefaultContext = $null; ContextOverride = $null + } + } + It 'restores the override to UNSET after the block' { + # $null -eq, never -BeNullOrEmpty: a buggy restore that assigned @() instead of $null + # would satisfy -BeNullOrEmpty, and @() is a DIFFERENT documented state (the + # run-locally escape hatch asserted further down). The whole point of this Describe is + # that those two are distinguishable, so its assertions must be able to tell them apart. + Invoke-PfbInContext -Array $script:fb -Context 'FB-B' -ScriptBlock { $null } + $null -eq $script:fb.ContextOverride | Should -BeTrue + } + It 'exposes the override to the block' { + $captured = Invoke-PfbInContext -Array $script:fb -Context 'FB-B' -ScriptBlock { $script:fb.ContextOverride.Entries[0].Name } + $captured | Should -Be 'FB-B' + } + It 'restores the override when the scriptblock throws partway through' { + { Invoke-PfbInContext -Array $script:fb -Context 'FB-B' -ScriptBlock { throw 'boom' } } | Should -Throw 'boom' + $null -eq $script:fb.ContextOverride | Should -BeTrue # unset, NOT @() + } + It 'nests: the inner block restores the OUTER value, not null' { + Invoke-PfbInContext -Array $script:fb -Context 'outer' -ScriptBlock { + Invoke-PfbInContext -Array $script:fb -Context 'inner' -ScriptBlock { + $script:fb.ContextOverride.Entries[0].Name | Should -Be 'inner' + } + $script:fb.ContextOverride.Entries[0].Name | Should -Be 'outer' + } + $null -eq $script:fb.ContextOverride | Should -BeTrue # unset, NOT @() + } + It 'nests safely when the INNER block throws' { + { + Invoke-PfbInContext -Array $script:fb -Context 'outer' -ScriptBlock { + Invoke-PfbInContext -Array $script:fb -Context 'inner' -ScriptBlock { throw 'inner boom' } + } + } | Should -Throw 'inner boom' + $null -eq $script:fb.ContextOverride | Should -BeTrue # unset, NOT @() + } + It 'accepts an empty collection as the explicit run-locally escape hatch' { + $captured = Invoke-PfbInContext -Array $script:fb -Context @() -ScriptBlock { $script:fb.ContextOverride } + @($captured.Entries).Count | Should -Be 0 + $null -ne $captured | Should -BeTrue # empty != unset: a context object EXISTS + } + It 'throws its own errors for missing arguments rather than prompting' { + { Invoke-PfbInContext -Array $script:fb -Context 'FB-B' } | Should -Throw -ExpectedMessage '*-ScriptBlock*' + { Invoke-PfbInContext -Context 'FB-B' -ScriptBlock {} } | Should -Throw -ExpectedMessage '*-Array*' + } +} From 65f0006981809f9a3a15a25327c5faebe4840b88 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 13:59:33 -0700 Subject: [PATCH 10/53] fix(context): make the connection-cache restore actually restore, drop dead cast Connect-PfbArray mutates $script:PfbArrays in place, so capturing the reference and reassigning the same object left the 'fb.test' key leaking to later test files. Capture a shallow copy instead, and add a final ordered test asserting the key is absent so a silently-failing restore fails loudly. Also drop the [object[]] cast on New-PfbContext -Entries and its misleading comment: the @() wrapper around ConvertTo-PfbContextEntryList is the only thing required for the -Context @() escape hatch. Realign the -Array guard brace. Co-Authored-By: Claude Opus 5 --- Public/Context/Invoke-PfbInContext.ps1 | 8 ++------ Tests/Connect-PfbArray.Context.Tests.ps1 | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Public/Context/Invoke-PfbInContext.ps1 b/Public/Context/Invoke-PfbInContext.ps1 index f805848c..4d83266c 100644 --- a/Public/Context/Invoke-PfbInContext.ps1 +++ b/Public/Context/Invoke-PfbInContext.ps1 @@ -41,7 +41,7 @@ function Invoke-PfbInContext { [switch]$AllArrays ) - if (-not $Array) { throw "Invoke-PfbInContext requires -Array: pass a connection object from Connect-PfbArray." } + if (-not $Array) { throw "Invoke-PfbInContext requires -Array: pass a connection object from Connect-PfbArray." } if (-not $ScriptBlock) { throw "Invoke-PfbInContext requires -ScriptBlock: pass the block to run in the context." } # -ne $null, never truthiness: @() is falsy but meaningful. if ($null -eq $Context) { throw "Invoke-PfbInContext requires -Context. Pass @() to run the block against the local array." } @@ -55,11 +55,7 @@ function Invoke-PfbInContext { # Deliberately mutated IN PLACE on the shared connection, unlike Set-PfbContext's # copy-on-write: the override is ambient, so a copy no caller holds would be invisible. $previous = $Array.ContextOverride - # The [object[]] cast is load-bearing for the -Context @() escape hatch: passing an empty - # array through as a bare variable makes the binder see $null and reject the mandatory - # -Entries, in spite of its [AllowEmptyCollection()]. The cast preserves the empty - # collection. Measured on both PowerShell editions. - $Array.ContextOverride = New-PfbContext -Entries ([object[]]$entries) + $Array.ContextOverride = New-PfbContext -Entries $entries try { & $ScriptBlock } finally { $Array.ContextOverride = $previous } } diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index 041aaba0..e8d29100 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -129,8 +129,14 @@ Describe 'Connect-PfbArray -Context behaviour' { # $script:PfbArrays['fb.test'] at a fake connection. Without a restore, that state # outlives this file and is available to poison any later one, so it is captured here # and restored in AfterEach for every test in this block. + # + # `@{} + $script:PfbArrays` is a shallow COPY, not the reference: Connect-PfbArray + # mutates that hashtable in place ($script:PfbArrays[$Endpoint] = ...), so reassigning + # the identical object in AfterEach would be a no-op and the 'fb.test' key would still + # leak. $script:PfbDefaultArray is a plain reassignment, so capturing the reference is + # correct there. $script:originalState = InModuleScope PureStorageFlashBladePowerShell { - @{ Arrays = $script:PfbArrays; Default = $script:PfbDefaultArray } + @{ Arrays = @{} + $script:PfbArrays; Default = $script:PfbDefaultArray } } } @@ -229,4 +235,15 @@ Describe 'Connect-PfbArray -Context behaviour' { } } } + + It 'has left no fb.test entry in the module connection cache' { + # Ordered last on purpose: every preceding test in this block has already run its + # AfterEach, so this asserts the restore actually worked rather than merely ran. It is + # the guard for the reference-vs-copy trap -- Connect-PfbArray mutates $script:PfbArrays + # in place, so capturing a reference makes the AfterEach reassignment a no-op and this + # assertion then fails with the key still present. + InModuleScope PureStorageFlashBladePowerShell { + $script:PfbArrays.ContainsKey('fb.test') | Should -BeFalse + } + } } From f12acacca76b10373df750e3b3bea66cf570d7a0 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 14:09:48 -0700 Subject: [PATCH 11/53] feat(context): resolve context precedence with a tri-state unset/empty distinction Co-Authored-By: Claude Opus 5 --- Private/Resolve-PfbRequestContext.ps1 | 38 +++++++++++++ Tests/Connect-PfbArray.Context.Tests.ps1 | 2 +- Tests/Resolve-PfbRequestContext.Tests.ps1 | 68 +++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 Private/Resolve-PfbRequestContext.ps1 create mode 100644 Tests/Resolve-PfbRequestContext.Tests.ps1 diff --git a/Private/Resolve-PfbRequestContext.ps1 b/Private/Resolve-PfbRequestContext.ps1 new file mode 100644 index 00000000..f7f699d6 --- /dev/null +++ b/Private/Resolve-PfbRequestContext.ps1 @@ -0,0 +1,38 @@ +function Resolve-PfbRequestContext { + <# + .SYNOPSIS + Resolves the effective Fusion context for one request, once, at the choke point. + .DESCRIPTION + Precedence: + explicit -QueryParams['context_names'] > ContextOverride > DefaultContext > none + + TRI-STATE. $null means UNSET. A context whose Entries collection is EMPTY means the + caller explicitly asked for no context ("run this one call locally"). Both inject + nothing, but only the unset form may fall through to a lower precedence tier, and only + a NON-EMPTY resolved context is subject to the hard-throw gates. Every test here is + written with -ne $null rather than truthiness for exactly that reason. + + The explicit -QueryParams tier is defensive layering: nothing in the public surface + can populate it today (resolved open question 1). + .OUTPUTS + A PfbContext object, or $null when unset. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][PSCustomObject]$Array, + [Parameter()][AllowNull()][hashtable]$QueryParams + ) + + if ($QueryParams -and $QueryParams.ContainsKey($script:PfbContextParameterName)) { + $explicit = $QueryParams[$script:PfbContextParameterName] + # @(...) around the CALL, not just around $explicit: for an explicitly-empty context + # ConvertTo-PfbContextEntryList emits nothing, and a command emitting nothing assigns + # $null -- so the unwrapped form passes $null to -Entries, which throws, and would + # collapse "explicitly no context" into "unset" if it did not. Measured on both + # editions in Task 5; New-PfbContext accepts the wrapped empty array fine. + return New-PfbContext -Entries @(ConvertTo-PfbContextEntryList -Name @($explicit)) + } + if ($null -ne $Array.ContextOverride) { return $Array.ContextOverride } + if ($null -ne $Array.DefaultContext) { return $Array.DefaultContext } + return $null +} diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index e8d29100..ea36bf6a 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -17,7 +17,7 @@ Describe 'connection context state' { } $copy = Copy-PfbConnection -Array $fake $copy.DefaultContext = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) - $fake.DefaultContext | Should -BeNullOrEmpty + $null -eq $fake.DefaultContext | Should -BeTrue $copy.PSObject.TypeNames | Should -Contain 'PureStorage.FlashBlade.Connection' [object]::ReferenceEquals($copy, $fake) | Should -BeFalse } diff --git a/Tests/Resolve-PfbRequestContext.Tests.ps1 b/Tests/Resolve-PfbRequestContext.Tests.ps1 new file mode 100644 index 00000000..d4d223b2 --- /dev/null +++ b/Tests/Resolve-PfbRequestContext.Tests.ps1 @@ -0,0 +1,68 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +BeforeAll { + Import-Module "$PSScriptRoot/../PureStorageFlashBladePowerShell.psd1" -Force +} + +# InModuleScope goes INSIDE each It, never around the Describe body: Describe-level +# InModuleScope fails at discovery in this repo, and every passing test on this branch +# already uses the It-level form. The fixtures have to be built inside it too -- +# New-PfbContext / New-PfbContextEntry / Resolve-PfbRequestContext are all private and +# do not exist outside module scope. +# +# Fixtures are plain locals ($fb), NOT $script: -- inside InModuleScope a $script: +# assignment writes to the MODULE's script scope, which is leaked module state that +# outlives the test file. Locals cost three lines of repetition per It and leak nothing. +Describe 'Resolve-PfbRequestContext' { + It 'returns $null when nothing is set' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null } + $null -eq (Resolve-PfbRequestContext -Array $fb -QueryParams $null) | Should -BeTrue + } + } + It 'uses DefaultContext when only it is set' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null } + $fb.DefaultContext = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + (Resolve-PfbRequestContext -Array $fb -QueryParams $null).Entries[0].Name | Should -Be 'FB-B' + } + } + It 'prefers ContextOverride over DefaultContext' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null } + $fb.DefaultContext = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $fb.ContextOverride = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-C')) + (Resolve-PfbRequestContext -Array $fb -QueryParams $null).Entries[0].Name | Should -Be 'FB-C' + } + } + It 'prefers an explicit QueryParams context_names over both' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null } + $fb.DefaultContext = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $fb.ContextOverride = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-C')) + $resolved = Resolve-PfbRequestContext -Array $fb -QueryParams @{ context_names = 'FB-D' } + $resolved.Entries[0].Name | Should -Be 'FB-D' + } + } + It 'distinguishes an explicit EMPTY override from unset' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null } + $fb.DefaultContext = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $fb.ContextOverride = New-PfbContext -Entries @() + $resolved = Resolve-PfbRequestContext -Array $fb -QueryParams $null + $null -ne $resolved | Should -BeTrue # a context object EXISTS... + @($resolved.Entries).Count | Should -Be 0 # ...it just carries no entries + } + } + It 'does not treat an empty override as falsy and fall through to the default' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # The whole point of the tri-state: a truthiness check here would silently + # reinstate the session default and send the call to the wrong array. + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null } + $fb.DefaultContext = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $fb.ContextOverride = New-PfbContext -Entries @() + @((Resolve-PfbRequestContext -Array $fb -QueryParams $null).Entries).Count | + Should -Be 0 # count, not -BeNullOrEmpty: proves empty rather than merely falsy + } + } +} From 8d37a8da03151ddc9076fa6047f5ac572f6c5cbd Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 14:20:28 -0700 Subject: [PATCH 12/53] test(context): pin the null-comparison and empty-array-wrapper contracts Add two mutation-killing tests for Resolve-PfbRequestContext: a falsy-but-present ContextOverride that a truthiness check would skip, and an explicitly empty QueryParams context_names that requires the @() wrapper. Comment-only clarifications in the implementation; no behaviour change. Co-Authored-By: Claude Opus 5 --- Private/Resolve-PfbRequestContext.ps1 | 14 +++++++------ Tests/Resolve-PfbRequestContext.Tests.ps1 | 25 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/Private/Resolve-PfbRequestContext.ps1 b/Private/Resolve-PfbRequestContext.ps1 index f7f699d6..f2ef6577 100644 --- a/Private/Resolve-PfbRequestContext.ps1 +++ b/Private/Resolve-PfbRequestContext.ps1 @@ -9,8 +9,10 @@ function Resolve-PfbRequestContext { TRI-STATE. $null means UNSET. A context whose Entries collection is EMPTY means the caller explicitly asked for no context ("run this one call locally"). Both inject nothing, but only the unset form may fall through to a lower precedence tier, and only - a NON-EMPTY resolved context is subject to the hard-throw gates. Every test here is - written with -ne $null rather than truthiness for exactly that reason. + a NON-EMPTY resolved context is subject to the hard-throw gates. Presence must + therefore always be tested with -ne $null and never with truthiness: an empty + collection is falsy but meaningful, and treating it as absent would silently + reinstate a lower tier and route the request to the wrong array. The explicit -QueryParams tier is defensive layering: nothing in the public surface can populate it today (resolved open question 1). @@ -23,13 +25,13 @@ function Resolve-PfbRequestContext { [Parameter()][AllowNull()][hashtable]$QueryParams ) - if ($QueryParams -and $QueryParams.ContainsKey($script:PfbContextParameterName)) { + if ($null -ne $QueryParams -and $QueryParams.ContainsKey($script:PfbContextParameterName)) { $explicit = $QueryParams[$script:PfbContextParameterName] # @(...) around the CALL, not just around $explicit: for an explicitly-empty context # ConvertTo-PfbContextEntryList emits nothing, and a command emitting nothing assigns - # $null -- so the unwrapped form passes $null to -Entries, which throws, and would - # collapse "explicitly no context" into "unset" if it did not. Measured on both - # editions in Task 5; New-PfbContext accepts the wrapped empty array fine. + # $null -- so the unwrapped form passes $null to -Entries, which is Mandatory and + # rejects $null, and would collapse "explicitly no context" into "unset" if it did + # not throw. New-PfbContext accepts the wrapped empty array fine. return New-PfbContext -Entries @(ConvertTo-PfbContextEntryList -Name @($explicit)) } if ($null -ne $Array.ContextOverride) { return $Array.ContextOverride } diff --git a/Tests/Resolve-PfbRequestContext.Tests.ps1 b/Tests/Resolve-PfbRequestContext.Tests.ps1 index d4d223b2..77acfc60 100644 --- a/Tests/Resolve-PfbRequestContext.Tests.ps1 +++ b/Tests/Resolve-PfbRequestContext.Tests.ps1 @@ -65,4 +65,29 @@ Describe 'Resolve-PfbRequestContext' { Should -Be 0 # count, not -BeNullOrEmpty: proves empty rather than merely falsy } } + It 'checks ContextOverride against $null rather than truthiness' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null } + $fb.DefaultContext = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + # A deliberately FALSY-but-present override. A PfbContext is a PSCustomObject and so + # is always truthy, which is why the empty-Entries fixtures above cannot express this; + # a bare @() is $false in a boolean context but is NOT $null. + # under `$null -ne` : the override wins. Returning a bare @() emits nothing, so the + # call lands as $null -- and FB-B never appears. + # under truthiness : the override is skipped and the FB-B DefaultContext comes back. + # "Did FB-B leak through" is therefore the mutation detector. + $fb.ContextOverride = @() + $resolved = Resolve-PfbRequestContext -Array $fb -QueryParams $null + $null -eq $resolved | Should -BeTrue + } + } + It 'treats an explicit EMPTY QueryParams context_names as no-context, not unset' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null } + $fb.DefaultContext = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $resolved = Resolve-PfbRequestContext -Array $fb -QueryParams @{ context_names = @() } + $null -ne $resolved | Should -BeTrue # explicit, so it does NOT fall through + @($resolved.Entries).Count | Should -Be 0 + } + } } From b86c4bec0cdb960b9abf91883b217c014039022a Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 14:30:40 -0700 Subject: [PATCH 13/53] feat(context): inject context_names at the choke point before the version gate Co-Authored-By: Claude Opus 5 --- Private/Assert-PfbApiCapability.ps1 | 3 +- Private/Assert-PfbContextSupported.ps1 | 19 +++ Private/Invoke-PfbApiRequest.ps1 | 20 +++ ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 129 ++++++++++++++++++ 4 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 Private/Assert-PfbContextSupported.ps1 create mode 100644 Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 diff --git a/Private/Assert-PfbApiCapability.ps1 b/Private/Assert-PfbApiCapability.ps1 index 65b74827..5a6d6a25 100644 --- a/Private/Assert-PfbApiCapability.ps1 +++ b/Private/Assert-PfbApiCapability.ps1 @@ -50,8 +50,7 @@ function Assert-PfbApiCapability { $map = Get-PfbCapabilityMap if (-not $map) { return } - $normalizedEndpoint = '/' + $Endpoint.TrimStart('/') - $key = "$Method $normalizedEndpoint" + $key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint $entry = $map.endpoints.$key if (-not $entry) { return } diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 new file mode 100644 index 00000000..25ff70f4 --- /dev/null +++ b/Private/Assert-PfbContextSupported.ps1 @@ -0,0 +1,19 @@ +function Get-PfbEndpointKey { + <# + .SYNOPSIS + Builds the capability-map endpoints key for a method and endpoint. + .DESCRIPTION + ONE home for this normalization. Assert-PfbApiCapability and the context gates must + agree byte-for-byte: a second copy that differed by a leading slash would silently + miss every entry and turn confirmed capability data into "no entry", which the + capability gate reads as a reason to throw. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][string]$Method, + [Parameter(Mandatory)][string]$Endpoint + ) + + "$Method /" + $Endpoint.TrimStart('/') +} diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index 26e87df3..cf918fe6 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -40,6 +40,26 @@ function Invoke-PfbApiRequest { [string]$ApiVersionOverride ) + # Resolve and inject the Fusion context BEFORE Assert-PfbApiCapability, never after. + # Assert is the version gate this design leans on; if context_names lands in $QueryParams + # after Assert has run, Assert never sees the parameter and the check never fires. Do NOT + # move this to query-string construction below -- that is after Assert. + # + # Tri-state: $null means unset (inject nothing). A context that EXISTS but has no entries + # means "run this one call locally" -- also inject nothing, and specifically do not fall + # through to any lower-precedence context. Hence -ne $null plus an explicit count, never + # truthiness on the context object. + $resolvedContext = Resolve-PfbRequestContext -Array $Array -QueryParams $QueryParams + if ($null -ne $resolvedContext -and @($resolvedContext.Entries).Count -gt 0) { + # Clone first: $QueryParams is a reference to the CALLER's hashtable, and a targeting + # parameter must not leak back into a hashtable the caller may reuse for another call. + # Assigning the clone to the local also means the -AutoPaginate loop below rebuilds + # the query from the clone, so the context survives page 2+. + $QueryParams = if ($null -ne $QueryParams) { $QueryParams.Clone() } else { @{} } + $QueryParams[$script:PfbContextParameterName] = + @($resolvedContext.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ',' + } + # Fail fast if the connected array's REST version doesn't support this endpoint/param/ # field, before any network call is made. Never sent if incompatible: see # Assert-PfbApiCapability's header for why an unrecognized endpoint is a silent no-op. diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 new file mode 100644 index 00000000..69f665b9 --- /dev/null +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -0,0 +1,129 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } +<# +.SYNOPSIS + Confirms Invoke-PfbApiRequest injects the resolved Fusion context as context_names at the + single choke point, before the capability version gate runs, without polluting the + caller's query-parameter hashtable. +#> + +BeforeAll { + Import-Module "$PSScriptRoot/../PureStorageFlashBladePowerShell.psd1" -Force +} + +# Two scaffolding rules, both load-bearing: +# +# 1. InModuleScope goes inside each It. Describe-level fails at discovery in this repo, so +# the block silently never runs. The fixture is rebuilt per It rather than in a shared +# BeforeEach, because it calls the private New-PfbContext and so must be inside module +# scope; the repetition is the price of the block actually executing. +# +# 2. Captures use a List and .Add(), never `$script:x = ...` from inside a Mock body. A mock +# body can READ the test's variables, but an assignment inside it does not propagate back +# out -- and `$script:` inside InModuleScope writes to the MODULE's script scope, which is +# leaked state that outlives the file. Mutating a list the test already holds is +# scope-safe either way. `$script:PfbContextParameterName` inside a mock body is fine and +# intended: that IS a real module constant, and we are in module scope to read it. +Describe 'context injection in Invoke-PfbApiRequest' { + It 'injects context_names BEFORE Assert-PfbApiCapability sees the query params' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # This is the ordering bug's only real detector. A test that supplies + # context_names in -QueryParams itself cannot detect it -- an early live test + # "confirmed" the gate that way and exercised a path the shipped code never takes. + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) + ContextOverride = $null; AuthorizationModel = $null + } + $seen = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Assert-PfbApiCapability -MockWith { + $seen.Add($QueryParams[$script:PfbContextParameterName]) + } + Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null + @($seen).Count | Should -Be 1 # the gate ran exactly once... + $seen[0] | Should -Be 'FB-B' # ...and context_names was already there + } + } + It 'renders the .arrays form on the wire' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet' -Form 'AllArrays'))) + ContextOverride = $null; AuthorizationModel = $null + } + $uris = [System.Collections.Generic.List[string]]::new() + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { $uris.Add($Uri); [PSCustomObject]@{ items = @() } } + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null + $uris[0] | Should -BeLike '*context_names=cc-test-fleet.arrays*' + } + } + It 'keeps context_names on page 2 and beyond' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) + ContextOverride = $null; AuthorizationModel = $null + } + $uris = [System.Collections.Generic.List[string]]::new() + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { + $uris.Add($Uri) + if ($uris.Count -eq 1) { [PSCustomObject]@{ items = @(1); continuation_token = 'tok'; total_item_count = 2 } } + else { [PSCustomObject]@{ items = @(2); continuation_token = $null; total_item_count = 2 } } + } + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'arrays/space' -AutoPaginate | Out-Null + @($uris).Count | Should -Be 2 + $uris[1] | Should -BeLike '*context_names=FB-B*' + } + } + It 'injects nothing when no context is set' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + } + $uris = [System.Collections.Generic.List[string]]::new() + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { $uris.Add($Uri); [PSCustomObject]@{ items = @() } } + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null + $uris[0] | Should -Not -BeLike '*context_names*' + } + } + It 'injects nothing for an explicit empty context' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # Explicit no-context: a context object EXISTS but carries no entries. It must + # inject nothing AND must not fall through to the DefaultContext below it. + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) + ContextOverride = (New-PfbContext -Entries @()); AuthorizationModel = $null + } + $uris = [System.Collections.Generic.List[string]]::new() + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { $uris.Add($Uri); [PSCustomObject]@{ items = @() } } + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'alert-watchers' | Out-Null + $uris[0] | Should -Not -BeLike '*context_names*' + } + } + It 'does not pollute the caller hashtable with context_names' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) + ContextOverride = $null; AuthorizationModel = $null + } + $callerParams = @{ limit = 5 } + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' -QueryParams $callerParams | Out-Null + $callerParams.ContainsKey($script:PfbContextParameterName) | Should -BeFalse + } + } +} From 766180cb3883e47ef7bdda07ecb49237c71d14ad Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 16:06:37 -0700 Subject: [PATCH 14/53] test(context): pin the empty-context guard at the capability gate The URI cannot observe an injected context_names = '' -- both downstream sinks discard empty-string values -- so assert on what Assert-PfbApiCapability actually receives. Also corrects Get-PfbEndpointKey's doc comment: a drifted key silently disables the version gate, it does not throw. Co-Authored-By: Claude Opus 5 --- Private/Assert-PfbContextSupported.ps1 | 8 +++++--- ...voke-PfbApiRequest.ContextInjection.Tests.ps1 | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index 25ff70f4..4a480e91 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -4,9 +4,11 @@ function Get-PfbEndpointKey { Builds the capability-map endpoints key for a method and endpoint. .DESCRIPTION ONE home for this normalization. Assert-PfbApiCapability and the context gates must - agree byte-for-byte: a second copy that differed by a leading slash would silently - miss every entry and turn confirmed capability data into "no entry", which the - capability gate reads as a reason to throw. + agree byte-for-byte: a second copy that differed by a leading slash would miss every + entry in the map. That failure is silent, which makes it worse than a throw -- + Assert-PfbApiCapability treats a missing entry as a deliberate pass + ("if (-not $entry) { return }"), so a drifted key blocks nothing. It quietly + disables the version gate for every endpoint in the module. #> [CmdletBinding()] [OutputType([string])] diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index 69f665b9..9d6e1afa 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -105,9 +105,23 @@ Describe 'context injection in Invoke-PfbApiRequest' { ContextOverride = (New-PfbContext -Entries @()); AuthorizationModel = $null } $uris = [System.Collections.Generic.List[string]]::new() - Mock -CommandName Assert-PfbApiCapability -MockWith {} + # The URI alone CANNOT pin this: both downstream sinks (ConvertTo-PfbQueryString and + # Assert-PfbApiCapability's query-param loop) discard empty-string values, so an + # injected context_names = '' is invisible in the URI. Mutating the guard to bare + # `if ($resolvedContext)` therefore left the whole suite green. So capture what the + # gate ACTUALLY receives: on correct code the key must not be present at all. + # + # $QueryParams is legitimately still $null here (the caller passed none), and + # ContainsKey on $null throws -- so test presence with an explicit null check rather + # than letting the throw stand in for the assertion, which would hide a regression. + $keyPresent = [System.Collections.Generic.List[bool]]::new() + Mock -CommandName Assert-PfbApiCapability -MockWith { + $keyPresent.Add($null -ne $QueryParams -and $QueryParams.ContainsKey($script:PfbContextParameterName)) + } Mock -CommandName Invoke-RestMethod -MockWith { $uris.Add($Uri); [PSCustomObject]@{ items = @() } } Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'alert-watchers' | Out-Null + @($keyPresent).Count | Should -Be 1 # the gate ran exactly once... + $keyPresent[0] | Should -BeFalse # ...and saw no context_names key at all $uris[0] | Should -Not -BeLike '*context_names*' } } From d594def4bbecd705936e49734087fbae1140ea68 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 16:31:00 -0700 Subject: [PATCH 15/53] feat(context): gate injection on recorded context_names support --- Private/Assert-PfbContextSupported.ps1 | 49 +++++++++ Private/Invoke-PfbApiRequest.ps1 | 5 + Tests/Assert-PfbContextSupported.Tests.ps1 | 121 +++++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 Tests/Assert-PfbContextSupported.Tests.ps1 diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index 4a480e91..4a6c2e84 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -19,3 +19,52 @@ function Get-PfbEndpointKey { "$Method /" + $Endpoint.TrimStart('/') } + +function Assert-PfbContextCapability { + <# + .SYNOPSIS + Throws when a context is set for an endpoint the map says cannot take one. + .DESCRIPTION + Rows 3 and 4 of the design's injection/gating table, and they MIRROR each other: the + two "absent" cases (no entry at all, entry without context_names) get identical + treatment, because the likeliest real staleness is an endpoint that exists today and + GAINS context_names later -- entry present, parameter absent. + + Keyed on the map's generatedFrom via Test-PfbCapabilityMapCoverage, so absence WITHIN + the scanned range is confirmed absence and absence beyond it is no evidence at all. + + Why client-side rather than "send it and let the array error": in the case that + matters there is no error to surface. An endpoint that never supported context_names + (/alert-watchers) silently accepts it -- HTTP 200, real mutations applied, no mention + of the parameter. The array performs no query-parameter validation on reads at all. + Accepting a parameter is not evidence an endpoint supports it. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][PSCustomObject]$Array, + [Parameter(Mandatory)][string]$Method, + [Parameter(Mandatory)][string]$Endpoint, + [Parameter(Mandatory)]$Context, + [Parameter()][AllowNull()]$CapabilityMap + ) + + if (-not $CapabilityMap) { return } + + $key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint + $entry = $CapabilityMap.endpoints.$key + + $supportsContext = $false + if ($entry -and $entry.parameters) { + $supportsContext = @($entry.parameters.PSObject.Properties.Name) -contains $script:PfbContextParameterName + } + if ($supportsContext) { return } # Assert-PfbApiCapability owns "recorded but array too old" + + # Beyond the scanned range the map has no evidence, so proceed permissively rather than + # punish a packaging lag the caller cannot see. + if (Test-PfbCapabilityMapCoverage -NegotiatedVersion $Array.ApiVersion -CapabilityMap $CapabilityMap) { + return + } + + $names = @($Context.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ', ' + throw "$key does not support the context_names parameter, so the context '$names' cannot be applied to it. Run this call against the local array with Invoke-PfbInContext -Context @() { ... }, or remove the session context with Clear-PfbContext." +} diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index cf918fe6..b765c727 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -51,6 +51,11 @@ function Invoke-PfbApiRequest { # truthiness on the context object. $resolvedContext = Resolve-PfbRequestContext -Array $Array -QueryParams $QueryParams if ($null -ne $resolvedContext -and @($resolvedContext.Entries).Count -gt 0) { + # Gate before injecting: an endpoint with no recorded context_names support silently + # accepts the parameter on the wire, so the array will never tell the caller. + $capabilityMap = Get-PfbCapabilityMap + Assert-PfbContextCapability -Array $Array -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap + # Clone first: $QueryParams is a reference to the CALLER's hashtable, and a targeting # parameter must not leak back into a hashtable the caller may reuse for another call. # Assigning the clone to the local also means the -AutoPaginate loop below rebuilds diff --git a/Tests/Assert-PfbContextSupported.Tests.ps1 b/Tests/Assert-PfbContextSupported.Tests.ps1 new file mode 100644 index 00000000..51159a26 --- /dev/null +++ b/Tests/Assert-PfbContextSupported.Tests.ps1 @@ -0,0 +1,121 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } +<# +.SYNOPSIS + Covers the Fusion context gates in Private/Assert-PfbContextSupported.ps1. +#> + +BeforeAll { + Import-Module "$PSScriptRoot/../PureStorageFlashBladePowerShell.psd1" -Force +} + +# InModuleScope inside each It (Describe-level fails at discovery here -- see Global +# Constraints). The fixtures are rebuilt per It as plain LOCALS rather than shared from a +# BeforeEach: $ctx needs the private New-PfbContext so it has to be inside module scope, and a +# `$script:` fixture inside InModuleScope would write to the MODULE's script scope and leak past +# the file. The repetition is deliberate and is the price of the block actually running. +Describe 'Assert-PfbContextCapability' { + It 'allows an endpoint whose entry lists context_names' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $map = [PSCustomObject]@{ + generatedFrom = @('2.0', '2.26') + parameterComponentDefaults = [PSCustomObject]@{} + endpoints = [PSCustomObject]@{ + 'GET /file-systems' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ context_names = '2.23'; allow_errors = '2.23' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + 'GET /alert-watchers' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ names = '2.0' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + } + } + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; ApiVersion = '2.26' } + { Assert-PfbContextCapability -Array $fb -Method 'GET' -Endpoint 'file-systems' -Context $ctx -CapabilityMap $map } | + Should -Not -Throw + } + } + It 'throws when the entry exists but lacks context_names (the likeliest staleness case)' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $map = [PSCustomObject]@{ + generatedFrom = @('2.0', '2.26') + parameterComponentDefaults = [PSCustomObject]@{} + endpoints = [PSCustomObject]@{ + 'GET /file-systems' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ context_names = '2.23'; allow_errors = '2.23' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + 'GET /alert-watchers' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ names = '2.0' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + } + } + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; ApiVersion = '2.26' } + { Assert-PfbContextCapability -Array $fb -Method 'GET' -Endpoint 'alert-watchers' -Context $ctx -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*GET /alert-watchers*does not support*' + } + } + It 'throws when there is no entry at all, within the scanned range' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $map = [PSCustomObject]@{ + generatedFrom = @('2.0', '2.26') + parameterComponentDefaults = [PSCustomObject]@{} + endpoints = [PSCustomObject]@{ + 'GET /file-systems' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ context_names = '2.23'; allow_errors = '2.23' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + 'GET /alert-watchers' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ names = '2.0' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + } + } + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; ApiVersion = '2.26' } + { Assert-PfbContextCapability -Array $fb -Method 'GET' -Endpoint 'not-in-map' -Context $ctx -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*GET /not-in-map*' + } + } + It 'stays permissive when the array is NEWER than the scanned range' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $map = [PSCustomObject]@{ + generatedFrom = @('2.0', '2.26') + parameterComponentDefaults = [PSCustomObject]@{} + endpoints = [PSCustomObject]@{ + 'GET /file-systems' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ context_names = '2.23'; allow_errors = '2.23' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + 'GET /alert-watchers' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ names = '2.0' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + } + } + # 2.40 is beyond generatedFrom's upper bound, so the map cannot be authoritative. + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; ApiVersion = '2.40' } + { Assert-PfbContextCapability -Array $fb -Method 'GET' -Endpoint 'not-in-map' -Context $ctx -CapabilityMap $map } | + Should -Not -Throw + { Assert-PfbContextCapability -Array $fb -Method 'GET' -Endpoint 'alert-watchers' -Context $ctx -CapabilityMap $map } | + Should -Not -Throw + } + } + It 'treats a local-array context as a context and still throws' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # Deliberate divergence from the server, which short-circuits a local context + # before validating. A cmdlet that works only SOME of the time depending on which + # array the context names is a worse contract than one that fails consistently. + $map = [PSCustomObject]@{ + generatedFrom = @('2.0', '2.26') + parameterComponentDefaults = [PSCustomObject]@{} + endpoints = [PSCustomObject]@{ + 'GET /file-systems' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ context_names = '2.23'; allow_errors = '2.23' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + 'GET /alert-watchers' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ names = '2.0' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + } + } + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; ApiVersion = '2.26' } + $local = New-PfbContext -Entries @((New-PfbContextEntry -Name 'fb.example')) + { Assert-PfbContextCapability -Array $fb -Method 'GET' -Endpoint 'alert-watchers' -Context $local -CapabilityMap $map } | + Should -Throw + } + } + It 'applies the throw to GET as uniformly as to a mutation' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $map = [PSCustomObject]@{ + generatedFrom = @('2.0', '2.26') + parameterComponentDefaults = [PSCustomObject]@{} + endpoints = [PSCustomObject]@{ + 'GET /file-systems' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ context_names = '2.23'; allow_errors = '2.23' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + 'GET /alert-watchers' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ names = '2.0' }; contextScope = [PSCustomObject]@{ scope = 'array'; provenance = 'default' } } + } + } + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; ApiVersion = '2.26' } + foreach ($m in 'GET', 'POST', 'PATCH', 'PUT', 'DELETE') { + { Assert-PfbContextCapability -Array $fb -Method $m -Endpoint 'alert-watchers' -Context $ctx -CapabilityMap $map } | + Should -Throw + } + } + } +} From a7d87c6cd2a6b9f74e46b5ecf22cbbfadf859450 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 16:44:43 -0700 Subject: [PATCH 16/53] test(context): constrain the capability gate's message in all four throw tests --- Tests/Assert-PfbContextSupported.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Assert-PfbContextSupported.Tests.ps1 b/Tests/Assert-PfbContextSupported.Tests.ps1 index 51159a26..4ba68111 100644 --- a/Tests/Assert-PfbContextSupported.Tests.ps1 +++ b/Tests/Assert-PfbContextSupported.Tests.ps1 @@ -97,7 +97,7 @@ Describe 'Assert-PfbContextCapability' { $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; ApiVersion = '2.26' } $local = New-PfbContext -Entries @((New-PfbContextEntry -Name 'fb.example')) { Assert-PfbContextCapability -Array $fb -Method 'GET' -Endpoint 'alert-watchers' -Context $local -CapabilityMap $map } | - Should -Throw + Should -Throw -ExpectedMessage '*does not support*' } } It 'applies the throw to GET as uniformly as to a mutation' { @@ -114,7 +114,7 @@ Describe 'Assert-PfbContextCapability' { $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; ApiVersion = '2.26' } foreach ($m in 'GET', 'POST', 'PATCH', 'PUT', 'DELETE') { { Assert-PfbContextCapability -Array $fb -Method $m -Endpoint 'alert-watchers' -Context $ctx -CapabilityMap $map } | - Should -Throw + Should -Throw -ExpectedMessage '*does not support*' } } } From 542eb5510836f22d641398a0fc76380c8bb48b03 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 16:53:38 -0700 Subject: [PATCH 17/53] feat(context): gate multi-value contexts on the shipped cardinality rule --- Private/Assert-PfbContextSupported.ps1 | 44 +++++++++++ Private/Invoke-PfbApiRequest.ps1 | 4 + Tests/Assert-PfbContextSupported.Tests.ps1 | 30 +++++++ ...Test-PfbContextMultiValueCapable.Tests.ps1 | 78 +++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 Tests/Test-PfbContextMultiValueCapable.Tests.ps1 diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index 4a6c2e84..259e8fc2 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -68,3 +68,47 @@ function Assert-PfbContextCapability { $names = @($Context.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ', ' throw "$key does not support the context_names parameter, so the context '$names' cannot be applied to it. Run this call against the local array with Invoke-PfbInContext -Context @() { ... }, or remove the session context with Clear-PfbContext." } + +function Assert-PfbContextCardinality { + <# + .SYNOPSIS + Throws when a multi-value context targets an endpoint that accepts only one. + .DESCRIPTION + Converts 400 code 15 "Multiple location contexts are not allowed." into an actionable + message. The rule itself lives in Test-PfbContextMultiValueCapable (#73) and the + component resolution in Resolve-PfbParameterComponent (#74) -- this function only + feeds them, deliberately, so the multi-value component literal is compared in exactly + one place in Private/ -- inside the predicate, never here. + + Called only from inside Invoke-PfbApiRequest's + "$null -ne $resolvedContext -and Count -gt 0" block, so a non-null / non-empty + re-check here would be unreachable. The Count -le 1 early return below is about + CARDINALITY, not emptiness. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Method, + [Parameter(Mandatory)][string]$Endpoint, + [Parameter(Mandatory)]$Context, + [Parameter()][AllowNull()]$CapabilityMap + ) + + if (@($Context.Entries).Count -le 1) { return } + if (-not $CapabilityMap) { return } + + $key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint + $entry = $CapabilityMap.endpoints.$key + if (-not $entry) { return } # Assert-PfbContextCapability already ruled on absence; do not double-throw + + $component = Resolve-PfbParameterComponent -EndpointEntry $entry ` + -ParameterName $script:PfbContextParameterName ` + -ParameterComponentDefaults $CapabilityMap.parameterComponentDefaults + $declaresAllowErrors = @($entry.parameters.PSObject.Properties.Name) -contains $script:PfbAllowErrorsParameterName + + if (Test-PfbContextMultiValueCapable -Method $Method -ContextComponent $component -DeclaresAllowErrors $declaresAllowErrors) { + return + } + + $names = @($Context.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ', ' + throw "$key accepts only one context, but $(@($Context.Entries).Count) were given ($names). Narrow the context to a single name. To target every array in a fleet or topology group with one context, use -AllArrays instead of listing members." +} diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index b765c727..6a93d65e 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -56,6 +56,10 @@ function Invoke-PfbApiRequest { $capabilityMap = Get-PfbCapabilityMap Assert-PfbContextCapability -Array $Array -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap + # Second gate: a multi-value context on an endpoint that accepts exactly one returns + # 400 code 15 with no hint about the fix, so translate it client-side. + Assert-PfbContextCardinality -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap + # Clone first: $QueryParams is a reference to the CALLER's hashtable, and a targeting # parameter must not leak back into a hashtable the caller may reuse for another call. # Assigning the clone to the local also means the -AutoPaginate loop below rebuilds diff --git a/Tests/Assert-PfbContextSupported.Tests.ps1 b/Tests/Assert-PfbContextSupported.Tests.ps1 index 4ba68111..4b525aa8 100644 --- a/Tests/Assert-PfbContextSupported.Tests.ps1 +++ b/Tests/Assert-PfbContextSupported.Tests.ps1 @@ -119,3 +119,33 @@ Describe 'Assert-PfbContextCapability' { } } } + +# Same It-level InModuleScope rule as above: New-PfbContext, Get-PfbCapabilityMap and +# Assert-PfbContextCardinality are all private, so without module scope every It here raises +# CommandNotFoundException -- which would silently satisfy the throw test below. +Describe 'Assert-PfbContextCardinality' { + It 'throws for a multi-value context on a non-capable endpoint, naming the narrowing fix' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $multi = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'), (New-PfbContextEntry -Name 'FB-C')) + $map = Get-PfbCapabilityMap + { Assert-PfbContextCardinality -Method 'GET' -Endpoint 'presets/workload' -Context $multi -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*accepts only one context*' + } + } + It 'allows a multi-value context on a capable endpoint' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $multi = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'), (New-PfbContextEntry -Name 'FB-C')) + $map = Get-PfbCapabilityMap + { Assert-PfbContextCardinality -Method 'GET' -Endpoint 'file-systems' -Context $multi -CapabilityMap $map } | + Should -Not -Throw + } + } + It 'never throws for a single-value context, capable or not' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $single = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $map = Get-PfbCapabilityMap + { Assert-PfbContextCardinality -Method 'GET' -Endpoint 'presets/workload' -Context $single -CapabilityMap $map } | + Should -Not -Throw + } + } +} diff --git a/Tests/Test-PfbContextMultiValueCapable.Tests.ps1 b/Tests/Test-PfbContextMultiValueCapable.Tests.ps1 new file mode 100644 index 00000000..7007e9ff --- /dev/null +++ b/Tests/Test-PfbContextMultiValueCapable.Tests.ps1 @@ -0,0 +1,78 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } +<# +.SYNOPSIS + Direct tests for the Fusion context cardinality rule in + Private/Test-PfbContextMultiValueCapable.ps1. +.DESCRIPTION + The predicate shipped in #73 with no dedicated test file -- its coverage was indirect via + PfbContextRuleTools.Tests.ps1. Phase 1 makes it a runtime gate, so it owes direct tests. +#> + +BeforeAll { + Import-Module "$PSScriptRoot/../PureStorageFlashBladePowerShell.psd1" -Force +} + +# InModuleScope inside each It, never around the Describe body (Describe-level fails at +# discovery here and the block silently never runs -- see Global Constraints). Each It below is +# already self-contained, so this is a pure re-scoping with no fixture changes. +Describe 'Test-PfbContextMultiValueCapable' { + It 'is capable only with BOTH the multi-value component and allow_errors' { + InModuleScope 'PureStorageFlashBladePowerShell' { + Test-PfbContextMultiValueCapable -Method 'GET' -ContextComponent 'Context_names_get' -DeclaresAllowErrors $true | Should -BeTrue + Test-PfbContextMultiValueCapable -Method 'GET' -ContextComponent 'Context_names_get' -DeclaresAllowErrors $false | Should -BeFalse + Test-PfbContextMultiValueCapable -Method 'GET' -ContextComponent 'Context_names' -DeclaresAllowErrors $true | Should -BeFalse + } + } + It 'does not use the HTTP verb when a component signal exists -- the verb rule is FALSIFIED' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # Four fleet-scoped GETs reject any two-name context with code 15. A GET is not + # multi-value-capable by virtue of being a GET. Do not reintroduce the verb rule. + Test-PfbContextMultiValueCapable -Method 'GET' -ContextComponent 'Context_names_get' -DeclaresAllowErrors $false | Should -BeFalse + Test-PfbContextMultiValueCapable -Method 'POST' -ContextComponent 'Context_names_get' -DeclaresAllowErrors $true | Should -BeTrue + } + } + It 'falls back to the verb ONLY with no component signal at all' { + InModuleScope 'PureStorageFlashBladePowerShell' { + Test-PfbContextMultiValueCapable -Method 'GET' -ContextComponent $null -DeclaresAllowErrors $false | Should -BeTrue + } + } + It 'throws rather than assuming $false for a verb it has no verdict for' { + InModuleScope 'PureStorageFlashBladePowerShell' { + { Test-PfbContextMultiValueCapable -Method 'HEAD' -ContextComponent $null -DeclaresAllowErrors $false } | + Should -Throw -ExpectedMessage '*no context-cardinality verdict*' + } + } + It 'agrees with the committed map on all four fleet-scoped GETs (code 15 on the wire)' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + foreach ($key in 'GET /presets/workload', 'GET /topology-groups', 'GET /topology-groups/arrays', 'GET /topology-groups/members') { + $entry = $map.endpoints.$key + $component = Resolve-PfbParameterComponent -EndpointEntry $entry -ParameterName $script:PfbContextParameterName -ParameterComponentDefaults $map.parameterComponentDefaults + $declares = @($entry.parameters.PSObject.Properties.Name) -contains $script:PfbAllowErrorsParameterName + Test-PfbContextMultiValueCapable -Method 'GET' -ContextComponent $component -DeclaresAllowErrors $declares | + Should -BeFalse -Because "$key returns 400 code 15 on any two-name context" + } + } + } +} + +Describe 'Cardinality rule single-home invariant' { + # The brief's filter was `-notmatch '^\s*#'`, which also keeps the four prose mentions + # inside this function's <# .DESCRIPTION #> block (they are inside a block comment, not + # line comments), so it can never reach 1. What the spec risk table actually cares about is + # that exactly one place in Private/ COMPARES against the component literal -- pinned here + # by matching the comparison itself, plus a whole-file sweep proving no other file in + # Private/ mentions the literal outside a line comment. + It 'has exactly one Context_names_get comparison in Private/' { + $all = Select-String -Path (Join-Path $PSScriptRoot '../Private/*.ps1') -Pattern 'Context_names_get' + + $comparisons = @($all | Where-Object { $_.Line -match "-eq\s+'Context_names_get'" }) + $comparisons.Count | Should -Be 1 + (Split-Path $comparisons[0].Path -Leaf) | Should -Be 'Test-PfbContextMultiValueCapable.ps1' + + $strayFiles = @($all | + Where-Object { $_.Line -notmatch '^\s*#' } | + Where-Object { (Split-Path $_.Path -Leaf) -ne 'Test-PfbContextMultiValueCapable.ps1' }) + $strayFiles.Count | Should -Be 0 + } +} From 16404247ce57b886998061ad70da166d93e61b7c Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 17:17:45 -0700 Subject: [PATCH 18/53] fix(context): scope the cardinality gate to endpoints declaring context_names Fix round 1 on Task 9. Important 1: Resolve-PfbParameterComponent returns the map's DEFAULT component for entries that do not declare context_names, so the cardinality rule read $false for them and the gate advised narrowing a context the endpoint cannot take at all -- firing precisely where Assert-PfbContextCapability deliberately abstains (array beyond the map's scanned range). Extract Test-PfbEndpointDeclaresContextNames as the one home for that question and have both gates call it. This also makes the cardinality gate order-independent. Important 2: pin both gate call sites in Invoke-PfbApiRequest, and their relative order, which were completely unpinned -- deleting either call left every test green. Minors: recurse the single-home invariant sweep into Private/ subdirectories, add -Because explaining a 0-count refactor, and correct the if (-not $entry) comment which named an ordering dependency that measurement shows does not exist. --- Private/Assert-PfbContextSupported.ps1 | 49 ++++++++++-- Tests/Assert-PfbContextSupported.Tests.ps1 | 58 ++++++++++++++ ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 76 +++++++++++++++++++ ...Test-PfbContextMultiValueCapable.Tests.ps1 | 18 ++++- 4 files changed, 193 insertions(+), 8 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index 259e8fc2..121c8740 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -20,6 +20,34 @@ function Get-PfbEndpointKey { "$Method /" + $Endpoint.TrimStart('/') } +function Test-PfbEndpointDeclaresContextNames { + <# + .SYNOPSIS + Does this capability-map endpoint entry declare the context_names parameter? + .DESCRIPTION + ONE home for this question, for the same reason Get-PfbEndpointKey exists: both context + gates need it and they must agree. Assert-PfbContextCapability uses it to decide whether + to throw; Assert-PfbContextCardinality uses it as a precondition, so an endpoint that + takes no context at all is left entirely to the capability gate rather than being told to + "narrow the context" -- advice that cannot possibly work there. + + This is NOT the same question as "did Resolve-PfbParameterComponent return a component". + That helper falls back to the map's DEFAULT component for the 256 entries that do not + declare the parameter, so a non-null component is no evidence the endpoint supports a + context. Only the parameters collection is evidence. + .OUTPUTS + [bool] + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)][AllowNull()]$EndpointEntry + ) + + if (-not $EndpointEntry -or -not $EndpointEntry.parameters) { return $false } + return (@($EndpointEntry.parameters.PSObject.Properties.Name) -contains $script:PfbContextParameterName) +} + function Assert-PfbContextCapability { <# .SYNOPSIS @@ -53,11 +81,10 @@ function Assert-PfbContextCapability { $key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint $entry = $CapabilityMap.endpoints.$key - $supportsContext = $false - if ($entry -and $entry.parameters) { - $supportsContext = @($entry.parameters.PSObject.Properties.Name) -contains $script:PfbContextParameterName + # Shared with Assert-PfbContextCardinality -- one home for "does this entry declare it". + if (Test-PfbEndpointDeclaresContextNames -EndpointEntry $entry) { + return # Assert-PfbApiCapability owns "recorded but array too old" } - if ($supportsContext) { return } # Assert-PfbApiCapability owns "recorded but array too old" # Beyond the scanned range the map has no evidence, so proceed permissively rather than # punish a packaging lag the caller cannot see. @@ -84,6 +111,15 @@ function Assert-PfbContextCardinality { "$null -ne $resolvedContext -and Count -gt 0" block, so a non-null / non-empty re-check here would be unreachable. The Count -le 1 early return below is about CARDINALITY, not emptiness. + + SCOPE. This gate rules only on endpoints that actually DECLARE context_names. An entry + that does not declare it (or is absent from the map) is out of scope and returns + silently: Resolve-PfbParameterComponent would hand back the map's default component for + such an entry, the cardinality rule would then read $false, and this gate would advise + narrowing a context the endpoint cannot take at all. Worse, that would fire precisely + where Assert-PfbContextCapability deliberately abstains -- an array beyond the map's + scanned range, where absence is no evidence -- reversing its permissiveness. The + precondition makes this gate independent of which gate runs first. #> [CmdletBinding()] param( @@ -98,7 +134,10 @@ function Assert-PfbContextCardinality { $key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint $entry = $CapabilityMap.endpoints.$key - if (-not $entry) { return } # Assert-PfbContextCapability already ruled on absence; do not double-throw + # Out of scope unless the endpoint declares context_names -- see SCOPE above. This also + # covers an entry absent from the map entirely, so the gate is order-independent: whether + # Assert-PfbContextCapability runs before or after, this returns silently either way. + if (-not (Test-PfbEndpointDeclaresContextNames -EndpointEntry $entry)) { return } $component = Resolve-PfbParameterComponent -EndpointEntry $entry ` -ParameterName $script:PfbContextParameterName ` diff --git a/Tests/Assert-PfbContextSupported.Tests.ps1 b/Tests/Assert-PfbContextSupported.Tests.ps1 index 4b525aa8..197f8a1e 100644 --- a/Tests/Assert-PfbContextSupported.Tests.ps1 +++ b/Tests/Assert-PfbContextSupported.Tests.ps1 @@ -148,4 +148,62 @@ Describe 'Assert-PfbContextCardinality' { Should -Not -Throw } } + # Fix round 1, Important 1. Resolve-PfbParameterComponent returns the map's DEFAULT component + # for the 256 entries that do not declare context_names, so without a precondition the + # cardinality rule reads $false for them and this gate advised "narrow the context to a single + # name" for an endpoint that takes no context at all -- and it fired precisely where + # Assert-PfbContextCapability deliberately abstains (array beyond the map's scanned range). + It 'stays silent for an entry that declares no context_names, even beyond map coverage' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $multi = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'), (New-PfbContextEntry -Name 'FB-C')) + $map = [PSCustomObject]@{ + generatedFrom = @('2.0', '2.28') + parameterComponentDefaults = [PSCustomObject]@{ context_names = 'Context_names' } + endpoints = [PSCustomObject]@{ + 'GET /active-directory' = [PSCustomObject]@{ parameters = [PSCustomObject]@{ names = '2.0' } } + } + } + { Assert-PfbContextCardinality -Method 'GET' -Endpoint 'active-directory' -Context $multi -CapabilityMap $map } | + Should -Not -Throw + } + } + It 'stays silent for an endpoint absent from the map, in either gate order' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $multi = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'), (New-PfbContextEntry -Name 'FB-C')) + $map = [PSCustomObject]@{ + generatedFrom = @('2.0', '2.28') + parameterComponentDefaults = [PSCustomObject]@{ context_names = 'Context_names' } + endpoints = [PSCustomObject]@{} + } + { Assert-PfbContextCardinality -Method 'GET' -Endpoint 'not-an-endpoint' -Context $multi -CapabilityMap $map } | + Should -Not -Throw + } + } +} + +Describe 'Test-PfbEndpointDeclaresContextNames' { + It 'is true only when the parameters collection actually lists context_names' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $declares = [PSCustomObject]@{ parameters = [PSCustomObject]@{ context_names = '2.23'; allow_errors = '2.23' } } + $lacks = [PSCustomObject]@{ parameters = [PSCustomObject]@{ names = '2.0' } } + $noParams = [PSCustomObject]@{ contextScope = [PSCustomObject]@{ scope = 'array' } } + + Test-PfbEndpointDeclaresContextNames -EndpointEntry $declares | Should -BeTrue + Test-PfbEndpointDeclaresContextNames -EndpointEntry $lacks | Should -BeFalse + Test-PfbEndpointDeclaresContextNames -EndpointEntry $noParams | Should -BeFalse + Test-PfbEndpointDeclaresContextNames -EndpointEntry $null | Should -BeFalse + } + } + It 'ignores a resolvable default component -- only the parameters collection is evidence' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # The exact confusion behind Important 1: a component resolves for this entry, yet + # the endpoint declares no context_names. + $lacks = [PSCustomObject]@{ parameters = [PSCustomObject]@{ names = '2.0' } } + $defaults = [PSCustomObject]@{ context_names = 'Context_names' } + $component = Resolve-PfbParameterComponent -EndpointEntry $lacks ` + -ParameterName $script:PfbContextParameterName -ParameterComponentDefaults $defaults + $component | Should -Be 'Context_names' # a component IS resolved... + Test-PfbEndpointDeclaresContextNames -EndpointEntry $lacks | Should -BeFalse # ...and means nothing + } + } } diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index 9d6e1afa..ac8a5c47 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -141,3 +141,79 @@ Describe 'context injection in Invoke-PfbApiRequest' { } } } + +# Fix round 1, Important 2. Both context gates were entirely unpinned at the call site: deleting +# either call from Invoke-PfbApiRequest left every relevant test green, so both gates could be +# unwired from the request path unnoticed. Tasks 10 and 11 add two more gates to this same site, +# so the wiring gets its own detector now. +Describe 'context gate wiring in Invoke-PfbApiRequest' { + It 'calls both context gates, capability before cardinality, when a context is set' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) + ContextOverride = $null; AuthorizationModel = $null + } + # A List with .Add() -- an assignment inside a Mock body does not propagate out, and + # `$script:` inside InModuleScope would write to the MODULE's script scope. + $calls = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Assert-PfbContextCapability -MockWith { $calls.Add('capability') } + Mock -CommandName Assert-PfbContextCardinality -MockWith { $calls.Add('cardinality') } + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } + + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null + + @($calls).Count | Should -Be 2 -Because 'both gates must fire from the request path; a count of 1 means one call was deleted or never wired' + $calls[0] | Should -Be 'capability' -Because 'the capability gate must rule on "endpoint takes no context at all" first' + $calls[1] | Should -Be 'cardinality' + } + } + It 'passes each gate the resolved context and the shared capability map' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) + ContextOverride = $null; AuthorizationModel = $null + } + $seen = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Assert-PfbContextCapability -MockWith { + $seen.Add([PSCustomObject]@{ Gate = 'capability'; Names = @($Context.Entries.Name) -join ','; Endpoint = $Endpoint; HasMap = ($null -ne $CapabilityMap) }) + } + Mock -CommandName Assert-PfbContextCardinality -MockWith { + $seen.Add([PSCustomObject]@{ Gate = 'cardinality'; Names = @($Context.Entries.Name) -join ','; Endpoint = $Endpoint; HasMap = ($null -ne $CapabilityMap) }) + } + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } + + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null + + @($seen).Count | Should -Be 2 + foreach ($record in $seen) { + $record.Names | Should -Be 'FB-B' -Because "$($record.Gate) must see the RESOLVED context, not a raw parameter" + $record.Endpoint | Should -Be 'file-systems' + $record.HasMap | Should -BeTrue -Because "$($record.Gate) must receive the capability map, or it silently no-ops" + } + } + } + It 'calls neither gate when no context is set' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + } + $calls = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Assert-PfbContextCapability -MockWith { $calls.Add('capability') } + Mock -CommandName Assert-PfbContextCardinality -MockWith { $calls.Add('cardinality') } + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } + + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null + + @($calls).Count | Should -Be 0 + } + } +} diff --git a/Tests/Test-PfbContextMultiValueCapable.Tests.ps1 b/Tests/Test-PfbContextMultiValueCapable.Tests.ps1 index 7007e9ff..582a8bbe 100644 --- a/Tests/Test-PfbContextMultiValueCapable.Tests.ps1 +++ b/Tests/Test-PfbContextMultiValueCapable.Tests.ps1 @@ -64,15 +64,27 @@ Describe 'Cardinality rule single-home invariant' { # by matching the comparison itself, plus a whole-file sweep proving no other file in # Private/ mentions the literal outside a line comment. It 'has exactly one Context_names_get comparison in Private/' { - $all = Select-String -Path (Join-Path $PSScriptRoot '../Private/*.ps1') -Pattern 'Context_names_get' + # Recurse: a stray comparison in a Private/ SUBDIRECTORY must not escape the sweep. + $files = Get-ChildItem -Path (Join-Path $PSScriptRoot '../Private') -Filter '*.ps1' -Recurse -File + $all = Select-String -Path $files.FullName -Pattern 'Context_names_get' $comparisons = @($all | Where-Object { $_.Line -match "-eq\s+'Context_names_get'" }) - $comparisons.Count | Should -Be 1 + $comparisons.Count | Should -Be 1 -Because @' +the cardinality rule must be compared in exactly ONE place in Private/. + Got 0? The comparison in Test-PfbContextMultiValueCapable.ps1 was most likely refactored to + another form (-match, a switch, a lookup variable) rather than removed. That is + behaviour-preserving but moves the rule out of this tripwire's sight -- update the pattern here + deliberately. + Got more than 1? A second home for the rule has appeared, which is exactly the defect the + spec risk table exists to catch: feed Test-PfbContextMultiValueCapable instead of re-deriving. +'@ (Split-Path $comparisons[0].Path -Leaf) | Should -Be 'Test-PfbContextMultiValueCapable.ps1' $strayFiles = @($all | Where-Object { $_.Line -notmatch '^\s*#' } | Where-Object { (Split-Path $_.Path -Leaf) -ne 'Test-PfbContextMultiValueCapable.ps1' }) - $strayFiles.Count | Should -Be 0 + $strayFiles.Count | Should -Be 0 -Because ( + 'only Test-PfbContextMultiValueCapable.ps1 may mention the component literal outside ' + + 'a line comment; stray hits: ' + (($strayFiles | ForEach-Object { "$($_.Filename):$($_.LineNumber)" }) -join ', ')) } } From 38db2c26e0b2e03cdb94d735f96c2544e7d04a7d Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 17:43:19 -0700 Subject: [PATCH 19/53] feat(context): gate context kind against endpoint scope and require one where needed --- Private/Assert-PfbContextSupported.ps1 | 131 ++++++++++++ Private/Invoke-PfbApiRequest.ps1 | 16 +- Tests/Assert-PfbContextSupported.Tests.ps1 | 197 ++++++++++++++++++ ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 66 +++++- Tests/Invoke-PfbApiRequest.Tests.ps1 | 7 + 5 files changed, 412 insertions(+), 5 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index 121c8740..5d9ad035 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -48,6 +48,35 @@ function Test-PfbEndpointDeclaresContextNames { return (@($EndpointEntry.parameters.PSObject.Properties.Name) -contains $script:PfbContextParameterName) } +function Get-PfbEndpointContextScope { + <# + .SYNOPSIS + Reads the capability map's contextScope for a method and endpoint. + .DESCRIPTION + ONE home for this lookup, for the same reason Get-PfbEndpointKey exists: both scope gates + must agree, and both must degrade identically on absent metadata. Every read of + contextScope goes through here -- never by indexing the map's contextScope member at a + call site. + + Returns 'unknown' for a missing map, a missing entry, or an entry with no contextScope, + so a caller has exactly one sentinel to test rather than three shapes of absence. + .OUTPUTS + [string] + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][string]$Method, + [Parameter(Mandatory)][string]$Endpoint, + [Parameter()][AllowNull()]$CapabilityMap + ) + + if (-not $CapabilityMap) { return 'unknown' } + $entry = $CapabilityMap.endpoints.(Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint) + if (-not $entry -or -not $entry.contextScope) { return 'unknown' } + $entry.contextScope.scope +} + function Assert-PfbContextCapability { <# .SYNOPSIS @@ -151,3 +180,105 @@ function Assert-PfbContextCardinality { $names = @($Context.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ', ' throw "$key accepts only one context, but $(@($Context.Entries).Count) were given ($names). Narrow the context to a single name. To target every array in a fleet or topology group with one context, use -AllArrays instead of listing members." } + +function Assert-PfbContextKindMatchesScope { + <# + .SYNOPSIS + Throws when the context's KIND cannot address the endpoint's scope. + .DESCRIPTION + Reads contextScope from the capability map through Get-PfbEndpointContextScope. One + uniform message rather than relaying the server's grab-bag (code 13 here, code 42 there, + and a silent 200 for a local context). scope 'unknown' -- 19 operations -- SUPPRESSES the + check: the gate must degrade, not throw, on absent metadata. + + Wire truth this encodes: + array-scoped: bare array name OK; .arrays and .arrays OK (fan-out); + bare fleet name rejected (code 42); bare group name rejected (code 42). + fleet-scoped: bare fleet name OK; everything else rejected (code 13), including + .arrays forms and any array name other than the local one -- and the + local one only because middleware short-circuits it before validating, + which is not a scope grant. + + Called only from inside Invoke-PfbApiRequest's + "$null -ne $resolvedContext -and Count -gt 0" block, so a non-null / non-empty re-check + here would be unreachable -- same contract as Assert-PfbContextCardinality. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Method, + [Parameter(Mandatory)][string]$Endpoint, + [Parameter(Mandatory)]$Context, + [Parameter()][AllowNull()]$CapabilityMap + ) + + $scope = Get-PfbEndpointContextScope -Method $Method -Endpoint $Endpoint -CapabilityMap $CapabilityMap + if ($scope -eq 'unknown') { return } + + $key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint + + foreach ($entry in $Context.Entries) { + $wire = ConvertTo-PfbContextWireValue -Entry $entry + + if ($scope -eq 'array') { + # A membership form (.arrays) fans out ACROSS arrays, so it is valid here; a bare + # fleet or group name addresses an object that is not an array, so it is not. + if ($entry.Form -eq 'Object' -and $entry.Kind -ne 'Array') { + throw "$key is array-scoped, so '$wire' is not a valid context for it: a $($entry.Kind.ToLowerInvariant()) name addresses a $($entry.Kind.ToLowerInvariant())-level object, not an array. Use a member array name, or '$($entry.Name).arrays' to target every array in it." + } + } + elseif ($scope -eq 'fleet') { + if ($entry.Kind -ne 'Fleet' -or $entry.Form -ne 'Object') { + throw "$key targets a fleet-scoped resource, which requires a bare fleet context; '$wire' is not one. Set a fleet context with Set-PfbContext -Context -Kind Fleet, or run this call in one with Invoke-PfbInContext -Context -Kind Fleet { ... }. Get the fleet name from Get-PfbFleet." + } + } + } +} + +function Assert-PfbContextRequired { + <# + .SYNOPSIS + Throws when a fleet-scoped endpoint needs a fleet context and none is set. + .DESCRIPTION + Open question 7. On a fleet-scoped endpoint, omitting context_names does not resolve to a + usable local view -- it fails, and confusingly: POST returns code 13 "Creating a preset in + the array context is not supported", PUT/DELETE return code 6 "Preset does not exist", and + a NAME-SCOPED GET returns code 6 as well. Throwing here names the requirement and the + cmdlet that satisfies it instead. + + THE ONE EXCEPTION, and it is not the verb: an UNFILTERED read with no context WORKS, + returning the locally replicated copy. The local view is list-only -- sufficient to + enumerate, insufficient to resolve a name against -- so any call targeting by names= or + ids= is in the mutation case regardless of its verb, and an unfiltered list is not. Keying + this on the verb alone would break Get-PfbPresetWorkload, the only preset operation that + works today. + + Called from the ELSE branch in Invoke-PfbApiRequest, so unlike the three shape gates this + one legitimately sees BOTH the unset and the explicitly-empty context. That is deliberate: + on a fleet-scoped mutation or name-scoped read, an explicit @() is exactly as broken as + omitting the context. Do NOT add an empty-context bypass. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Method, + [Parameter(Mandatory)][string]$Endpoint, + [Parameter()][AllowNull()][hashtable]$QueryParams, + [Parameter()][AllowNull()]$CapabilityMap + ) + + if ((Get-PfbEndpointContextScope -Method $Method -Endpoint $Endpoint -CapabilityMap $CapabilityMap) -ne 'fleet') { + return + } + + if ($Method -eq 'GET') { + $isNameScoped = $false + if ($QueryParams) { + foreach ($selector in 'names', 'ids') { + if ($QueryParams.ContainsKey($selector) -and $QueryParams[$selector]) { $isNameScoped = $true } + } + } + if (-not $isNameScoped) { return } # unfiltered list: works without a context + } + + $key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint + throw "$key targets a fleet-scoped resource and requires a fleet context, but none is set. Set one with Set-PfbContext -Context -Kind Fleet, or run this call in one with Invoke-PfbInContext -Context -Kind Fleet { ... }. Get the fleet name from Get-PfbFleet." +} diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index 6a93d65e..b9d1087f 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -50,16 +50,23 @@ function Invoke-PfbApiRequest { # through to any lower-precedence context. Hence -ne $null plus an explicit count, never # truthiness on the context object. $resolvedContext = Resolve-PfbRequestContext -Array $Array -QueryParams $QueryParams + # Loaded once, above the branch, because BOTH branches gate on it now. Get-PfbCapabilityMap + # memoizes, but hoisting it also guarantees the two branches rule on the same object. + $capabilityMap = Get-PfbCapabilityMap if ($null -ne $resolvedContext -and @($resolvedContext.Entries).Count -gt 0) { # Gate before injecting: an endpoint with no recorded context_names support silently # accepts the parameter on the wire, so the array will never tell the caller. - $capabilityMap = Get-PfbCapabilityMap Assert-PfbContextCapability -Array $Array -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap # Second gate: a multi-value context on an endpoint that accepts exactly one returns # 400 code 15 with no hint about the fix, so translate it client-side. Assert-PfbContextCardinality -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap + # Third gate: the context's KIND must be able to address the endpoint's scope. Runs AFTER + # the two above on purpose -- a wrong-kind context aimed at an endpoint that takes no + # context at all should hear about that first. + Assert-PfbContextKindMatchesScope -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap + # Clone first: $QueryParams is a reference to the CALLER's hashtable, and a targeting # parameter must not leak back into a hashtable the caller may reuse for another call. # Assigning the clone to the local also means the -AutoPaginate loop below rebuilds @@ -68,6 +75,13 @@ function Invoke-PfbApiRequest { $QueryParams[$script:PfbContextParameterName] = @($resolvedContext.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ',' } + else { + # A fleet-scoped endpoint has no usable no-context default for a mutation or a + # name-scoped read. An explicit @() is still the caller saying "locally", so it reaches + # here too -- and for those calls that is exactly as broken as omitting the context. + # An unfiltered fleet-scoped read is exempt; the gate handles that distinction. + Assert-PfbContextRequired -Method $Method -Endpoint $Endpoint -QueryParams $QueryParams -CapabilityMap $capabilityMap + } # Fail fast if the connected array's REST version doesn't support this endpoint/param/ # field, before any network call is made. Never sent if incompatible: see diff --git a/Tests/Assert-PfbContextSupported.Tests.ps1 b/Tests/Assert-PfbContextSupported.Tests.ps1 index 197f8a1e..f6a9ffe8 100644 --- a/Tests/Assert-PfbContextSupported.Tests.ps1 +++ b/Tests/Assert-PfbContextSupported.Tests.ps1 @@ -207,3 +207,200 @@ Describe 'Test-PfbEndpointDeclaresContextNames' { } } } + +# Task 10. Scaffolding rules, all four load-bearing: +# +# 1. InModuleScope goes INSIDE each It. Describe-level fails at discovery in this repo, so the +# block silently never runs. Every function called below is private, so outside module scope +# they all raise CommandNotFoundException. +# 2. The map is a plain LOCAL rebuilt per It, never `$script:map` from a BeforeEach: a `$script:` +# assignment inside InModuleScope writes to the MODULE's script scope and leaks past this file. +# Get-PfbCapabilityMap memoizes, so re-calling it is nearly free. +# 3. These tests deliberately run against the REAL shipped map rather than a fixture, because the +# point is partly to pin the shipped contextScope data (see the map-literal test). That is the +# opposite choice from the capability-gate tests above and it is intentional. +# 4. Every throw assertion pins a message substring taken from the actual `throw`. A bare +# `Should -Throw` is satisfied by CommandNotFoundException, so it would pass in RED before the +# function exists and keep passing however the real throw is worded. +Describe 'Assert-PfbContextKindMatchesScope' { + It 'rejects a bare fleet name on an array-scoped endpoint' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + $fleet = New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet')) + { Assert-PfbContextKindMatchesScope -Method 'GET' -Endpoint 'file-systems' -Context $fleet -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*array-scoped*' + } + } + It 'suggests .arrays in the array-scoped rejection' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + $fleet = New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet')) + { Assert-PfbContextKindMatchesScope -Method 'GET' -Endpoint 'file-systems' -Context $fleet -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*.arrays*' + } + } + It 'allows an array name on an array-scoped endpoint' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + $arr = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + { Assert-PfbContextKindMatchesScope -Method 'GET' -Endpoint 'file-systems' -Context $arr -CapabilityMap $map } | + Should -Not -Throw + } + } + It 'allows .arrays on an array-scoped endpoint' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + $fanout = New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet' -Form 'AllArrays')) + { Assert-PfbContextKindMatchesScope -Method 'GET' -Endpoint 'file-systems' -Context $fanout -CapabilityMap $map } | + Should -Not -Throw + } + } + It 'rejects an array name on a fleet-scoped WRITE, on every write verb' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + $arr = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + foreach ($m in 'POST', 'PUT', 'PATCH', 'DELETE') { + { Assert-PfbContextKindMatchesScope -Method $m -Endpoint 'presets/workload' -Context $arr -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*requires a bare fleet context*' + } + } + } + It 'allows a bare fleet name on a fleet-scoped write' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + $fleet = New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet')) + { Assert-PfbContextKindMatchesScope -Method 'POST' -Endpoint 'presets/workload' -Context $fleet -CapabilityMap $map } | + Should -Not -Throw + } + } + It 'rejects .arrays on a fleet-scoped endpoint (code 13 on the wire)' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + $fanout = New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet' -Form 'AllArrays')) + { Assert-PfbContextKindMatchesScope -Method 'POST' -Endpoint 'presets/workload' -Context $fanout -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*requires a bare fleet context*' + } + } + It 'rejects an array name on GET /presets/workload and allows the fleet name' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # Ruling: fleet. GET behaves like the writes. Measured from a REMOTE member (so the + # local short-circuit cannot mask it): a remote array name and .arrays both + # return code 13, and only the bare fleet name is accepted, on every verb. + $map = Get-PfbCapabilityMap + $arr = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $fleet = New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet')) + { Assert-PfbContextKindMatchesScope -Method 'GET' -Endpoint 'presets/workload' -Context $arr -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*requires a bare fleet context*' + { Assert-PfbContextKindMatchesScope -Method 'GET' -Endpoint 'presets/workload' -Context $fleet -CapabilityMap $map } | + Should -Not -Throw + } + } + It 'throws for a LOCAL array name on GET /presets/workload too' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # The server returns 200 here via the short-circuit. The module diverges on purpose: + # a cmdlet that works only when the context happens to name the local array is a + # worse contract than one that fails consistently. + $map = Get-PfbCapabilityMap + $local = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-A')) + { Assert-PfbContextKindMatchesScope -Method 'GET' -Endpoint 'presets/workload' -Context $local -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*requires a bare fleet context*' + } + } + It "pins the map literal so a regeneration cannot silently flip this test's meaning" { + InModuleScope 'PureStorageFlashBladePowerShell' { + # Without this, a map regressing to scope=array turns every assertion above into a + # test of the opposite behaviour that still passes. + $map = Get-PfbCapabilityMap + $map.endpoints.'GET /presets/workload'.contextScope.scope | Should -Be 'fleet' + } + } + It 'suppresses the check entirely for contextScope unknown' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # 19 operations are 'unknown'. The gate must DEGRADE on absent metadata, not throw. + $map = Get-PfbCapabilityMap + $fleet = New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet')) + $unknown = @($map.endpoints.PSObject.Properties | + Where-Object { $_.Value.contextScope.scope -eq 'unknown' }) + @($unknown).Count | Should -BeGreaterThan 0 -Because 'this test and its Assert-PfbContextRequired twin index [0] of this list, and would fail on a null index if the shipped map had none' + $parts = $unknown[0].Name -split ' ', 2 + { Assert-PfbContextKindMatchesScope -Method $parts[0] -Endpoint $parts[1].TrimStart('/') -Context $fleet -CapabilityMap $map } | + Should -Not -Throw + } + } + It 'rejects a TopologyGroup AllArrays context on a fleet-scoped endpoint' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + $grp = New-PfbContext -Entries @((New-PfbContextEntry -Name 'zz-claude-tg-parent' -Kind 'TopologyGroup' -Form 'AllArrays')) + { Assert-PfbContextKindMatchesScope -Method 'POST' -Endpoint 'presets/workload' -Context $grp -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*requires a bare fleet context*' + } + } +} + +Describe 'Assert-PfbContextRequired' { + # Same four scaffolding rules as above. Note this gate is called from the ELSE branch in + # Invoke-PfbApiRequest, so unlike the three shape gates it legitimately sees BOTH the unset + # and the explicitly-empty context. Do not add an empty-context bypass to it. + It 'throws for a fleet-scoped mutation with no context, before the wire' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + foreach ($m in 'POST', 'PUT', 'PATCH', 'DELETE') { + { Assert-PfbContextRequired -Method $m -Endpoint 'presets/workload' -QueryParams $null -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*Set-PfbContext*' + } + } + } + It 'names Invoke-PfbInContext as the per-call alternative' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + { Assert-PfbContextRequired -Method 'POST' -Endpoint 'presets/workload' -QueryParams $null -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*Invoke-PfbInContext*' + } + } + It 'does NOT throw for an unfiltered GET on a fleet-scoped endpoint' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # The unfiltered read with no context WORKS -- it returns the locally replicated copy, + # and it is the only preset operation that functions on main today. Throwing here + # would break Get-PfbPresetWorkload. + $map = Get-PfbCapabilityMap + { Assert-PfbContextRequired -Method 'GET' -Endpoint 'presets/workload' -QueryParams $null -CapabilityMap $map } | + Should -Not -Throw + { Assert-PfbContextRequired -Method 'GET' -Endpoint 'presets/workload' -QueryParams @{ limit = 10 } -CapabilityMap $map } | + Should -Not -Throw + } + } + It 'DOES throw for a name-scoped GET on a fleet-scoped endpoint' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # ?names= with no context returns code 6 "Preset does not exist." The local view is + # list-only: enough to enumerate, not enough to resolve a name against. This is the + # case a verb-shaped gate misses in the other direction. + $map = Get-PfbCapabilityMap + { Assert-PfbContextRequired -Method 'GET' -Endpoint 'presets/workload' -QueryParams @{ names = 'p1' } -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*Set-PfbContext*' + } + } + It 'treats ?ids= the same as ?names=' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + { Assert-PfbContextRequired -Method 'GET' -Endpoint 'presets/workload' -QueryParams @{ ids = 'abc' } -CapabilityMap $map } | + Should -Throw -ExpectedMessage '*requires a fleet context*' + } + } + It 'does not throw for an array-scoped endpoint with no context, name-scoped or not' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + { Assert-PfbContextRequired -Method 'GET' -Endpoint 'file-systems' -QueryParams @{ names = 'fs1' } -CapabilityMap $map } | Should -Not -Throw + { Assert-PfbContextRequired -Method 'POST' -Endpoint 'file-systems' -QueryParams $null -CapabilityMap $map } | Should -Not -Throw + } + } + It 'does not throw for an unknown-scope endpoint with no context' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + $unknown = @($map.endpoints.PSObject.Properties | Where-Object { $_.Value.contextScope.scope -eq 'unknown' }) + @($unknown).Count | Should -BeGreaterThan 0 + $parts = $unknown[0].Name -split ' ', 2 + { Assert-PfbContextRequired -Method $parts[0] -Endpoint $parts[1].TrimStart('/') -QueryParams $null -CapabilityMap $map } | + Should -Not -Throw + } + } +} diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index ac8a5c47..ea040eb9 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -147,7 +147,7 @@ Describe 'context injection in Invoke-PfbApiRequest' { # unwired from the request path unnoticed. Tasks 10 and 11 add two more gates to this same site, # so the wiring gets its own detector now. Describe 'context gate wiring in Invoke-PfbApiRequest' { - It 'calls both context gates, capability before cardinality, when a context is set' { + It 'calls all three shape gates in order, capability before cardinality before kindMatchesScope' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' @@ -160,14 +160,16 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $calls = [System.Collections.Generic.List[object]]::new() Mock -CommandName Assert-PfbContextCapability -MockWith { $calls.Add('capability') } Mock -CommandName Assert-PfbContextCardinality -MockWith { $calls.Add('cardinality') } + Mock -CommandName Assert-PfbContextKindMatchesScope -MockWith { $calls.Add('kindMatchesScope') } Mock -CommandName Assert-PfbApiCapability -MockWith {} Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null - @($calls).Count | Should -Be 2 -Because 'both gates must fire from the request path; a count of 1 means one call was deleted or never wired' + @($calls).Count | Should -Be 3 -Because 'all three shape gates must fire from the request path; a lower count means one call was deleted or never wired' $calls[0] | Should -Be 'capability' -Because 'the capability gate must rule on "endpoint takes no context at all" first' $calls[1] | Should -Be 'cardinality' + $calls[2] | Should -Be 'kindMatchesScope' -Because 'it runs after cardinality: a wrong-KIND context aimed at an endpoint that takes no context at all should hear about capability first, not about scope' } } It 'passes each gate the resolved context and the shared capability map' { @@ -185,12 +187,15 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { Mock -CommandName Assert-PfbContextCardinality -MockWith { $seen.Add([PSCustomObject]@{ Gate = 'cardinality'; Names = @($Context.Entries.Name) -join ','; Endpoint = $Endpoint; HasMap = ($null -ne $CapabilityMap) }) } + Mock -CommandName Assert-PfbContextKindMatchesScope -MockWith { + $seen.Add([PSCustomObject]@{ Gate = 'kindMatchesScope'; Names = @($Context.Entries.Name) -join ','; Endpoint = $Endpoint; HasMap = ($null -ne $CapabilityMap) }) + } Mock -CommandName Assert-PfbApiCapability -MockWith {} Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null - @($seen).Count | Should -Be 2 + @($seen).Count | Should -Be 3 foreach ($record in $seen) { $record.Names | Should -Be 'FB-B' -Because "$($record.Gate) must see the RESOLVED context, not a raw parameter" $record.Endpoint | Should -Be 'file-systems' @@ -198,7 +203,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { } } } - It 'calls neither gate when no context is set' { + It 'calls none of the three shape gates when no context is set' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' @@ -208,6 +213,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $calls = [System.Collections.Generic.List[object]]::new() Mock -CommandName Assert-PfbContextCapability -MockWith { $calls.Add('capability') } Mock -CommandName Assert-PfbContextCardinality -MockWith { $calls.Add('cardinality') } + Mock -CommandName Assert-PfbContextKindMatchesScope -MockWith { $calls.Add('kindMatchesScope') } Mock -CommandName Assert-PfbApiCapability -MockWith {} Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } @@ -216,4 +222,56 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { @($calls).Count | Should -Be 0 } } + # The It above counts only the three SHAPE gates, so it passes whether or not the + # required-context gate is wired at all -- it looks like coverage of the else branch and is + # not. This is the detector for that call site: its own List, its own count. + It 'calls the required-context gate exactly once, with the caller query params and the shared map, when no context is set' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + } + $required = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Assert-PfbContextRequired -MockWith { + $required.Add([PSCustomObject]@{ + Method = $Method + Endpoint = $Endpoint + Limit = if ($null -ne $QueryParams -and $QueryParams.ContainsKey('limit')) { $QueryParams['limit'] } else { $null } + HasMap = ($null -ne $CapabilityMap) + }) + } + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } + + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' -QueryParams @{ limit = 5 } | Out-Null + + @($required).Count | Should -Be 1 -Because 'the else branch must gate the context-free call; a count of 0 means the call was deleted or never wired' + $required[0].Method | Should -Be 'GET' + $required[0].Endpoint | Should -Be 'file-systems' + $required[0].Limit | Should -Be 5 -Because 'it must see the CALLER query params, which carry the names=/ids= selectors it discriminates on' + $required[0].HasMap | Should -BeTrue -Because 'without the map it reads every scope as unknown and silently no-ops' + } + } + It 'calls the required-context gate for an explicit empty context too' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # An explicit @() is the caller saying "locally", which on a fleet-scoped mutation is + # exactly as broken as omitting the context -- so it must reach the gate, not bypass it. + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) + ContextOverride = (New-PfbContext -Entries @()); AuthorizationModel = $null + } + $required = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Assert-PfbContextRequired -MockWith { $required.Add($Endpoint) } + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } + + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null + + @($required).Count | Should -Be 1 + $required[0] | Should -Be 'file-systems' + } + } } diff --git a/Tests/Invoke-PfbApiRequest.Tests.ps1 b/Tests/Invoke-PfbApiRequest.Tests.ps1 index 84dfba8e..ce2d65ee 100644 --- a/Tests/Invoke-PfbApiRequest.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.Tests.ps1 @@ -74,6 +74,11 @@ Describe 'Invoke-PfbApiRequest - PUT support' { $array = [PSCustomObject]@{ Endpoint = 'fb.test'; ApiVersion = '2.26'; AuthToken = 'tok' ApiToken = $null; AuthMethod = 'ApiToken'; SkipCertificateCheck = $false + # /presets/workload is fleet-scoped, so a context-free PUT to it is now rejected + # before the wire by Assert-PfbContextRequired -- correctly: that call cannot + # work. This It is about body serialisation, so give it the fleet context the + # endpoint requires rather than weakening the gate. + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet'))) } Invoke-PfbApiRequest -Array $array -Method PUT -Endpoint 'presets/workload' -Body @{ name = 'p1' } | Out-Null } @@ -92,6 +97,8 @@ Describe 'Invoke-PfbApiRequest - PUT support' { $array = [PSCustomObject]@{ Endpoint = 'fb.test'; ApiVersion = '2.26'; AuthToken = 'tok' ApiToken = $null; AuthMethod = 'ApiToken'; SkipCertificateCheck = $false + # Fleet-scoped endpoint: see the note in the previous It. + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet'))) } Invoke-PfbApiRequest -Array $array -Method PUT -Endpoint 'presets/workload' | Out-Null } From 164a9c1b54ee7053dcb65cd05a22e23f995e8db4 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 18:30:18 -0700 Subject: [PATCH 20/53] fix(context): let the version gate outrank the required-context gate --- Private/Assert-PfbContextSupported.ps1 | 21 +++++++++-- Private/Invoke-PfbApiRequest.ps1 | 35 +++++++++++++----- Tests/Assert-PfbContextSupported.Tests.ps1 | 36 ++++++++++++++----- ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 26 ++++++++++++++ 4 files changed, 98 insertions(+), 20 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index 5d9ad035..88df1183 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -192,8 +192,17 @@ function Assert-PfbContextKindMatchesScope { check: the gate must degrade, not throw, on absent metadata. Wire truth this encodes: - array-scoped: bare array name OK; .arrays and .arrays OK (fan-out); - bare fleet name rejected (code 42); bare group name rejected (code 42). + array-scoped: bare array name OK; fleet-dot-arrays and group-dot-arrays OK (fan-out); + bare fleet name rejected (code 42). + A bare GROUP name is invalid on the wire too (code 42) but never reaches + this gate: ConvertTo-PfbContextWireValue runs first on each entry and + Assert-PfbContextEntryComposition rejects TopologyGroup+Object outright, + for EVERY endpoint rather than only array-scoped ones. That is the correct + home for it -- the combination addresses nothing anywhere -- so the doc + claim, not the code, was what needed fixing. The array branch below stays + written as the general "Kind is not Array" predicate rather than being + narrowed to Fleet: only Fleet can reach it today, and a fourth Kind would + be handled without an edit. fleet-scoped: bare fleet name OK; everything else rejected (code 13), including .arrays forms and any array name other than the local one -- and the local one only because middleware short-circuits it before validating, @@ -212,6 +221,11 @@ function Assert-PfbContextKindMatchesScope { ) $scope = Get-PfbEndpointContextScope -Method $Method -Endpoint $Endpoint -CapabilityMap $CapabilityMap + # Belt and braces, and NOT redundant even though it is currently behaviour-neutral: the loop + # below already no-ops for any scope that is neither 'array' nor 'fleet', so deleting this + # line changes nothing today and no test can pin it. It exists so that the day a third scope + # value gains a branch down there, 'unknown' does not silently acquire that branch's meaning. + # Do not delete it on the grounds that it is untested. if ($scope -eq 'unknown') { return } $key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint @@ -221,7 +235,8 @@ function Assert-PfbContextKindMatchesScope { if ($scope -eq 'array') { # A membership form (.arrays) fans out ACROSS arrays, so it is valid here; a bare - # fleet or group name addresses an object that is not an array, so it is not. + # fleet name addresses an object that is not an array, so it is not. (A bare group + # name cannot reach this line -- see the .DESCRIPTION note.) if ($entry.Form -eq 'Object' -and $entry.Kind -ne 'Array') { throw "$key is array-scoped, so '$wire' is not a valid context for it: a $($entry.Kind.ToLowerInvariant()) name addresses a $($entry.Kind.ToLowerInvariant())-level object, not an array. Use a member array name, or '$($entry.Name).arrays' to target every array in it." } diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index b9d1087f..d42e44d1 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -50,10 +50,18 @@ function Invoke-PfbApiRequest { # through to any lower-precedence context. Hence -ne $null plus an explicit count, never # truthiness on the context object. $resolvedContext = Resolve-PfbRequestContext -Array $Array -QueryParams $QueryParams - # Loaded once, above the branch, because BOTH branches gate on it now. Get-PfbCapabilityMap + + # ONE home for the tri-state predicate. Both branches need it and they are no longer + # adjacent (the required-context gate moved below Assert-PfbApiCapability -- see there), so a + # second copy would have to stay in exact negated agreement forever. $hasContext is a plain + # [bool], so "-not $hasContext" is legitimate: the truthiness ban is on the context OBJECT, + # which is a PSCustomObject and therefore unconditionally truthy. + $hasContext = ($null -ne $resolvedContext -and @($resolvedContext.Entries).Count -gt 0) + + # Loaded once, above the branch, because BOTH branches gate on it. Get-PfbCapabilityMap # memoizes, but hoisting it also guarantees the two branches rule on the same object. $capabilityMap = Get-PfbCapabilityMap - if ($null -ne $resolvedContext -and @($resolvedContext.Entries).Count -gt 0) { + if ($hasContext) { # Gate before injecting: an endpoint with no recorded context_names support silently # accepts the parameter on the wire, so the array will never tell the caller. Assert-PfbContextCapability -Array $Array -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap @@ -75,19 +83,30 @@ function Invoke-PfbApiRequest { $QueryParams[$script:PfbContextParameterName] = @($resolvedContext.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ',' } - else { + + # Fail fast if the connected array's REST version doesn't support this endpoint/param/ + # field, before any network call is made. Never sent if incompatible: see + # Assert-PfbApiCapability's header for why an unrecognized endpoint is a silent no-op. + Assert-PfbApiCapability -Array $Array -Method $Method -Endpoint $Endpoint -Body $Body -QueryParams $QueryParams -ApiVersion $ApiVersionOverride + + if (-not $hasContext) { # A fleet-scoped endpoint has no usable no-context default for a mutation or a # name-scoped read. An explicit @() is still the caller saying "locally", so it reaches # here too -- and for those calls that is exactly as broken as omitting the context. # An unfiltered fleet-scoped read is exempt; the gate handles that distinction. + # + # Deliberately AFTER Assert-PfbApiCapability. Do NOT move this back above the version + # gate. Unlike the three shape gates, this one injects nothing, so it has no ordering + # requirement against Assert-PfbApiCapability -- and a version failure is the more + # fundamental fact, so it must win. Measured on an array at REST 2.20 calling + # Remove-PfbPresetWorkload -Name p1 with this call placed FIRST: the caller was told + # "requires a fleet context ... Set one with Set-PfbContext" instead of + # "DELETE /presets/workload requires REST 2.23 ... but the connected array is running + # REST 2.20". The first message cannot be acted on -- an array too old for the endpoint + # has no fleets to name. Assert-PfbContextRequired -Method $Method -Endpoint $Endpoint -QueryParams $QueryParams -CapabilityMap $capabilityMap } - # Fail fast if the connected array's REST version doesn't support this endpoint/param/ - # field, before any network call is made. Never sent if incompatible: see - # Assert-PfbApiCapability's header for why an unrecognized endpoint is a silent no-op. - Assert-PfbApiCapability -Array $Array -Method $Method -Endpoint $Endpoint -Body $Body -QueryParams $QueryParams -ApiVersion $ApiVersionOverride - # Certificate/OAuth2 sessions: proactively refresh the access token before it expires, # rather than waiting for a 401. A proactive refresh generates no failed-authentication # entry in the array's session log, unlike a reactive 401-triggered refresh. diff --git a/Tests/Assert-PfbContextSupported.Tests.ps1 b/Tests/Assert-PfbContextSupported.Tests.ps1 index f6a9ffe8..4111af1a 100644 --- a/Tests/Assert-PfbContextSupported.Tests.ps1 +++ b/Tests/Assert-PfbContextSupported.Tests.ps1 @@ -231,7 +231,9 @@ Describe 'Assert-PfbContextKindMatchesScope' { Should -Throw -ExpectedMessage '*array-scoped*' } } - It 'suggests .arrays in the array-scoped rejection' { + # Titles below say "fleet-dot-arrays", not the angle-bracket form: Pester reads <...> in an It + # name as a -ForEach data placeholder and expands it to $null when there is no data. + It 'suggests fleet-dot-arrays in the array-scoped rejection' { InModuleScope 'PureStorageFlashBladePowerShell' { $map = Get-PfbCapabilityMap $fleet = New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet')) @@ -247,7 +249,7 @@ Describe 'Assert-PfbContextKindMatchesScope' { Should -Not -Throw } } - It 'allows .arrays on an array-scoped endpoint' { + It 'allows fleet-dot-arrays on an array-scoped endpoint' { InModuleScope 'PureStorageFlashBladePowerShell' { $map = Get-PfbCapabilityMap $fanout = New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet' -Form 'AllArrays')) @@ -273,7 +275,7 @@ Describe 'Assert-PfbContextKindMatchesScope' { Should -Not -Throw } } - It 'rejects .arrays on a fleet-scoped endpoint (code 13 on the wire)' { + It 'rejects fleet-dot-arrays on a fleet-scoped endpoint (code 13 on the wire)' { InModuleScope 'PureStorageFlashBladePowerShell' { $map = Get-PfbCapabilityMap $fanout = New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet' -Form 'AllArrays')) @@ -308,8 +310,11 @@ Describe 'Assert-PfbContextKindMatchesScope' { } It "pins the map literal so a regeneration cannot silently flip this test's meaning" { InModuleScope 'PureStorageFlashBladePowerShell' { - # Without this, a map regressing to scope=array turns every assertion above into a - # test of the opposite behaviour that still passes. + # NOT because a scope flip would otherwise pass silently -- measured, it would not: + # flipping GET /presets/workload to scope=array fails four sibling Its independently. + # What this pins buys is LOCALISATION. Without it, a map regression surfaces as a + # scatter of confusing throw/no-throw mismatches across this Describe; with it, one + # obviously-named assertion says "the shipped map changed" and the rest is noise. $map = Get-PfbCapabilityMap $map.endpoints.'GET /presets/workload'.contextScope.scope | Should -Be 'fleet' } @@ -393,13 +398,26 @@ Describe 'Assert-PfbContextRequired' { { Assert-PfbContextRequired -Method 'POST' -Endpoint 'file-systems' -QueryParams $null -CapabilityMap $map } | Should -Not -Throw } } - It 'does not throw for an unknown-scope endpoint with no context' { + It 'does not throw for an unknown-scope endpoint with no context, on either side of the GET split' { InModuleScope 'PureStorageFlashBladePowerShell' { + # The scope early-return is what must be pinned here, so the two cases are chosen + # DELIBERATELY rather than taken from [0] of the unknown list. An unfiltered GET + # returns early on the verb branch as well, so it alone cannot detect a deleted scope + # check; a non-GET, and a name-scoped GET, both reach the throw if the scope check + # goes. Picking [0] and hoping it is a non-GET makes the kill power accidental. $map = Get-PfbCapabilityMap $unknown = @($map.endpoints.PSObject.Properties | Where-Object { $_.Value.contextScope.scope -eq 'unknown' }) - @($unknown).Count | Should -BeGreaterThan 0 - $parts = $unknown[0].Name -split ' ', 2 - { Assert-PfbContextRequired -Method $parts[0] -Endpoint $parts[1].TrimStart('/') -QueryParams $null -CapabilityMap $map } | + $nonGet = @($unknown | Where-Object { $_.Name -notlike 'GET *' }) + $get = @($unknown | Where-Object { $_.Name -like 'GET *' }) + @($nonGet).Count | Should -BeGreaterThan 0 -Because 'a non-GET unknown-scope endpoint is what detects a deleted scope early-return' + @($get).Count | Should -BeGreaterThan 0 -Because 'the name-scoped GET case detects it independently of the verb branch' + + $p = $nonGet[0].Name -split ' ', 2 + { Assert-PfbContextRequired -Method $p[0] -Endpoint $p[1].TrimStart('/') -QueryParams $null -CapabilityMap $map } | + Should -Not -Throw + + $p = $get[0].Name -split ' ', 2 + { Assert-PfbContextRequired -Method $p[0] -Endpoint $p[1].TrimStart('/') -QueryParams @{ names = 'x' } -CapabilityMap $map } | Should -Not -Throw } } diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index ea040eb9..a49b182a 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -253,6 +253,32 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $required[0].HasMap | Should -BeTrue -Because 'without the map it reads every scope as unknown and silently no-ops' } } + # Fix round 1, Important 1. The required-context gate used to run BEFORE the version gate, so + # an array too old for the endpoint was told to "Set one with Set-PfbContext" -- advice it + # cannot follow, because an array below the endpoint's minVersion has no fleets to name. This + # It pins the corrected precedence and fails if the call moves back above the version gate. + # + # Assert-PfbApiCapability is deliberately NOT mocked here: the assertion is about which of two + # REAL gates wins, so mocking either away would make the test vacuous. + It 'lets the version gate win over the required-context gate on a too-old array' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # DELETE /presets/workload is fleet-scoped (so the required-context gate WOULD fire) + # and has minVersion 2.23, above this array's 2.20 (so the version gate fires too). + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.20'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + } + Mock -CommandName Invoke-RestMethod -MockWith { throw 'the request must never be attempted' } + + # '*Upgrade the array*' is unique to Assert-PfbApiCapability's throw and absent from + # the required-context throw, so this substring alone discriminates the two gates. + { Invoke-PfbApiRequest -Array $fb -Method 'DELETE' -Endpoint 'presets/workload' } | + Should -Throw -ExpectedMessage '*Upgrade the array*' + { Invoke-PfbApiRequest -Array $fb -Method 'DELETE' -Endpoint 'presets/workload' } | + Should -Throw -ExpectedMessage '*requires REST 2.23*' + } + } It 'calls the required-context gate for an explicit empty context too' { InModuleScope 'PureStorageFlashBladePowerShell' { # An explicit @() is the caller saying "locally", which on a fleet-scoped mutation is From a49df99b3849a3f38db41c0a04b202a8ff4cf9dd Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 18:34:03 -0700 Subject: [PATCH 21/53] test(context): pin that a satisfied context is not reported as missing --- ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index a49b182a..6b2d552a 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -253,6 +253,31 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $required[0].HasMap | Should -BeTrue -Because 'without the map it reads every scope as unknown and silently no-ops' } } + # Found by mutation during fix round 1: weakening the "-not $hasContext" guard to always-run + # left this whole Describe green. The only tests that caught it were the two PUT-body + # serialisation Its in Tests/Invoke-PfbApiRequest.Tests.ps1 -- incidental coverage in a file + # about something else, which would evaporate the moment those fixtures change. The negation + # matters: run unconditionally, and a fleet-scoped call that HAS a perfectly good fleet + # context is told "requires a fleet context, but none is set". Pin it deliberately. + It 'does NOT call the required-context gate when a context IS set' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet'))) + ContextOverride = $null; AuthorizationModel = $null + } + $required = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Assert-PfbContextRequired -MockWith { $required.Add($Endpoint) } + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } + + # Fleet-scoped endpoint with a valid bare-fleet context: the request must go through. + Invoke-PfbApiRequest -Array $fb -Method 'PUT' -Endpoint 'presets/workload' -Body @{ name = 'p1' } | Out-Null + + @($required).Count | Should -Be 0 -Because 'a satisfied context must not be reported as missing' + } + } # Fix round 1, Important 1. The required-context gate used to run BEFORE the version gate, so # an array too old for the endpoint was told to "Set one with Set-PfbContext" -- advice it # cannot follow, because an array below the endpoint's minVersion has no fleets to name. This From 487ce5866e17b703dda0fa223484a451367e212a Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 18:48:03 -0700 Subject: [PATCH 22/53] fix(context): narrow the name-scoped context requirement to a measured allowlist --- Private/Assert-PfbContextSupported.ps1 | 37 ++++++++++++++++++---- Private/PfbContextConstants.ps1 | 8 +++++ Tests/Assert-PfbContextSupported.Tests.ps1 | 32 +++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index 88df1183..c2e89648 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -260,12 +260,25 @@ function Assert-PfbContextRequired { a NAME-SCOPED GET returns code 6 as well. Throwing here names the requirement and the cmdlet that satisfies it instead. - THE ONE EXCEPTION, and it is not the verb: an UNFILTERED read with no context WORKS, - returning the locally replicated copy. The local view is list-only -- sufficient to - enumerate, insufficient to resolve a name against -- so any call targeting by names= or - ids= is in the mutation case regardless of its verb, and an unfiltered list is not. Keying - this on the verb alone would break Get-PfbPresetWorkload, the only preset operation that - works today. + READS ARE NARROWER THAN THE VERB, TWICE OVER, and both narrowings are measured rather + than derived. + + First: an UNFILTERED read with no context WORKS, returning the locally replicated copy. + Verified on one preset that provably existed (created, probed, deleted): ?names= with + no context returned code 6 while the unfiltered list returned 200 with that same object + in it, and ?names= with a fleet context returned 200. The local view is list-only -- + sufficient to enumerate, insufficient to resolve a name against. Keying this on the verb + alone would break Get-PfbPresetWorkload, the only preset operation that works today. + + Second: even a NAME-SCOPED read only needs a context on the endpoints where that was + measured, listed in $script:PfbNameScopedContextRequiredEndpoints. "scope: fleet" is not + sufficient evidence -- three of the eight fleet-scoped endpoints contradict the + derivation. Measured with no context: GET /topology-groups?names= returns 200 with + 1 item, and /topology-groups/members and /topology-groups/arrays each return 200 with 2 + items. Throwing on those rejected calls the array answers happily. Non-GET verbs stay + unconditional: the five preset write verbs fail without a context, and the topology-group + write verbs are scope: array, so the early return above already covers them (confirmed + live -- those writes succeed with no context). Called from the ELSE branch in Invoke-PfbApiRequest, so unlike the three shape gates this one legitimately sees BOTH the unset and the explicitly-empty context. That is deliberate: @@ -284,6 +297,11 @@ function Assert-PfbContextRequired { return } + # Hoisted above the GET branch so the allowlist is matched against the SAME key the throw + # names, built the one sanctioned way. Never build this key a second way -- see + # Get-PfbEndpointKey on why a one-character drift fails silently. + $key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint + if ($Method -eq 'GET') { $isNameScoped = $false if ($QueryParams) { @@ -292,8 +310,13 @@ function Assert-PfbContextRequired { } } if (-not $isNameScoped) { return } # unfiltered list: works without a context + + # And a name-scoped read only needs a context where that was MEASURED. scope: fleet is not + # sufficient evidence: three of the eight fleet-scoped endpoints -- GET /topology-groups, + # /topology-groups/members, /topology-groups/arrays -- answer a name-scoped context-free + # read with 200, so deriving the rule from scope alone rejected working calls. + if ($key -notin $script:PfbNameScopedContextRequiredEndpoints) { return } } - $key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint throw "$key targets a fleet-scoped resource and requires a fleet context, but none is set. Set one with Set-PfbContext -Context -Kind Fleet, or run this call in one with Invoke-PfbInContext -Context -Kind Fleet { ... }. Get the fleet name from Get-PfbFleet." } diff --git a/Private/PfbContextConstants.ps1 b/Private/PfbContextConstants.ps1 index cc655273..9f9055cb 100644 --- a/Private/PfbContextConstants.ps1 +++ b/Private/PfbContextConstants.ps1 @@ -11,3 +11,11 @@ # duplication #74 is about. $script:PfbContextParameterName = 'context_names' $script:PfbAllowErrorsParameterName = 'allow_errors' + +# Fleet-scoped endpoints where a NAME-SCOPED read genuinely cannot resolve without a fleet +# context. Measured, not derived: GET /presets/workload?names= returns +# code 6 with no context and 200 with a fleet context, because the locally replicated view is +# list-only. The three fleet-scoped topology-group GETs are deliberately ABSENT -- a +# name-scoped context-free read returns 200 there, so throwing would reject a working call. +# Add an endpoint here only with a measurement; absent evidence, do not throw. +$script:PfbNameScopedContextRequiredEndpoints = @('GET /presets/workload') diff --git a/Tests/Assert-PfbContextSupported.Tests.ps1 b/Tests/Assert-PfbContextSupported.Tests.ps1 index 4111af1a..b845ee14 100644 --- a/Tests/Assert-PfbContextSupported.Tests.ps1 +++ b/Tests/Assert-PfbContextSupported.Tests.ps1 @@ -391,6 +391,38 @@ Describe 'Assert-PfbContextRequired' { Should -Throw -ExpectedMessage '*requires a fleet context*' } } + # Fix round 2. Live-measured on FB-B/FB-C with NO context set: a name-scoped read on the three + # fleet-scoped topology-group GETs returns 200, so the scope-derived rule was rejecting calls + # the array answers happily. The rule is now an evidence-backed allowlist. + # GET /topology-groups?names=zz-claude-tg-parent -> 200, 1 item + # GET /topology-groups/members?topology_group_names= -> 200, 2 items + # GET /topology-groups/arrays?topology_group_names= -> 200, 2 items + It 'does not throw for a name-scoped context-free GET on the topology-group endpoints' -ForEach @( + @{ Ep = 'topology-groups' } + @{ Ep = 'topology-groups/members' } + @{ Ep = 'topology-groups/arrays' } + ) { + InModuleScope 'PureStorageFlashBladePowerShell' -Parameters @{ Ep = $Ep } { + param($Ep) + $map = Get-PfbCapabilityMap + # Pin that these really are fleet-scoped, so the test cannot be satisfied by the scope + # early-return instead of the allowlist -- that would make it prove nothing. + $map.endpoints."GET /$Ep".contextScope.scope | Should -Be 'fleet' + { Assert-PfbContextRequired -Method 'GET' -Endpoint $Ep -QueryParams @{ names = 'zz-claude-tg-parent' } -CapabilityMap $map } | + Should -Not -Throw + { Assert-PfbContextRequired -Method 'GET' -Endpoint $Ep -QueryParams @{ ids = 'abc' } -CapabilityMap $map } | + Should -Not -Throw + } + } + It 'pins the name-scoped allowlist to what was actually measured' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # Makes "someone widened this without a measurement" a test failure in both directions. + $script:PfbNameScopedContextRequiredEndpoints | Should -Contain 'GET /presets/workload' + foreach ($ep in 'GET /topology-groups', 'GET /topology-groups/members', 'GET /topology-groups/arrays') { + $script:PfbNameScopedContextRequiredEndpoints | Should -Not -Contain $ep -Because "a name-scoped context-free read returns 200 on $ep, so requiring a context there rejects a working call" + } + } + } It 'does not throw for an array-scoped endpoint with no context, name-scoped or not' { InModuleScope 'PureStorageFlashBladePowerShell' { $map = Get-PfbCapabilityMap From ff902d39056c5cf3aa9a3865c7a7facd3d4696fc Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 19:08:25 -0700 Subject: [PATCH 23/53] test(context): correct an inaccurate -Because on the unknown-scope partition The GET partition's -Because claimed it detects a deleted scope early-return independently of the verb branch. Measured false: the allowlist added in fix round 2 returns before the throw for every unknown-scope GET, so the non-GET partition is what kills that mutation. The assertion is still needed -- it keeps the GET probe from vacuously passing on a $null key -- but its stated reason was wrong, the same defect class as the map-pin comment corrected in fix round 1. Co-Authored-By: Claude Opus 5 --- Tests/Assert-PfbContextSupported.Tests.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Tests/Assert-PfbContextSupported.Tests.ps1 b/Tests/Assert-PfbContextSupported.Tests.ps1 index b845ee14..c68b266f 100644 --- a/Tests/Assert-PfbContextSupported.Tests.ps1 +++ b/Tests/Assert-PfbContextSupported.Tests.ps1 @@ -442,7 +442,12 @@ Describe 'Assert-PfbContextRequired' { $nonGet = @($unknown | Where-Object { $_.Name -notlike 'GET *' }) $get = @($unknown | Where-Object { $_.Name -like 'GET *' }) @($nonGet).Count | Should -BeGreaterThan 0 -Because 'a non-GET unknown-scope endpoint is what detects a deleted scope early-return' - @($get).Count | Should -BeGreaterThan 0 -Because 'the name-scoped GET case detects it independently of the verb branch' + # The GET partition is asserted non-empty so the GET probe below exercises a real + # endpoint rather than vacuously passing on a $null key. It does NOT contribute kill + # power against a deleted scope early-return: the allowlist added in fix round 2 + # returns before the throw for every unknown-scope GET, so the non-GET partition above + # is what detects that mutation. Measured, not assumed. + @($get).Count | Should -BeGreaterThan 0 -Because 'the GET probe below needs a real unknown-scope GET endpoint to be meaningful' $p = $nonGet[0].Name -split ' ', 2 { Assert-PfbContextRequired -Method $p[0] -Endpoint $p[1].TrimStart('/') -QueryParams $null -CapabilityMap $map } | From 833c908e9e4e66af8728945a3849fc41966d852b Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 19:31:39 -0700 Subject: [PATCH 24/53] feat(context): pre-validate the admin authorization model, failing open when unknown --- Private/Assert-PfbContextSupported.ps1 | 78 +++++++++++++++++++ Private/Invoke-PfbApiRequest.ps1 | 6 ++ Public/Connection/Connect-PfbArray.ps1 | 6 ++ Public/Context/Set-PfbContext.ps1 | 4 + Tests/Assert-PfbContextSupported.Tests.ps1 | 47 +++++++++++ Tests/Connect-PfbArray.Context.Tests.ps1 | 26 +++++++ ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 20 ++++- Tests/Set-PfbContext.Tests.ps1 | 32 ++++++++ 8 files changed, 216 insertions(+), 3 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index c2e89648..ec776e7d 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -320,3 +320,81 @@ function Assert-PfbContextRequired { throw "$key targets a fleet-scoped resource and requires a fleet context, but none is set. Set one with Set-PfbContext -Context -Kind Fleet, or run this call in one with Invoke-PfbInContext -Context -Kind Fleet { ... }. Get the fleet name from Get-PfbFleet." } + +function Resolve-PfbAuthorizationModel { + <# + .SYNOPSIS + Best-effort read of the connected admin's authorization_model. + .DESCRIPTION + Only LDAP/SAML remote admins are 'dynamic'. Since 4.5.0 an admin can create additional + named LOCAL users with the same privileges, and the 4.8.1 service-account admin type + is also local -- so pureuser, custom local users and service accounts are ALL 'static'. + This is not "pureuser vs everyone". + + Returns $null rather than throwing on any failure. GET /admins can 403 under a + restrictive management-access policy, and an OAuth2 client may have no username to + match. An indeterminate model must never fail a Connect-PfbArray, because this data + drives a diagnostic and not a correctness gate. + + No recursion risk despite calling Invoke-PfbApiRequest: this runs at connect time, when + the connection's DefaultContext and ContextOverride are both still $null, so + Invoke-PfbApiRequest's $hasContext is false and none of the four shape gates -- including + Assert-PfbContextAuthorizationModel, which is the only one that would read back into this + state -- can fire. + .OUTPUTS + [string] + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)][PSCustomObject]$Array) + + if (-not $Array.Username) { return $null } + try { + $response = Invoke-PfbApiRequest -Array $Array -Method 'GET' -Endpoint 'admins' -QueryParams @{ names = $Array.Username } + $model = @($response.items)[0].authorization_model + if ($model) { return [string]$model } + return $null + } + catch { + Write-Verbose "Could not determine the authorization model for '$($Array.Username)' on $($Array.Endpoint): $($_.Exception.Message). Cross-array context checks will not be pre-validated." + return $null + } +} + +function Assert-PfbContextAuthorizationModel { + <# + .SYNOPSIS + Throws when a static-authorization-model admin sets any Fusion context. + .DESCRIPTION + Diagnostic, never a security boundary. A static-model admin's cross-array call fails + loudly on the wire with 'Operation not permitted' (code 20), so this gate can never turn + a would-be wrong-target success into a failure -- it only replaces an opaque server error + with the actionable reason. + + Fails OPEN on an indeterminate model and CLOSED on a known-static one. Those are not in + tension: $null means no evidence (an OAuth2 client with no username, or GET /admins 403 + under a restrictive management-access policy), while 'static' is positive evidence the + call cannot work. Failing closed on the unknown case would block legitimate OAuth2 and + restricted-policy sessions while protecting nothing. + + Takes -Array, unlike the two pure shape gates, because the model is a property of the + SESSION rather than of the endpoint -- and takes no -Endpoint or -CapabilityMap for the + same reason. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][PSCustomObject]$Array, + [Parameter(Mandatory)]$Context + ) + + # Fail OPEN on an indeterminate model. See Resolve-PfbAuthorizationModel. + if ($Array.AuthorizationModel -ne 'static') { return } + + # NO local-array exemption. An earlier draft let a static admin through when the context + # named only the connected array, but (a) the connection object carries no array NAME to + # compare against -- it has Endpoint, an IP or hostname -- so the check could only ever have + # worked against an invented test fixture, and (b) maintainer ruling 2026-08-05: a static + # user has no business setting a context at all. Naming your own array buys nothing anyway, + # since the server short-circuits it. Do not reintroduce the exemption or an ArrayName field. + throw "Setting a Fusion context requires a dynamic-authorization-model (LDAP/SAML) admin; static-model admins, including pureuser and other local accounts such as custom local users and service accounts, are not permitted. The connected admin '$($Array.Username)' is static-model, so a cross-array call would return 'Operation not permitted' (code 20) regardless of the context value." +} diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index d42e44d1..c7191ac6 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -75,6 +75,12 @@ function Invoke-PfbApiRequest { # context at all should hear about that first. Assert-PfbContextKindMatchesScope -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap + # Fourth gate: a static-authorization-model admin cannot use a context at all. Runs last + # of the four because it is endpoint-independent -- an endpoint-specific problem is the + # more actionable thing to hear about first. Fails open on an indeterminate model, so it + # cannot break OAuth2 or restricted-policy sessions. + Assert-PfbContextAuthorizationModel -Array $Array -Context $resolvedContext + # Clone first: $QueryParams is a reference to the CALLER's hashtable, and a targeting # parameter must not leak back into a hashtable the caller may reuse for another call. # Assigning the clone to the local also means the -AutoPaginate loop below rebuilds diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index 095aa7b1..66b4b867 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -494,6 +494,12 @@ function Connect-PfbArray { $script:PfbDefaultArray = $connection $script:PfbArrays[$Endpoint] = $connection + # Best-effort and non-fatal: see Resolve-PfbAuthorizationModel. Runs after the caches are + # populated and before the connect-time -Context handling, so the gate below already has the + # model to rule on. Safe from recursion: the resolver's own Invoke-PfbApiRequest call happens + # while DefaultContext and ContextOverride are both still $null, so no context gate fires. + $connection.AuthorizationModel = Resolve-PfbAuthorizationModel -Array $connection + # A context supplied at connect is the durable session default. Already validated above, # before authentication. The gate is $contextRequested -- never a truthiness or $null test # on $contextEntries, which is what keeps $null (unset) distinct from @() (explicit diff --git a/Public/Context/Set-PfbContext.ps1 b/Public/Context/Set-PfbContext.ps1 index cbc47c98..a2a2e203 100644 --- a/Public/Context/Set-PfbContext.ps1 +++ b/Public/Context/Set-PfbContext.ps1 @@ -78,6 +78,10 @@ function Set-PfbContext { $entries = ConvertTo-PfbContextEntryList -Name $names.ToArray() -Kind $Kind -Form $form foreach ($entry in $entries) { Assert-PfbContextEntryComposition -Entry $entry } + # A static-model admin cannot use a context at all, so say so here rather than letting + # every later call fail with an opaque code 20. Fails open on an indeterminate model. + Assert-PfbContextAuthorizationModel -Array $target -Context (New-PfbContext -Entries $entries) + $allowErrors = if ($PSBoundParameters.ContainsKey('AllowErrors')) { [bool]$AllowErrors } else { $null } $copy = Copy-PfbConnection -Array $target diff --git a/Tests/Assert-PfbContextSupported.Tests.ps1 b/Tests/Assert-PfbContextSupported.Tests.ps1 index c68b266f..b9fe6247 100644 --- a/Tests/Assert-PfbContextSupported.Tests.ps1 +++ b/Tests/Assert-PfbContextSupported.Tests.ps1 @@ -459,3 +459,50 @@ Describe 'Assert-PfbContextRequired' { } } } + +Describe 'Assert-PfbContextAuthorizationModel' { + It 'throws for a static-model admin setting a cross-array context' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AuthorizationModel = 'static' } + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | + Should -Throw -ExpectedMessage '*dynamic-authorization-model*' + } + } + It 'allows a dynamic-model admin' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'juemerson'; AuthorizationModel = 'dynamic' } + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | Should -Not -Throw + } + } + It 'FAILS OPEN when the model could not be determined' { + # OAuth2 client with no username, or GET /admins 403 under a restrictive access policy. + # The gate is diagnostic, never a security boundary -- blocking here would deny + # legitimate sessions and protect nothing, since the wire still answers code 20. + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = $null; AuthorizationModel = $null } + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | Should -Not -Throw + } + } + It 'mentions that pureuser and other LOCAL accounts are all static' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AuthorizationModel = 'static' } + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | + Should -Throw -ExpectedMessage '*local*' + } + } + It 'throws for a static-model admin even when the context names only the connected array' { + # No local-array exemption (maintainer ruling 2026-08-05). The connection object carries + # no array NAME to compare against -- only Endpoint, an IP or hostname -- so the old + # exemption could only ever have matched an invented fixture. + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AuthorizationModel = 'static' } + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'fb.example')) + { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | + Should -Throw -ExpectedMessage '*dynamic-authorization-model*' + } + } +} diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index ea36bf6a..fd97b106 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -247,3 +247,29 @@ Describe 'Connect-PfbArray -Context behaviour' { } } } + +Describe 'Resolve-PfbAuthorizationModel' { + It 'leaves AuthorizationModel null when the admin lookup fails' { + InModuleScope 'PureStorageFlashBladePowerShell' { + Mock -CommandName Invoke-PfbApiRequest -MockWith { throw 'HTTP 403' } + { Resolve-PfbAuthorizationModel -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'u' }) } | Should -Not -Throw + $null -eq (Resolve-PfbAuthorizationModel -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'u' })) | Should -BeTrue + } + } + It 'reads authorization_model for the connecting username' { + InModuleScope 'PureStorageFlashBladePowerShell' { + Mock -CommandName Invoke-PfbApiRequest -MockWith { + [PSCustomObject]@{ items = @([PSCustomObject]@{ name = 'juemerson'; authorization_model = 'dynamic' }) } + } + Resolve-PfbAuthorizationModel -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'juemerson' }) | + Should -Be 'dynamic' + } + } + It 'returns null without a network call when the connection has no username' { + InModuleScope 'PureStorageFlashBladePowerShell' { + Mock -CommandName Invoke-PfbApiRequest -MockWith { throw 'must not be called' } + $null -eq (Resolve-PfbAuthorizationModel -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = $null })) | Should -BeTrue + Should -Invoke -CommandName Invoke-PfbApiRequest -Times 0 + } + } +} diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index 6b2d552a..9456a9cc 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -147,7 +147,7 @@ Describe 'context injection in Invoke-PfbApiRequest' { # unwired from the request path unnoticed. Tasks 10 and 11 add two more gates to this same site, # so the wiring gets its own detector now. Describe 'context gate wiring in Invoke-PfbApiRequest' { - It 'calls all three shape gates in order, capability before cardinality before kindMatchesScope' { + It 'calls all four shape gates in order, capability before cardinality before kindMatchesScope before authorizationModel' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' @@ -161,15 +161,17 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { Mock -CommandName Assert-PfbContextCapability -MockWith { $calls.Add('capability') } Mock -CommandName Assert-PfbContextCardinality -MockWith { $calls.Add('cardinality') } Mock -CommandName Assert-PfbContextKindMatchesScope -MockWith { $calls.Add('kindMatchesScope') } + Mock -CommandName Assert-PfbContextAuthorizationModel -MockWith { $calls.Add('authorizationModel') } Mock -CommandName Assert-PfbApiCapability -MockWith {} Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null - @($calls).Count | Should -Be 3 -Because 'all three shape gates must fire from the request path; a lower count means one call was deleted or never wired' + @($calls).Count | Should -Be 4 -Because 'all four shape gates must fire from the request path; a lower count means one call was deleted or never wired' $calls[0] | Should -Be 'capability' -Because 'the capability gate must rule on "endpoint takes no context at all" first' $calls[1] | Should -Be 'cardinality' $calls[2] | Should -Be 'kindMatchesScope' -Because 'it runs after cardinality: a wrong-KIND context aimed at an endpoint that takes no context at all should hear about capability first, not about scope' + $calls[3] | Should -Be 'authorizationModel' -Because 'the authorization-model gate is diagnostic and endpoint-independent, so the three endpoint-specific gates rule first' } } It 'passes each gate the resolved context and the shared capability map' { @@ -190,6 +192,14 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { Mock -CommandName Assert-PfbContextKindMatchesScope -MockWith { $seen.Add([PSCustomObject]@{ Gate = 'kindMatchesScope'; Names = @($Context.Entries.Name) -join ','; Endpoint = $Endpoint; HasMap = ($null -ne $CapabilityMap) }) } + # The authorization-model gate has a DIFFERENT signature from the other three: it + # takes -Array (which only the capability gate also takes) and neither -Endpoint nor + # -CapabilityMap, because the admin's model is a property of the session, not of the + # endpoint. Record what it actually receives rather than forcing it into their shape. + $authSeen = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Assert-PfbContextAuthorizationModel -MockWith { + $authSeen.Add([PSCustomObject]@{ Names = @($Context.Entries.Name) -join ','; ArrayEndpoint = $Array.Endpoint }) + } Mock -CommandName Assert-PfbApiCapability -MockWith {} Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } @@ -201,9 +211,12 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $record.Endpoint | Should -Be 'file-systems' $record.HasMap | Should -BeTrue -Because "$($record.Gate) must receive the capability map, or it silently no-ops" } + @($authSeen).Count | Should -Be 1 + $authSeen[0].Names | Should -Be 'FB-B' -Because 'the authorization-model gate must see the RESOLVED context too' + $authSeen[0].ArrayEndpoint | Should -Be 'fb.example' -Because 'it must receive the CONNECTION, which is where AuthorizationModel lives; without -Array it can only ever no-op' } } - It 'calls none of the three shape gates when no context is set' { + It 'calls none of the four shape gates when no context is set' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' @@ -214,6 +227,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { Mock -CommandName Assert-PfbContextCapability -MockWith { $calls.Add('capability') } Mock -CommandName Assert-PfbContextCardinality -MockWith { $calls.Add('cardinality') } Mock -CommandName Assert-PfbContextKindMatchesScope -MockWith { $calls.Add('kindMatchesScope') } + Mock -CommandName Assert-PfbContextAuthorizationModel -MockWith { $calls.Add('authorizationModel') } Mock -CommandName Assert-PfbApiCapability -MockWith {} Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } diff --git a/Tests/Set-PfbContext.Tests.ps1 b/Tests/Set-PfbContext.Tests.ps1 index e3f4cc1f..fada7041 100644 --- a/Tests/Set-PfbContext.Tests.ps1 +++ b/Tests/Set-PfbContext.Tests.ps1 @@ -57,6 +57,38 @@ Describe 'Set-PfbContext' { Should -Invoke -CommandName Invoke-PfbApiRequest -ModuleName 'PureStorageFlashBladePowerShell' -Times 0 Should -Invoke -CommandName Invoke-RestMethod -ModuleName 'PureStorageFlashBladePowerShell' -Times 0 } + # A SEPARATE call site from Invoke-PfbApiRequest's request path -- the wiring test over there + # says nothing about this one, and vice versa. + It 'calls the authorization-model gate with the target connection and the composed context' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = 'dynamic' + } + $seen = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Assert-PfbContextAuthorizationModel -MockWith { + $seen.Add([PSCustomObject]@{ Model = $Array.AuthorizationModel; Names = @($Context.Entries.Name) -join ',' }) + } + + Set-PfbContext -Array $fb -Context 'FB-B' | Out-Null + + @($seen).Count | Should -Be 1 -Because "Set-PfbContext's end{} must call the gate; a count of 0 means the call was deleted or never wired" + $seen[0].Model | Should -Be 'dynamic' -Because 'the gate must receive the connection that carries AuthorizationModel' + $seen[0].Names | Should -Be 'FB-B' + } + } + It 'refuses to set a context for a static-model admin' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; Username = 'pureuser' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = 'static' + } + { Set-PfbContext -Array $fb -Context 'FB-B' } | + Should -Throw -ExpectedMessage '*dynamic-authorization-model*' + } + } It 'repoints the module caches at the copy' { InModuleScope 'PureStorageFlashBladePowerShell' { $originalArrays = $script:PfbArrays; $originalDefault = $script:PfbDefaultArray From 18e41fb34a5b926dba51f8ddebb3a4dcfa0f772f Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 20:11:52 -0700 Subject: [PATCH 25/53] fix(context): read the admin model off the unwrapped response and pin the connect capture Fix round 1 for task 11. Critical: Resolve-PfbAuthorizationModel read .items off Invoke-PfbApiRequest's return value, which already unwraps the envelope and hands back an object[] of admin objects. The resolver therefore returned $null on every real array and the gate was permanently inert in production behind a green suite. Read the admin objects directly and match on name rather than taking row 0. The test that should have caught it mocked Invoke-PfbApiRequest and fed it the wire envelope -- a shape that function can never emit. Re-pinned at the Invoke-RestMethod boundary so the real unwrap runs. Also: pin the connect-time capture with a -Credential harness (deleting that one line disabled the whole feature and reddened nothing); move the gate below Assert-PfbApiCapability so a firmware blocker wins over an admin-model one, per Task 10's measured ruling, with a five-element ordering test; add an un-mocked request-path throw test; name the offending context values in the message; build one context object in Set-PfbContext instead of a throwaway; document the -ApiToken inertness and the 403 reconnect amplification. Co-Authored-By: Claude Opus 5 --- Private/Assert-PfbContextSupported.ps1 | 47 ++++++++-- Private/Invoke-PfbApiRequest.ps1 | 24 +++-- Public/Connection/Connect-PfbArray.ps1 | 23 ++++- Public/Context/Set-PfbContext.ps1 | 18 +++- Tests/Assert-PfbContextSupported.Tests.ps1 | 14 +++ Tests/Connect-PfbArray.Context.Tests.ps1 | 89 +++++++++++++++++-- ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 51 ++++++++++- 7 files changed, 237 insertions(+), 29 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index ec776e7d..db54031d 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -331,10 +331,37 @@ function Resolve-PfbAuthorizationModel { is also local -- so pureuser, custom local users and service accounts are ALL 'static'. This is not "pureuser vs everyone". - Returns $null rather than throwing on any failure. GET /admins can 403 under a - restrictive management-access policy, and an OAuth2 client may have no username to - match. An indeterminate model must never fail a Connect-PfbArray, because this data - drives a diagnostic and not a correctness gate. + Returns $null rather than throwing on any failure. An indeterminate model must never fail + a Connect-PfbArray, because this data drives a diagnostic and not a correctness gate. + THREE distinct routes reach indeterminate, and the third is the common one: + 1. GET /admins 403s under a restrictive management-access policy. + 2. An OAuth2 client has no username to match. + 3. -ApiToken -- the DEFAULT parameter set -- never populates Username at all, so the + early return below fires and the gate is permanently inert for it. Only the + Credential, PSCredential and Certificate sets normalize Username + (Connect-PfbArray.ps1:206-212). This is a correct application of the fail-open + ruling (no username, no evidence), not a bug: do NOT "fix" it by inferring a model + from the token or by defaulting to 'static'. + + NO .items UNWRAP. Invoke-PfbApiRequest already unwraps the envelope itself -- it collects + $response.items into $allItems and returns $allItems.ToArray(), an object[] of admin + objects (Invoke-PfbApiRequest.ps1:287-291, :328). Reading .items off that value yields + nothing on every real array, which silently returned $null forever and left the gate + inert. Measured on both editions. -Raw would give the raw envelope but bypasses the + pagination and error handling below its early return, and no other list read in the + module uses it. Never reintroduce an .items read here. + + Matches on name rather than taking row 0: reading the WRONG admin's model is worse than + reading none, because a 'static' read off a peer's row would hard-throw a legitimate LDAP + session out of Set-PfbContext. + + Cost on a 403: more than one round trip. Invoke-PfbApiRequest's auto-reconnect gate fires + on 403 as well as 401 by design (:223-232 -- real arrays answer 403, not 401, for a bad + token), so a legitimate management-access-policy 403 is indistinguishable from an expired + token and costs ~3 round trips plus a spurious re-login per connect. Non-fatal -- the + catch below contains it -- and deliberately NOT worked around here: the only real fix + touches reconnect logic shared by every cmdlet in the module. Parked for live measurement + in Task 15. Do not change the shared reconnect logic on this note alone. No recursion risk despite calling Invoke-PfbApiRequest: this runs at connect time, when the connection's DefaultContext and ContextOverride are both still $null, so @@ -350,8 +377,8 @@ function Resolve-PfbAuthorizationModel { if (-not $Array.Username) { return $null } try { - $response = Invoke-PfbApiRequest -Array $Array -Method 'GET' -Endpoint 'admins' -QueryParams @{ names = $Array.Username } - $model = @($response.items)[0].authorization_model + $admins = @(Invoke-PfbApiRequest -Array $Array -Method 'GET' -Endpoint 'admins' -QueryParams @{ names = $Array.Username }) + $model = ($admins | Where-Object { $_.name -eq $Array.Username } | Select-Object -First 1).authorization_model if ($model) { return [string]$model } return $null } @@ -379,7 +406,10 @@ function Assert-PfbContextAuthorizationModel { Takes -Array, unlike the two pure shape gates, because the model is a property of the SESSION rather than of the endpoint -- and takes no -Endpoint or -CapabilityMap for the - same reason. + same reason. -Context does not affect WHETHER this throws (there is no local-array + exemption, so every context is rejected once the model is static) but it is named in the + message, which is what earns it its mandatory slot: the caller sees which values were + rejected rather than a generic complaint. #> [CmdletBinding()] param( @@ -396,5 +426,6 @@ function Assert-PfbContextAuthorizationModel { # worked against an invented test fixture, and (b) maintainer ruling 2026-08-05: a static # user has no business setting a context at all. Naming your own array buys nothing anyway, # since the server short-circuits it. Do not reintroduce the exemption or an ArrayName field. - throw "Setting a Fusion context requires a dynamic-authorization-model (LDAP/SAML) admin; static-model admins, including pureuser and other local accounts such as custom local users and service accounts, are not permitted. The connected admin '$($Array.Username)' is static-model, so a cross-array call would return 'Operation not permitted' (code 20) regardless of the context value." + $names = @($Context.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ', ' + throw "Setting a Fusion context requires a dynamic-authorization-model (LDAP/SAML) admin; static-model admins, including pureuser and other local accounts such as custom local users and service accounts, are not permitted. The connected admin '$($Array.Username)' is static-model, so the context '$names' would return 'Operation not permitted' (code 20) on any cross-array call regardless of its value." } diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index c7191ac6..bfb7a4e7 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -75,12 +75,6 @@ function Invoke-PfbApiRequest { # context at all should hear about that first. Assert-PfbContextKindMatchesScope -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap - # Fourth gate: a static-authorization-model admin cannot use a context at all. Runs last - # of the four because it is endpoint-independent -- an endpoint-specific problem is the - # more actionable thing to hear about first. Fails open on an indeterminate model, so it - # cannot break OAuth2 or restricted-policy sessions. - Assert-PfbContextAuthorizationModel -Array $Array -Context $resolvedContext - # Clone first: $QueryParams is a reference to the CALLER's hashtable, and a targeting # parameter must not leak back into a hashtable the caller may reuse for another call. # Assigning the clone to the local also means the -AutoPaginate loop below rebuilds @@ -95,7 +89,23 @@ function Invoke-PfbApiRequest { # Assert-PfbApiCapability's header for why an unrecognized endpoint is a silent no-op. Assert-PfbApiCapability -Array $Array -Method $Method -Endpoint $Endpoint -Body $Body -QueryParams $QueryParams -ApiVersion $ApiVersionOverride - if (-not $hasContext) { + # A symmetric pair, and BOTH halves sit below Assert-PfbApiCapability for the same measured + # reason. Neither injects anything and neither consults the endpoint, so neither has an + # ordering requirement against the version gate -- and a version failure is the more + # fundamental fact, so it must win. See the else branch for the 2.20 measurement that + # established this. + if ($hasContext) { + # Fourth shape gate, and the only one placed here rather than above the injection: a + # static-authorization-model admin cannot use a context at all, on any endpoint. Placed + # above the injection it reintroduced exactly the failure Task 10 measured -- a static + # admin on a REST 2.20 array calling a context-capable endpoint that needs 2.23 was told + # to go obtain an LDAP admin, and only after doing so learned the real blocker was + # firmware. Assert-PfbContextCapability defers "recorded but array too old" to + # Assert-PfbApiCapability by design, so gates 1-3 all pass in that scenario and this one + # got the last word. Fails open on an indeterminate model. + Assert-PfbContextAuthorizationModel -Array $Array -Context $resolvedContext + } + else { # A fleet-scoped endpoint has no usable no-context default for a mutation or a # name-scoped read. An explicit @() is still the caller saying "locally", so it reaches # here too -- and for those calls that is exactly as broken as omitting the context. diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index 66b4b867..e396959c 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -494,16 +494,31 @@ function Connect-PfbArray { $script:PfbDefaultArray = $connection $script:PfbArrays[$Endpoint] = $connection - # Best-effort and non-fatal: see Resolve-PfbAuthorizationModel. Runs after the caches are - # populated and before the connect-time -Context handling, so the gate below already has the - # model to rule on. Safe from recursion: the resolver's own Invoke-PfbApiRequest call happens - # while DefaultContext and ContextOverride are both still $null, so no context gate fires. + # Best-effort and non-fatal: see Resolve-PfbAuthorizationModel, which also documents the two + # costs -- this is inert for the DEFAULT -ApiToken set (no Username to look up) and costs ~3 + # round trips rather than 1 on a management-access-policy 403. Runs after the caches are + # populated and before the connect-time -Context handling. Safe from recursion: the resolver's + # own Invoke-PfbApiRequest call happens while DefaultContext and ContextOverride are both + # still $null, so no context gate fires. + # + # Deleting this line disables the entire authorization-model feature without breaking any + # call site, so it is pinned by a test: see 'populates AuthorizationModel from the connected + # admin' in Tests/Connect-PfbArray.Context.Tests.ps1. $connection.AuthorizationModel = Resolve-PfbAuthorizationModel -Array $connection # A context supplied at connect is the durable session default. Already validated above, # before authentication. The gate is $contextRequested -- never a truthiness or $null test # on $contextEntries, which is what keeps $null (unset) distinct from @() (explicit # no-context). + # + # Deliberately NOT gated by Assert-PfbContextAuthorizationModel: scoped out by design, since + # the request-path gate still throws before anything reaches the wire -- only the diagnostic + # is later. TRAP for whoever closes that gap: a gate call placed here would throw AFTER + # $script:PfbDefaultArray and $script:PfbArrays[$Endpoint] were repointed at this connection + # above, so the caller would get an exception while the module caches point at a connection + # this cmdlet never returned -- a "failed" connect installed as the default array, with the + # offending context attached. Any fix must validate before the cache assignment, or unwind + # the caches on throw. if ($contextRequested) { $connection.DefaultContext = New-PfbContext -Entries $contextEntries } diff --git a/Public/Context/Set-PfbContext.ps1 b/Public/Context/Set-PfbContext.ps1 index a2a2e203..0f76db75 100644 --- a/Public/Context/Set-PfbContext.ps1 +++ b/Public/Context/Set-PfbContext.ps1 @@ -78,14 +78,24 @@ function Set-PfbContext { $entries = ConvertTo-PfbContextEntryList -Name $names.ToArray() -Kind $Kind -Form $form foreach ($entry in $entries) { Assert-PfbContextEntryComposition -Entry $entry } + $allowErrors = if ($PSBoundParameters.ContainsKey('AllowErrors')) { [bool]$AllowErrors } else { $null } + + # Built ONCE, above the gate, and the same object is both gated and stored. An earlier + # revision passed a throwaway New-PfbContext to the gate and built the real one after, + # which minted two objects per call and meant the gate never saw -AllowErrors. + # + # NOT named $context: PowerShell variable names are case-insensitive, so that is the + # SAME variable as the [string[]]$Context parameter -- assigning a PfbContext object to + # it silently COERCES it to a one-element string[], and both the gate and + # $copy.DefaultContext then receive a stringified array instead of a context. Measured. + $newContext = New-PfbContext -Entries $entries -AllowErrors $allowErrors + # A static-model admin cannot use a context at all, so say so here rather than letting # every later call fail with an opaque code 20. Fails open on an indeterminate model. - Assert-PfbContextAuthorizationModel -Array $target -Context (New-PfbContext -Entries $entries) - - $allowErrors = if ($PSBoundParameters.ContainsKey('AllowErrors')) { [bool]$AllowErrors } else { $null } + Assert-PfbContextAuthorizationModel -Array $target -Context $newContext $copy = Copy-PfbConnection -Array $target - $copy.DefaultContext = New-PfbContext -Entries $entries -AllowErrors $allowErrors + $copy.DefaultContext = $newContext Update-PfbConnectionCache -Array $copy $copy } diff --git a/Tests/Assert-PfbContextSupported.Tests.ps1 b/Tests/Assert-PfbContextSupported.Tests.ps1 index b9fe6247..be89a7b6 100644 --- a/Tests/Assert-PfbContextSupported.Tests.ps1 +++ b/Tests/Assert-PfbContextSupported.Tests.ps1 @@ -494,6 +494,20 @@ Describe 'Assert-PfbContextAuthorizationModel' { Should -Throw -ExpectedMessage '*local*' } } + It 'names the offending context values in the message' { + # This is what earns -Context its mandatory slot. It does not change WHETHER the gate + # throws (there is no local-array exemption), but a caller who set several names needs to + # see which ones were rejected, in their wire form. + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AuthorizationModel = 'static' } + $ctx = New-PfbContext -Entries @( + (New-PfbContextEntry -Name 'FB-B'), + (New-PfbContextEntry -Name 'fleet-prod' -Kind 'Fleet' -Form 'AllArrays') + ) + { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | + Should -Throw -ExpectedMessage '*FB-B, fleet-prod.arrays*' + } + } It 'throws for a static-model admin even when the context names only the connected array' { # No local-array exemption (maintainer ruling 2026-08-05). The connection object carries # no array NAME to compare against -- only Endpoint, an IP or hostname -- so the old diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index fd97b106..1b913691 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -155,6 +155,48 @@ Describe 'Connect-PfbArray -Context behaviour' { } } + # THE detector for Connect-PfbArray.ps1's `$connection.AuthorizationModel = Resolve-...` line. + # Deleting that one line disables the whole authorization-model feature without breaking any + # gate call site, and nothing pinned it: the two pre-existing AuthorizationModel assertions in + # this file check that the PROPERTY EXISTS (declared in the object literal, so it passes + # either way) and that it is $null (which passes either way too, because those harnesses + # connect with -ApiToken and so Username is never populated). That gap is why the inert-gate + # defect shipped. + # + # Must use a username-bearing parameter set: -ApiToken never populates Username, so the + # resolver early-returns and this test would pass vacuously against a deleted line. + It 'populates AuthorizationModel from the connected admin' { + $cred = [System.Management.Automation.PSCredential]::new( + 'jdoe', (ConvertTo-SecureString 'pw' -AsPlainText -Force)) + # Boundary mocks, matched with -match so `?` is a literal and not the -like single-char + # wildcard: '*/admins?*' would also match the api-tokens URI's '/admins/'. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + [PSCustomObject]@{ + items = @([PSCustomObject]@{ name = 'jdoe'; authorization_model = 'static' }) + total_item_count = 1 + } + } -ParameterFilter { $Uri -match '/admins\?' } + # The best-effort API-token read/mint on the credential path. Answered with an empty list + # so it neither reaches the network nor supplies a token. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + [PSCustomObject]@{ items = @() } + } -ParameterFilter { $Uri -match '/admins/api-tokens' } + + $conn = Connect-PfbArray -Endpoint 'fb.test' -Credential $cred + + $conn.AuthorizationModel | Should -Be 'static' -Because 'Connect-PfbArray must assign the resolver result onto the connection; a $null here means the capture line was never wired' + } + + It 'resolves the authorization model exactly once per connect' { + # Pins the cost as "one lookup per connect, not one per request". + InModuleScope PureStorageFlashBladePowerShell { + Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'dynamic' } + $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' + $conn.AuthorizationModel | Should -Be 'dynamic' + Should -Invoke -CommandName Resolve-PfbAuthorizationModel -Times 1 -Exactly + } + } + It 'rejects -Context $null at the binder, naming -Context, not a downstream parameter' { # Without [ValidateNotNull()] on -Context, $null flows into # ConvertTo-PfbContextEntryList -Name $null and is rejected there, blaming -Name -- a @@ -256,13 +298,50 @@ Describe 'Resolve-PfbAuthorizationModel' { $null -eq (Resolve-PfbAuthorizationModel -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'u' })) | Should -BeTrue } } - It 'reads authorization_model for the connecting username' { + # Mocked at the Invoke-RestMethod boundary, NOT at Invoke-PfbApiRequest. A mock of the thing + # under test cannot prove its own return contract: an earlier revision mocked + # Invoke-PfbApiRequest returning [PSCustomObject]@{ items = @(...) } -- the WIRE envelope, + # which Invoke-PfbApiRequest can never actually return, because it unwraps items itself and + # hands back an object[] of admin objects. The resolver read .items off that array, got $null + # on every real array, and the whole gate was inert in production behind a green suite. + # Letting the real Invoke-PfbApiRequest do the unwrap is what makes this a contract test. + It 'reads authorization_model for the connecting username through the real items unwrap' { InModuleScope 'PureStorageFlashBladePowerShell' { - Mock -CommandName Invoke-PfbApiRequest -MockWith { - [PSCustomObject]@{ items = @([PSCustomObject]@{ name = 'juemerson'; authorization_model = 'dynamic' }) } + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + Username = 'juemerson' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + } + Mock -CommandName Invoke-RestMethod -MockWith { + [PSCustomObject]@{ + items = @([PSCustomObject]@{ name = 'juemerson'; authorization_model = 'dynamic' }) + total_item_count = 1 + } + } + + Resolve-PfbAuthorizationModel -Array $fb | Should -Be 'dynamic' + } + } + It 'ignores an admin row whose name does not match the connecting username' { + # A wrong-row read is worse than no read: 'static' off a peer's row would hard-throw a + # legitimate LDAP session out of Set-PfbContext. names= is a documented exact-match + # filter, so this is defence in depth rather than an observed server behaviour. + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + Username = 'juemerson' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null } - Resolve-PfbAuthorizationModel -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'juemerson' }) | - Should -Be 'dynamic' + Mock -CommandName Invoke-RestMethod -MockWith { + [PSCustomObject]@{ + items = @([PSCustomObject]@{ name = 'pureuser'; authorization_model = 'static' }) + total_item_count = 1 + } + } + + $null -eq (Resolve-PfbAuthorizationModel -Array $fb) | Should -BeTrue } } It 'returns null without a network call when the connection has no username' { diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index 9456a9cc..035fcfa5 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -174,6 +174,36 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $calls[3] | Should -Be 'authorizationModel' -Because 'the authorization-model gate is diagnostic and endpoint-independent, so the three endpoint-specific gates rule first' } } + # The It above records only the context gates, so it holds regardless of where the VERSION + # gate sits among them -- it cannot detect the authorization-model gate drifting back above + # Assert-PfbApiCapability. This one adds the version gate to the recording and pins the whole + # sequence. The ruling it encodes was measured in Task 10: a gate that injects nothing and + # consults no endpoint must run BELOW the version gate, or a static admin on a REST 2.20 array + # calling an endpoint that needs 2.23 is told to go obtain an LDAP admin and only afterwards + # learns the real blocker was firmware. Assert-PfbContextCapability defers "recorded but array + # too old" to Assert-PfbApiCapability by design, so gates 1-3 do not catch that case. + It 'runs the version gate after the three injecting gates but BEFORE the authorization-model gate' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) + ContextOverride = $null; AuthorizationModel = $null + } + $calls = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Assert-PfbContextCapability -MockWith { $calls.Add('capability') } + Mock -CommandName Assert-PfbContextCardinality -MockWith { $calls.Add('cardinality') } + Mock -CommandName Assert-PfbContextKindMatchesScope -MockWith { $calls.Add('kindMatchesScope') } + Mock -CommandName Assert-PfbContextAuthorizationModel -MockWith { $calls.Add('authorizationModel') } + # Recording, not silent: its POSITION is the thing under test here. + Mock -CommandName Assert-PfbApiCapability -MockWith { $calls.Add('versionGate') } + Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } + + Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null + + @($calls) -join ',' | Should -Be 'capability,cardinality,kindMatchesScope,versionGate,authorizationModel' -Because 'the three injecting gates must precede the version gate (it has to see the injected context_names), and the endpoint-independent authorization-model gate must follow it so a firmware blocker wins over an admin-model one' + } + } It 'passes each gate the resolved context and the shared capability map' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ @@ -236,7 +266,26 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { @($calls).Count | Should -Be 0 } } - # The It above counts only the three SHAPE gates, so it passes whether or not the + # An un-mocked companion to the wiring tests above: every one of those mocks the gate, so they + # pin that it is CALLED and say nothing about its effect through this function. This one also + # fails if the call is ever moved outside the $hasContext branch. + It 'actually throws for a static-model admin with a context, gate un-mocked' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + Username = 'pureuser' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) + ContextOverride = $null; AuthorizationModel = 'static' + } + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { throw 'the request must never be attempted' } + + { Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' } | + Should -Throw -ExpectedMessage '*dynamic-authorization-model*' + } + } + # The It above counts only the four SHAPE gates, so it passes whether or not the # required-context gate is wired at all -- it looks like coverage of the else branch and is # not. This is the detector for that call site: its own List, its own count. It 'calls the required-context gate exactly once, with the caller query params and the shared map, when no context is set' { From 2571af73a7c04cdb753c1a9e658f3554d7277852 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 20:32:37 -0700 Subject: [PATCH 26/53] refactor(context): resolve the authorization model only when a context is set Maintainer ruling 2026-08-05: GET /admins is the only route to authorization_model (POST /api/login returns just {"username":...}), so the call is necessary -- but resolving it at every connect made sessions that never touch Fusion pay for it. Two sites only: Connect-PfbArray inside the -Context block, and Set-PfbContext's end{}. Both are one-shot session setup, so no cache is needed and AuthorizationModel stays two-state-plus-null. Invoke-PfbInContext is deliberately excluded -- a per-call wrapper would mean one probe per loop iteration. Connect-PfbArray's cache assignment moved BELOW the context block so a rejected context can no longer leave a connection the cmdlet never returned installed as the default array. Set-PfbContext takes its copy before resolving, so the model is written onto the copy and copy-on-write holds for it as it does for context. Reworked FIX 2's capture test to pin the -Context path and added the test that pins the point of the change: a bare connect issues no admin call at all. Co-Authored-By: Claude Opus 5 --- Public/Connection/Connect-PfbArray.ps1 | 61 ++++++++++++++---------- Public/Context/Set-PfbContext.ps1 | 18 ++++++- Tests/Connect-PfbArray.Context.Tests.ps1 | 58 ++++++++++++++++++++-- Tests/Set-PfbContext.Tests.ps1 | 39 +++++++++++++-- 4 files changed, 139 insertions(+), 37 deletions(-) diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index e396959c..fa234782 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -490,38 +490,49 @@ function Connect-PfbArray { ) Add-Member -InputObject $connection -MemberType MemberSet -Name PSStandardMembers -Value $psStandardMembers - # Cache the connection - $script:PfbDefaultArray = $connection - $script:PfbArrays[$Endpoint] = $connection - - # Best-effort and non-fatal: see Resolve-PfbAuthorizationModel, which also documents the two - # costs -- this is inert for the DEFAULT -ApiToken set (no Username to look up) and costs ~3 - # round trips rather than 1 on a management-access-policy 403. Runs after the caches are - # populated and before the connect-time -Context handling. Safe from recursion: the resolver's - # own Invoke-PfbApiRequest call happens while DefaultContext and ContextOverride are both - # still $null, so no context gate fires. - # - # Deleting this line disables the entire authorization-model feature without breaking any - # call site, so it is pinned by a test: see 'populates AuthorizationModel from the connected - # admin' in Tests/Connect-PfbArray.Context.Tests.ps1. - $connection.AuthorizationModel = Resolve-PfbAuthorizationModel -Array $connection - # A context supplied at connect is the durable session default. Already validated above, # before authentication. The gate is $contextRequested -- never a truthiness or $null test # on $contextEntries, which is what keeps $null (unset) distinct from @() (explicit # no-context). # - # Deliberately NOT gated by Assert-PfbContextAuthorizationModel: scoped out by design, since - # the request-path gate still throws before anything reaches the wire -- only the diagnostic - # is later. TRAP for whoever closes that gap: a gate call placed here would throw AFTER - # $script:PfbDefaultArray and $script:PfbArrays[$Endpoint] were repointed at this connection - # above, so the caller would get an exception while the module caches point at a connection - # this cmdlet never returned -- a "failed" connect installed as the default array, with the - # offending context attached. Any fix must validate before the cache assignment, or unwind - # the caches on throw. + # DELIBERATELY BEFORE THE CACHE ASSIGNMENT BELOW. This block can throw (the gate), and the + # caches used to be repointed above it -- so a rejected context left a connection this cmdlet + # never returned installed as $script:PfbDefaultArray, with the offending context attached: a + # "failed" connect that is nonetheless the default array. Validating first and installing + # afterwards is what makes that unreachable, and it is why the two were reordered rather than + # an unwind-on-throw being bolted on. Nothing between here and the install needs the caches -- + # the resolver is passed $connection explicitly, and the reconnect/refresh paths' cache writes + # are guarded on the endpoint already being present, so they no-op rather than installing a + # half-configured connection behind our back. if ($contextRequested) { - $connection.DefaultContext = New-PfbContext -Entries $contextEntries + # Resolved HERE, not unconditionally at connect: a session that never touches Fusion must + # not pay for a GET /admins round trip (maintainer ruling 2026-08-05). The other site is + # Set-PfbContext's end{}. Exactly two sites, both one-shot session setup -- which is why + # no cache is needed and AuthorizationModel stays two-state-plus-null ('static'/'dynamic' + # known, $null indeterminate -> fail open). Do NOT add a per-call site such as + # Invoke-PfbInContext: it mutates ContextOverride in place on the shared connection and a + # thousand-iteration loop would mean a thousand probes. Do NOT memoize, and do NOT + # introduce a third "not yet asked" state to make memoizing safe. + # + # Best-effort and non-fatal: see Resolve-PfbAuthorizationModel, which also documents that + # this is inert for the DEFAULT -ApiToken set (no Username to look up) and costs ~3 round + # trips rather than 1 on a management-access-policy 403. Safe from recursion: the + # resolver's own Invoke-PfbApiRequest call happens while DefaultContext and + # ContextOverride are both still $null, so no context gate fires. + $connection.AuthorizationModel = Resolve-PfbAuthorizationModel -Array $connection + + $connectContext = New-PfbContext -Entries $contextEntries + + # Closes what the Task 11 review parked as a gap: the connect-time context path is now + # exactly where resolution happens, so it is also where the gate can rule. + Assert-PfbContextAuthorizationModel -Array $connection -Context $connectContext + + $connection.DefaultContext = $connectContext } + # Cache the connection. Last, so only a fully validated connection is ever installed. + $script:PfbDefaultArray = $connection + $script:PfbArrays[$Endpoint] = $connection + return $connection } diff --git a/Public/Context/Set-PfbContext.ps1 b/Public/Context/Set-PfbContext.ps1 index 0f76db75..ab63d8a0 100644 --- a/Public/Context/Set-PfbContext.ps1 +++ b/Public/Context/Set-PfbContext.ps1 @@ -90,11 +90,25 @@ function Set-PfbContext { # $copy.DefaultContext then receive a stringified array instead of a context. Measured. $newContext = New-PfbContext -Entries $entries -AllowErrors $allowErrors + # The copy is taken BEFORE resolving, so the model is written onto the copy and the + # caller's connection is never mutated -- this cmdlet's copy-on-write contract holds for + # the model exactly as it does for the context. Resolving onto $target instead would have + # been simpler and wrong: it mutates an object the caller may still hold. + $copy = Copy-PfbConnection -Array $target + + # Resolve the admin's authorization model HERE rather than relying on connect having done + # it. Since the 2026-08-05 ruling Connect-PfbArray only resolves when -Context was + # supplied, so on what is now the common path -- bare connect, then Set-PfbContext -- the + # model is still $null at this point, and skipping this would leave the gate permanently + # failing open. This is the second and last of the two resolution sites; do not add a + # third, and do not memoize (see Connect-PfbArray's note). + $copy.AuthorizationModel = Resolve-PfbAuthorizationModel -Array $copy + # A static-model admin cannot use a context at all, so say so here rather than letting # every later call fail with an opaque code 20. Fails open on an indeterminate model. - Assert-PfbContextAuthorizationModel -Array $target -Context $newContext + # Before the cache repoint below: a rejected context must leave no trace. + Assert-PfbContextAuthorizationModel -Array $copy -Context $newContext - $copy = Copy-PfbConnection -Array $target $copy.DefaultContext = $newContext Update-PfbConnectionCache -Array $copy $copy diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index 1b913691..6f7078b7 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -164,15 +164,18 @@ Describe 'Connect-PfbArray -Context behaviour' { # defect shipped. # # Must use a username-bearing parameter set: -ApiToken never populates Username, so the - # resolver early-returns and this test would pass vacuously against a deleted line. - It 'populates AuthorizationModel from the connected admin' { + # resolver early-returns and this test would pass vacuously against a deleted line. And must + # supply -Context: since the 2026-08-05 ruling the resolution happens ONLY on that path. + It 'populates AuthorizationModel from the connected admin when -Context is supplied' { $cred = [System.Management.Automation.PSCredential]::new( 'jdoe', (ConvertTo-SecureString 'pw' -AsPlainText -Force)) # Boundary mocks, matched with -match so `?` is a literal and not the -like single-char # wildcard: '*/admins?*' would also match the api-tokens URI's '/admins/'. + # 'dynamic', not 'static': a static model plus a connect-time context now throws (see the + # test below), so a static fixture here would be asserting on an unreachable state. Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { [PSCustomObject]@{ - items = @([PSCustomObject]@{ name = 'jdoe'; authorization_model = 'static' }) + items = @([PSCustomObject]@{ name = 'jdoe'; authorization_model = 'dynamic' }) total_item_count = 1 } } -ParameterFilter { $Uri -match '/admins\?' } @@ -182,21 +185,66 @@ Describe 'Connect-PfbArray -Context behaviour' { [PSCustomObject]@{ items = @() } } -ParameterFilter { $Uri -match '/admins/api-tokens' } + $conn = Connect-PfbArray -Endpoint 'fb.test' -Credential $cred -Context 'FB-B' + + $conn.AuthorizationModel | Should -Be 'dynamic' -Because 'Connect-PfbArray must assign the resolver result onto the connection; a $null here means the capture line was never wired' + @($conn.DefaultContext.Entries).Count | Should -Be 1 + } + + # THE detector for the whole point of the 2026-08-05 ruling: a session that never touches + # Fusion must not pay for a GET /admins round trip. Nothing else in the suite can see this -- + # every other test either supplies a context or connects with -ApiToken (no Username), so the + # resolution would be invisible to them whether it is conditional or unconditional. + It 'makes NO admin call on a bare connect, even with a username-bearing credential' { + $cred = [System.Management.Automation.PSCredential]::new( + 'jdoe', (ConvertTo-SecureString 'pw' -AsPlainText -Force)) + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + throw 'GET /admins must not be called on a bare connect' + } -ParameterFilter { $Uri -match '/admins\?' } + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + [PSCustomObject]@{ items = @() } + } -ParameterFilter { $Uri -match '/admins/api-tokens' } + $conn = Connect-PfbArray -Endpoint 'fb.test' -Credential $cred - $conn.AuthorizationModel | Should -Be 'static' -Because 'Connect-PfbArray must assign the resolver result onto the connection; a $null here means the capture line was never wired' + # -Times 0 is the real assertion. The throwing mock body above is NOT sufficient on its + # own: Resolve-PfbAuthorizationModel catches everything and returns $null, so the throw + # would be swallowed and this test would pass with the call still being made. + Should -Invoke -ModuleName PureStorageFlashBladePowerShell -CommandName Invoke-RestMethod ` + -ParameterFilter { $Uri -match '/admins\?' } -Times 0 ` + -Because 'a connect with no -Context must not resolve the authorization model at all' + $null -eq $conn.AuthorizationModel | Should -BeTrue } It 'resolves the authorization model exactly once per connect' { # Pins the cost as "one lookup per connect, not one per request". InModuleScope PureStorageFlashBladePowerShell { Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'dynamic' } - $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' + $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context 'FB-B' $conn.AuthorizationModel | Should -Be 'dynamic' Should -Invoke -CommandName Resolve-PfbAuthorizationModel -Times 1 -Exactly } } + It 'rejects a connect-time context for a static-model admin and installs nothing in the caches' { + # The connect-time context path is now gated -- it is where resolution happens, so it is + # where the gate can rule. The cache half of this assertion is the one that matters: the + # caches used to be repointed BEFORE this block, so a rejected context left a connection + # the cmdlet never returned installed as $script:PfbDefaultArray. + InModuleScope PureStorageFlashBladePowerShell { + Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'static' } + $sentinel = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'sentinel' } + $script:PfbArrays = @{ 'sentinel' = $sentinel } + $script:PfbDefaultArray = $sentinel + + { Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context 'FB-B' } | + Should -Throw -ExpectedMessage '*dynamic-authorization-model*' + + [object]::ReferenceEquals($script:PfbDefaultArray, $sentinel) | Should -BeTrue -Because 'a rejected connect must not become the default array' + $script:PfbArrays.ContainsKey('fb.test') | Should -BeFalse + } + } + It 'rejects -Context $null at the binder, naming -Context, not a downstream parameter' { # Without [ValidateNotNull()] on -Context, $null flows into # ConvertTo-PfbContextEntryList -Name $null and is rejected there, blaming -Name -- a diff --git a/Tests/Set-PfbContext.Tests.ps1 b/Tests/Set-PfbContext.Tests.ps1 index fada7041..e303200c 100644 --- a/Tests/Set-PfbContext.Tests.ps1 +++ b/Tests/Set-PfbContext.Tests.ps1 @@ -61,11 +61,15 @@ Describe 'Set-PfbContext' { # says nothing about this one, and vice versa. It 'calls the authorization-model gate with the target connection and the composed context' { InModuleScope 'PureStorageFlashBladePowerShell' { + # AuthorizationModel starts $null, which is now the COMMON state: since the + # 2026-08-05 ruling a bare connect resolves nothing. Set-PfbContext must resolve it + # itself, so the gate seeing 'dynamic' below is evidence it did. $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' - Endpoint = 'fb.example'; ApiVersion = '2.26' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = 'dynamic' + Endpoint = 'fb.example'; ApiVersion = '2.26'; Username = 'jdoe' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null } + Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'dynamic' } $seen = [System.Collections.Generic.List[object]]::new() Mock -CommandName Assert-PfbContextAuthorizationModel -MockWith { $seen.Add([PSCustomObject]@{ Model = $Array.AuthorizationModel; Names = @($Context.Entries.Name) -join ',' }) @@ -74,17 +78,37 @@ Describe 'Set-PfbContext' { Set-PfbContext -Array $fb -Context 'FB-B' | Out-Null @($seen).Count | Should -Be 1 -Because "Set-PfbContext's end{} must call the gate; a count of 0 means the call was deleted or never wired" - $seen[0].Model | Should -Be 'dynamic' -Because 'the gate must receive the connection that carries AuthorizationModel' + $seen[0].Model | Should -Be 'dynamic' -Because 'Set-PfbContext must resolve the model itself and hand the gate a connection carrying it; $null here means the resolution was deleted or never wired' $seen[0].Names | Should -Be 'FB-B' } } + # THE detector for Set-PfbContext's own resolution site, independent of the gate wiring above. + It 'resolves the authorization model itself, exactly once, without mutating the caller' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; Username = 'jdoe' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + } + Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'dynamic' } + + $new = Set-PfbContext -Array $fb -Context 'FB-B' + + Should -Invoke -CommandName Resolve-PfbAuthorizationModel -Times 1 -Exactly + $new.AuthorizationModel | Should -Be 'dynamic' + # Copy-on-write covers the model too: it is written onto the copy, never onto the + # object the caller still holds. + $null -eq $fb.AuthorizationModel | Should -BeTrue -Because 'the model is written onto the copy, not the caller connection' + } + } It 'refuses to set a context for a static-model admin' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; Username = 'pureuser' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = 'static' + DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null } + Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'static' } { Set-PfbContext -Array $fb -Context 'FB-B' } | Should -Throw -ExpectedMessage '*dynamic-authorization-model*' } @@ -93,7 +117,12 @@ Describe 'Set-PfbContext' { InModuleScope 'PureStorageFlashBladePowerShell' { $originalArrays = $script:PfbArrays; $originalDefault = $script:PfbDefaultArray try { - $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null } + # AuthorizationModel is declared because the real connection object declares it + # (Connect-PfbArray.ps1:477) and Set-PfbContext now WRITES it. Omitting it made + # this fixture pass while the property was only ever read; a write to a property a + # PSCustomObject does not have is a hard error, so the omission was latent + # infidelity rather than a harmless shortcut. + $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null } $script:PfbArrays = @{ 'fb.example' = $fb }; $script:PfbDefaultArray = $fb $new = Set-PfbContext -Array $fb -Context 'FB-B' [object]::ReferenceEquals($script:PfbDefaultArray, $new) | Should -BeTrue From 299ddb12df46e81ad8fecad7eca3fa2719b0d4d3 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 21:04:32 -0700 Subject: [PATCH 27/53] fix(context): re-derive the preconditions the two new resolution sites broke Fix round 3. Both Importants are the same mistake: round 2 added call sites without re-deriving what the old single site relied on. - Connect-PfbArray gated on $contextRequested alone, which is TRUE for -Context @() -- this codebase's explicit no-context state -- so a static admin's @() connect paid a probe and hard-threw with an empty value in the message. Now gates on both halves, mirroring Invoke-PfbApiRequest's $hasContext. - Resolve-PfbAuthorizationModel relied on "DefaultContext is still $null when I run", true only of the connect site. Set-PfbContext's $copy inherits the existing context and GET /admins declares context_names, so the identity probe was routed to a remote array -- or the kind gate threw inside the resolver and silently downgraded a known 'dynamic' to $null. The probe now strips both context slots from a copy, inside the resolver, so the invariant holds for every call site present and future. Docstring rewritten: it still asserted the false precondition. Also: Set-PfbContext's help said "No network call is made" and its guard test passed only because the fixture had no Username; both corrected. Comment at the connect block corrected -- the reconnect cache guards are endpoint-keyed, so the reordering narrows the window rather than closing it. Rejected -Context now releases the session it minted, without evicting a prior connection's cache entry. Co-Authored-By: Claude Opus 5 --- Private/Assert-PfbContextSupported.ps1 | 41 +++++++-- Public/Connection/Connect-PfbArray.ps1 | 103 +++++++++++++++++------ Public/Context/Set-PfbContext.ps1 | 18 +++- Tests/Connect-PfbArray.Context.Tests.ps1 | 91 ++++++++++++++++++++ Tests/Set-PfbContext.Tests.ps1 | 33 ++++++-- 5 files changed, 242 insertions(+), 44 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index db54031d..2002ffe5 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -363,11 +363,32 @@ function Resolve-PfbAuthorizationModel { touches reconnect logic shared by every cmdlet in the module. Parked for live measurement in Task 15. Do not change the shared reconnect logic on this note alone. - No recursion risk despite calling Invoke-PfbApiRequest: this runs at connect time, when - the connection's DefaultContext and ContextOverride are both still $null, so - Invoke-PfbApiRequest's $hasContext is false and none of the four shape gates -- including - Assert-PfbContextAuthorizationModel, which is the only one that would read back into this - state -- can fire. + THE PROBE IS ALWAYS CONTEXT-FREE, enforced HERE rather than at the call sites so it holds + for both of them and for any future third one. This asks who the CONNECTED admin is -- an + identity question about the local session -- so routing it through a context would answer + it from a different array. + + An earlier revision relied on a positional precondition instead: "this only runs at connect, + when DefaultContext and ContextOverride are both still $null". That was true of the single + original call site and became FALSE the moment Set-PfbContext became the second one, because + its $copy inherits the connection's EXISTING context. GET /admins declares context_names + (scope: array), so none of the three shape gates stops it, and the probe went out as + ?names=&context_names=. Three outcomes, all wrong: an Array/.arrays + context routed the identity probe to a REMOTE array's admin table; a bare Fleet context made + the kind/scope gate throw INSIDE this function, so the catch below silently downgraded a + known 'dynamic' to $null and the gate failed open for the rest of that connection's life; + an unreachable member did the same. Stripping the context makes the invariant a property of + this function rather than a property of where it is called from. + + Do NOT "fix" this by passing -QueryParams @{ context_names = @() }: with $hasContext false + the injection block is skipped, so that key survives into ConvertTo-PfbQueryString and puts + a bare context_names= on the wire. + + No recursion risk: because the probe carries no context, Invoke-PfbApiRequest's $hasContext + is false for it, so none of the four shape gates -- including + Assert-PfbContextAuthorizationModel, the only one that would read back into this state -- + can fire. That now FOLLOWS FROM the stripping below rather than from when we happen to be + called, which is the whole point of moving the guarantee in here. .OUTPUTS [string] #> @@ -377,7 +398,15 @@ function Resolve-PfbAuthorizationModel { if (-not $Array.Username) { return $null } try { - $admins = @(Invoke-PfbApiRequest -Array $Array -Method 'GET' -Endpoint 'admins' -QueryParams @{ names = $Array.Username }) + # Shallow clone, then null BOTH context slots -- Resolve-PfbRequestContext reads + # ContextOverride first, then DefaultContext, so leaving either populated re-opens the + # defect. A copy rather than mutate-and-restore: the caller's object must not be touched + # even transiently, since Invoke-PfbInContext may be holding it mid-block. + $probe = $Array.PSObject.Copy() + $probe.DefaultContext = $null + $probe.ContextOverride = $null + + $admins = @(Invoke-PfbApiRequest -Array $probe -Method 'GET' -Endpoint 'admins' -QueryParams @{ names = $Array.Username }) $model = ($admins | Where-Object { $_.name -eq $Array.Username } | Select-Object -First 1).authorization_model if ($model) { return [string]$model } return $null diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index fa234782..850a8ba8 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -499,35 +499,82 @@ function Connect-PfbArray { # caches used to be repointed above it -- so a rejected context left a connection this cmdlet # never returned installed as $script:PfbDefaultArray, with the offending context attached: a # "failed" connect that is nonetheless the default array. Validating first and installing - # afterwards is what makes that unreachable, and it is why the two were reordered rather than - # an unwind-on-throw being bolted on. Nothing between here and the install needs the caches -- - # the resolver is passed $connection explicitly, and the reconnect/refresh paths' cache writes - # are guarded on the endpoint already being present, so they no-op rather than installing a - # half-configured connection behind our back. + # afterwards is what NARROWS that, and it is why the two were reordered rather than an + # unwind-on-throw being bolted on. + # + # It narrows rather than eliminates, and the difference matters. Nothing between here and the + # install READS the caches -- the resolver is passed $connection explicitly -- but the + # reconnect and proactive-refresh paths inside Invoke-PfbApiRequest WRITE them + # (Invoke-PfbApiRequest.ps1:147-152 and :266-271), and their guards are ENDPOINT-KEYED: + # ContainsKey($Array.Endpoint) / PfbDefaultArray.Endpoint -eq. Those guards are false only when + # the endpoint is not already cached, i.e. on a FIRST connect. On a re-connect to an endpoint + # already in $script:PfbArrays -- routine -- a 401/403-then-success on the resolver's probe + # fires those writes and repoints both caches at this not-yet-gated $connection, so a + # subsequent gate throw still leaves them pointing at a connection this cmdlet never returned. + # Narrow (needs an already-cached endpoint plus a transient auth failure on the probe) and + # fail-safe in the common case. Closing it properly means capturing and restoring both cache + # slots in a catch -- the very unwind the reordering was chosen to avoid -- so it is recorded + # here rather than fixed. Do not delete this paragraph believing the reordering is airtight. if ($contextRequested) { - # Resolved HERE, not unconditionally at connect: a session that never touches Fusion must - # not pay for a GET /admins round trip (maintainer ruling 2026-08-05). The other site is - # Set-PfbContext's end{}. Exactly two sites, both one-shot session setup -- which is why - # no cache is needed and AuthorizationModel stays two-state-plus-null ('static'/'dynamic' - # known, $null indeterminate -> fail open). Do NOT add a per-call site such as - # Invoke-PfbInContext: it mutates ContextOverride in place on the shared connection and a - # thousand-iteration loop would mean a thousand probes. Do NOT memoize, and do NOT - # introduce a third "not yet asked" state to make memoizing safe. - # - # Best-effort and non-fatal: see Resolve-PfbAuthorizationModel, which also documents that - # this is inert for the DEFAULT -ApiToken set (no Username to look up) and costs ~3 round - # trips rather than 1 on a management-access-policy 403. Safe from recursion: the - # resolver's own Invoke-PfbApiRequest call happens while DefaultContext and - # ContextOverride are both still $null, so no context gate fires. - $connection.AuthorizationModel = Resolve-PfbAuthorizationModel -Array $connection - - $connectContext = New-PfbContext -Entries $contextEntries - - # Closes what the Task 11 review parked as a gap: the connect-time context path is now - # exactly where resolution happens, so it is also where the gate can rule. - Assert-PfbContextAuthorizationModel -Array $connection -Context $connectContext - - $connection.DefaultContext = $connectContext + # BOTH halves of the predicate, deliberately mirroring Invoke-PfbApiRequest's $hasContext + # ($null -ne $resolvedContext -and @(...Entries).Count -gt 0). $contextRequested alone is + # TRUE for -Context @(), which in this codebase is the first-class "explicitly no context" + # state, NOT "a context is being set". Gating only on the flag made + # `-Credential -Context @()` pay a GET /admins probe and then hard-throw with + # an empty value interpolated into the message ("the context '' would return..."), failing + # a connect that asked for no context at all. Keep this recognisably the same predicate as + # the request path's -- a different shape here is how the two drift apart. + if (@($contextEntries).Count -gt 0) { + # Resolved HERE, not unconditionally at connect: a session that never touches Fusion + # must not pay for a GET /admins round trip (maintainer ruling 2026-08-05). The other + # site is Set-PfbContext's end{}. Exactly two sites, both one-shot session setup -- + # which is why no cache is needed and AuthorizationModel stays two-state-plus-null + # ('static'/'dynamic' known, $null indeterminate -> fail open). Do NOT add a per-call + # site such as Invoke-PfbInContext: it mutates ContextOverride in place on the shared + # connection and a thousand-iteration loop would mean a thousand probes. Do NOT + # memoize, and do NOT introduce a third "not yet asked" state to make memoizing safe. + # + # Best-effort and non-fatal: see Resolve-PfbAuthorizationModel, which also documents + # that this is inert for the DEFAULT -ApiToken set (no Username to look up), costs ~3 + # round trips rather than 1 on a management-access-policy 403, and strips the context + # from its own probe so the identity question is never routed to another array. + $connection.AuthorizationModel = Resolve-PfbAuthorizationModel -Array $connection + + # Closes what the Task 11 review parked as a gap: the connect-time context path is now + # exactly where resolution happens, so it is also where the gate can rule. + # + # This is the cmdlet's first throw AFTER a successful login, so the session it just + # minted would otherwise be abandoned with no logout -- a script retrying a rejected + # -Context in a loop would fill the array's session log. Released best-effort here. + # The original error is captured first and re-thrown explicitly so a failure inside the + # logout can never replace or mask it. + try { + Assert-PfbContextAuthorizationModel -Array $connection -Context (New-PfbContext -Entries $contextEntries) + } + catch { + $gateError = $_ + try { + $logoutParams = @{ + Method = 'POST' + Uri = "https://${Endpoint}/api/logout" + Headers = @{ 'x-auth-token' = $authToken } + } + if ($IgnoreCertificateError -and $PSVersionTable.PSVersion.Major -ge 6) { + $logoutParams['SkipCertificateCheck'] = $true + } + Invoke-RestMethod @logoutParams -ErrorAction Stop | Out-Null + } + catch { + Write-Verbose "Could not release the session minted for the rejected connect to '${Endpoint}': $($_.Exception.Message)" + } + # Deliberately NOT Disconnect-PfbArray: that also evicts $Endpoint from the module + # caches, which on a re-connect would destroy the caller's still-valid PREVIOUS + # connection to the same array as a side effect of this one being rejected. + throw $gateError + } + } + + $connection.DefaultContext = New-PfbContext -Entries $contextEntries } # Cache the connection. Last, so only a fully validated connection is ever installed. diff --git a/Public/Context/Set-PfbContext.ps1 b/Public/Context/Set-PfbContext.ps1 index ab63d8a0..398335ae 100644 --- a/Public/Context/Set-PfbContext.ps1 +++ b/Public/Context/Set-PfbContext.ps1 @@ -8,10 +8,20 @@ function Set-PfbContext { caller capturing the return value sees the change. The output IS the effect -- there is no -PassThru. - No network call is made. The context name is not resolved: the wire rejects a bad one - loudly and verbatim on first use (code 42, quoting the offending value), so validating - here would buy only failing one call earlier at the cost of a hidden round trip. - Composition IS validated locally -- see section 9 of the design. + The context name is NOT resolved on the wire: the array rejects a bad one loudly and + verbatim on first use (code 42, quoting the offending value), so validating here would buy + only failing one call earlier at the cost of a hidden round trip. Composition IS validated + locally -- see section 9 of the design. + + One network call IS made, and it is not about the context: a single best-effort + GET /admins reads the connected admin's authorization_model, because only a + dynamic-authorization-model (LDAP/SAML) admin can use a Fusion context at all, and this + cmdlet refuses rather than letting every later request fail with an opaque + 'Operation not permitted' (code 20). It is skipped entirely when the connection has no + username -- which includes every -ApiToken session, the default parameter set -- and any + failure is swallowed, leaving the model indeterminate and this cmdlet permissive. Under a + management-access policy that denies GET /admins it can cost ~3 round trips rather than 1. + Worth knowing before calling this in a loop over many members: cheap, but not free. .NOTES Mixed-platform fleets: Get-PfbFleetMember will happily return FlashArrays. Piping those in is not supported -- cross-platform context is a non-goal (open question 5). diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index 6f7078b7..3f5c296c 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -216,6 +216,35 @@ Describe 'Connect-PfbArray -Context behaviour' { $null -eq $conn.AuthorizationModel | Should -BeTrue } + # The tri-state case and the model-resolving case were covered by DISJOINT sets of tests, and + # the defect lived in their intersection: the only -Context @() test connects with -ApiToken, + # so there was no Username, the resolver early-returned, and the gate failed open. A + # username-bearing @() connect is that missing intersection. + It 'treats -Context @() as no context: no admin probe, no gate, no throw' { + $cred = [System.Management.Automation.PSCredential]::new( + 'pureuser', (ConvertTo-SecureString 'pw' -AsPlainText -Force)) + # A STATIC admin: were the gate to run it would throw -- and with @() it would interpolate + # an empty value into the message ("the context '' would return ..."). + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + [PSCustomObject]@{ + items = @([PSCustomObject]@{ name = 'pureuser'; authorization_model = 'static' }) + total_item_count = 1 + } + } -ParameterFilter { $Uri -match '/admins\?' } + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + [PSCustomObject]@{ items = @() } + } -ParameterFilter { $Uri -match '/admins/api-tokens' } + + $conn = Connect-PfbArray -Endpoint 'fb.test' -Credential $cred -Context @() + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell -CommandName Invoke-RestMethod ` + -ParameterFilter { $Uri -match '/admins\?' } -Times 0 ` + -Because 'an explicit no-context connect names nothing, so there is nothing to pre-validate and no reason to pay for a probe' + # And the tri-state must survive: @() is DefaultContext-set-with-zero-entries, not unset. + $null -ne $conn.DefaultContext | Should -BeTrue + @($conn.DefaultContext.Entries).Count | Should -Be 0 + } + It 'resolves the authorization model exactly once per connect' { # Pins the cost as "one lookup per connect, not one per request". InModuleScope PureStorageFlashBladePowerShell { @@ -399,4 +428,66 @@ Describe 'Resolve-PfbAuthorizationModel' { Should -Invoke -CommandName Invoke-PfbApiRequest -Times 0 } } + # A BOUNDARY assertion on the outgoing URI, so it cannot pass vacuously. The probe asks who the + # CONNECTED admin is; routing it through an existing context asks a DIFFERENT array. Set-PfbContext + # made this reachable -- its $copy inherits the connection's existing context -- and GET /admins + # declares context_names (scope: array), so none of the three shape gates stops it. + It 'strips the context from its own probe, so an existing DefaultContext is not injected' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + Username = 'juemerson' + # A bare Fleet context: the case that made the kind/scope gate throw INSIDE the + # resolver, whose catch then silently downgraded a known 'dynamic' to $null. + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'fleet-prod' -Kind 'Fleet'))) + ContextOverride = $null; AuthorizationModel = $null + } + $uris = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Invoke-RestMethod -MockWith { + $uris.Add($Uri) + [PSCustomObject]@{ + items = @([PSCustomObject]@{ name = 'juemerson'; authorization_model = 'dynamic' }) + total_item_count = 1 + } + } + + $model = Resolve-PfbAuthorizationModel -Array $fb + + @($uris).Count | Should -Be 1 + $uris[0] | Should -Not -Match 'context_names' -Because 'the identity probe must never be context-scoped, or it is answered by another array' + # The downgrade guard: without the strip this returns $null, because the kind gate + # throws inside the resolver and the catch swallows it. + $model | Should -Be 'dynamic' -Because 'an existing context must not be able to downgrade a known model to indeterminate' + # And the caller's connection is untouched -- the strip works on a copy. + @($fb.DefaultContext.Entries).Count | Should -Be 1 + } + } + It 'strips a ContextOverride from its own probe too, not just DefaultContext' { + # Resolve-PfbRequestContext reads ContextOverride FIRST, so nulling only DefaultContext + # would leave the defect fully open inside an Invoke-PfbInContext block. + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + Username = 'juemerson' + DefaultContext = $null + ContextOverride = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) + AuthorizationModel = $null + } + $uris = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Invoke-RestMethod -MockWith { + $uris.Add($Uri) + [PSCustomObject]@{ + items = @([PSCustomObject]@{ name = 'juemerson'; authorization_model = 'dynamic' }) + total_item_count = 1 + } + } + + Resolve-PfbAuthorizationModel -Array $fb | Should -Be 'dynamic' + + @($uris).Count | Should -Be 1 + $uris[0] | Should -Not -Match 'context_names' -Because 'an Array-kind override would route the identity probe to a remote array, and it is read before DefaultContext' + } + } } diff --git a/Tests/Set-PfbContext.Tests.ps1 b/Tests/Set-PfbContext.Tests.ps1 index e303200c..6f249f2d 100644 --- a/Tests/Set-PfbContext.Tests.ps1 +++ b/Tests/Set-PfbContext.Tests.ps1 @@ -6,9 +6,13 @@ BeforeAll { Describe 'Set-PfbContext' { BeforeEach { + # Username IS declared. It was absent, and that absence was silently load-bearing: the + # resolver early-returns without a username, so `It 'makes no network call'` below passed + # by fixture accident rather than because the cmdlet made no call. A fixture that has to + # omit a real property to keep a test green is documenting the wrong thing. $script:fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' - Endpoint = 'fb.example'; ApiVersion = '2.26' + Endpoint = 'fb.example'; ApiVersion = '2.26'; Username = 'jdoe' DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null } } @@ -50,12 +54,29 @@ Describe 'Set-PfbContext' { # under -NonInteractive. { Set-PfbContext -Array $script:fb } | Should -Throw -ExpectedMessage '*-Context*' } - It 'makes no network call' { - Mock -CommandName Invoke-PfbApiRequest -ModuleName 'PureStorageFlashBladePowerShell' -MockWith {} - Mock -CommandName Invoke-RestMethod -ModuleName 'PureStorageFlashBladePowerShell' -MockWith {} + # Was 'makes no network call', which is no longer true and had stopped being a real test: with + # the fixture's Username absent the resolver early-returned, so the -Times 0 assertions held + # for a reason that had nothing to do with the cmdlet's behaviour. Now stated as what is + # actually guaranteed -- the CONTEXT is never resolved on the wire -- and pinned against a + # username-bearing fixture, so exactly one admin read is expected and nothing more. + It 'reads the admin model exactly once and never resolves the context name on the wire' { + Mock -CommandName Invoke-PfbApiRequest -ModuleName 'PureStorageFlashBladePowerShell' -MockWith { + @([PSCustomObject]@{ name = 'jdoe'; authorization_model = 'dynamic' }) + } + Mock -CommandName Invoke-RestMethod -ModuleName 'PureStorageFlashBladePowerShell' -MockWith { + throw 'Set-PfbContext must not call Invoke-RestMethod directly' + } + Set-PfbContext -Array $script:fb -Context 'no-such-array-at-all' | Out-Null - Should -Invoke -CommandName Invoke-PfbApiRequest -ModuleName 'PureStorageFlashBladePowerShell' -Times 0 - Should -Invoke -CommandName Invoke-RestMethod -ModuleName 'PureStorageFlashBladePowerShell' -Times 0 + + # Exactly one request, and it is the admin read -- NOT a lookup of the context name. A + # deliberately non-existent name is used: if this cmdlet ever validated it on the wire, + # that call would be a second invocation here. + Should -Invoke -CommandName Invoke-PfbApiRequest -ModuleName 'PureStorageFlashBladePowerShell' -Times 1 -Exactly + Should -Invoke -CommandName Invoke-PfbApiRequest -ModuleName 'PureStorageFlashBladePowerShell' ` + -ParameterFilter { $Endpoint -eq 'admins' } -Times 1 -Exactly ` + -Because 'the only permitted call is the authorization-model probe' + Should -Invoke -CommandName Invoke-RestMethod -ModuleName 'PureStorageFlashBladePowerShell' -Times 0 } # A SEPARATE call site from Invoke-PfbApiRequest's request path -- the wiring test over there # says nothing about this one, and vice versa. From dc8798962edfb5a79843cb97131e6b60ed9659f6 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 21:35:56 -0700 Subject: [PATCH 28/53] test(context): pin the rejected-connect logout, and record why the probe copy is benign Fix round 4, final for task 11. - The logout added last round had no detector: the existing test's throw-message and cache assertions all hold with the entire try/catch deleted. Now asserted at the Invoke-RestMethod boundary, -Times 1 -Exactly, with a second assertion requiring TimeoutSec so the timeout is pinned too rather than merely added. - The logout carries the connection timeout. An endpoint that accepts TCP and never answers would otherwise stall a cmdlet already on its way to failing. - Corrected the reconnect-window comment: since the context strip those writes repoint the caches at the resolver's PROBE COPY, not $connection. - Recorded at the strip site why the probe copy landing in the caches is benign -- the substitution only persists on a gate throw, a gate throw needs a static admin, and a static admin can never have had a context cached -- and stated the dependency: this stops being benign if a static admin is ever allowed to hold a context, or if either gate moves after its cache write. Co-Authored-By: Claude Opus 5 --- Private/Assert-PfbContextSupported.ps1 | 25 ++++++++++++++++++++++++ Public/Connection/Connect-PfbArray.ps1 | 25 +++++++++++++++--------- Tests/Connect-PfbArray.Context.Tests.ps1 | 15 +++++++++++++- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index 2002ffe5..4e330f7a 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -402,6 +402,31 @@ function Resolve-PfbAuthorizationModel { # ContextOverride first, then DefaultContext, so leaving either populated re-opens the # defect. A copy rather than mutate-and-restore: the caller's object must not be touched # even transiently, since Invoke-PfbInContext may be holding it mid-block. + # + # KNOWN AND RULED BENIGN, with the reasoning recorded rather than just the conclusion, + # because the conclusion depends on facts that could change. Invoke-PfbApiRequest's + # proactive-refresh (:142-152) and 401/403-reconnect (:260-271) paths write the new token + # onto whatever $Array they were handed AND install that same object into + # $script:PfbDefaultArray / $script:PfbArrays. Handed this probe copy, that means (i) a + # refreshed token lands on the copy and is discarded, so the caller keeps a stale token, + # and (ii) the object installed in the caches is this context-stripped probe rather than + # the real connection. + # + # Why neither harms today: + # - The cache substitution only PERSISTS if the gate then throws. Otherwise + # Update-PfbConnectionCache (Set-PfbContext) or the tail-end cache assignment + # (Connect-PfbArray) runs afterwards and overwrites the slot with the right object. + # - A gate throw requires a STATIC admin, and a static admin can never have had a context + # cached in the first place: Connect-PfbArray gates before its cache write, and + # Set-PfbContext gates before Update-PfbConnectionCache. So a stripped copy left in the + # cache is never a LOSS of context relative to what was cached -- it is a valid + # equivalent connection. + # - The stale token self-heals on the next request via the reactive 401 path. + # + # THE DEPENDENCY, stated so it can be checked rather than re-derived: this stops being + # benign if a future change ever (a) lets a static-model admin hold a context, or (b) moves + # either gate to AFTER its cache write. Either one makes a stripped probe copy able to + # persist in the caches in place of a connection that legitimately had a context. $probe = $Array.PSObject.Copy() $probe.DefaultContext = $null $probe.ContextOverride = $null diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index 850a8ba8..b10f6c23 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -509,12 +509,15 @@ function Connect-PfbArray { # ContainsKey($Array.Endpoint) / PfbDefaultArray.Endpoint -eq. Those guards are false only when # the endpoint is not already cached, i.e. on a FIRST connect. On a re-connect to an endpoint # already in $script:PfbArrays -- routine -- a 401/403-then-success on the resolver's probe - # fires those writes and repoints both caches at this not-yet-gated $connection, so a - # subsequent gate throw still leaves them pointing at a connection this cmdlet never returned. - # Narrow (needs an already-cached endpoint plus a transient auth failure on the probe) and - # fail-safe in the common case. Closing it properly means capturing and restoring both cache - # slots in a catch -- the very unwind the reordering was chosen to avoid -- so it is recorded - # here rather than fixed. Do not delete this paragraph believing the reordering is airtight. + # fires those writes and repoints both caches at the object the resolver handed them, which + # since the context strip is the resolver's PROBE COPY, not $connection. So a subsequent gate + # throw still leaves the caches pointing at an object this cmdlet never returned. Narrow (needs + # an already-cached endpoint plus a transient auth failure on the probe) and fail-safe in the + # common case -- see the strip site in Resolve-PfbAuthorizationModel for why the probe copy + # landing there is benign, and on what that depends. Closing it properly means capturing and + # restoring both cache slots in a catch -- the very unwind the reordering was chosen to avoid -- + # so it is recorded here rather than fixed. Do not delete this paragraph believing the + # reordering is airtight. if ($contextRequested) { # BOTH halves of the predicate, deliberately mirroring Invoke-PfbApiRequest's $hasContext # ($null -ne $resolvedContext -and @(...Entries).Count -gt 0). $contextRequested alone is @@ -554,10 +557,14 @@ function Connect-PfbArray { catch { $gateError = $_ try { + # TimeoutSec matters MORE here than on a normal call: the caller is already + # waiting on an error, so an endpoint that accepts TCP and never answers would + # stall a cmdlet that has already decided to fail. $logoutParams = @{ - Method = 'POST' - Uri = "https://${Endpoint}/api/logout" - Headers = @{ 'x-auth-token' = $authToken } + Method = 'POST' + Uri = "https://${Endpoint}/api/logout" + Headers = @{ 'x-auth-token' = $authToken } + TimeoutSec = $timeoutSec } if ($IgnoreCertificateError -and $PSVersionTable.PSVersion.Major -ge 6) { $logoutParams['SkipCertificateCheck'] = $true diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index 3f5c296c..fc8ee694 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -255,13 +255,18 @@ Describe 'Connect-PfbArray -Context behaviour' { } } - It 'rejects a connect-time context for a static-model admin and installs nothing in the caches' { + It 'rejects a connect-time context for a static-model admin, releases the session, and installs nothing in the caches' { # The connect-time context path is now gated -- it is where resolution happens, so it is # where the gate can rule. The cache half of this assertion is the one that matters: the # caches used to be repointed BEFORE this block, so a rejected context left a connection # the cmdlet never returned installed as $script:PfbDefaultArray. + # + # The logout assertion is the SOLE detector for the release: the throw-message and cache + # assertions all still hold with the entire try/catch deleted, so without this line the + # release would be untested while looking covered. InModuleScope PureStorageFlashBladePowerShell { Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'static' } + Mock -CommandName Invoke-RestMethod -MockWith {} -ParameterFilter { $Uri -match '/api/logout' } $sentinel = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'sentinel' } $script:PfbArrays = @{ 'sentinel' = $sentinel } $script:PfbDefaultArray = $sentinel @@ -269,6 +274,14 @@ Describe 'Connect-PfbArray -Context behaviour' { { Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context 'FB-B' } | Should -Throw -ExpectedMessage '*dynamic-authorization-model*' + Should -Invoke -CommandName Invoke-RestMethod -ParameterFilter { $Uri -match '/api/logout' } -Times 1 -Exactly ` + -Because 'this is the cmdlet first throw after a successful login, so the session it minted must be released rather than abandoned' + # Same call, but additionally requiring a timeout: without one, an endpoint that accepts + # TCP and never answers would stall a cmdlet that has already decided to fail. + Should -Invoke -CommandName Invoke-RestMethod ` + -ParameterFilter { $Uri -match '/api/logout' -and $TimeoutSec -gt 0 } -Times 1 -Exactly ` + -Because 'the logout must carry the connection timeout, since the caller is already waiting on an error' + [object]::ReferenceEquals($script:PfbDefaultArray, $sentinel) | Should -BeTrue -Because 'a rejected connect must not become the default array' $script:PfbArrays.ContainsKey('fb.test') | Should -BeFalse } From 26844ae3c4aa83fde7e6320f9a7e011a9b9294a2 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 23:02:47 -0700 Subject: [PATCH 29/53] feat(context): annotate context-targeting failures with the active context --- Private/Assert-PfbContextSupported.ps1 | 71 +++++++++++ Private/Invoke-PfbApiRequest.ps1 | 17 ++- ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 114 ++++++++++++++++++ 3 files changed, 200 insertions(+), 2 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index 4e330f7a..ec838d57 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -483,3 +483,74 @@ function Assert-PfbContextAuthorizationModel { $names = @($Context.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ', ' throw "Setting a Fusion context requires a dynamic-authorization-model (LDAP/SAML) admin; static-model admins, including pureuser and other local accounts such as custom local users and service accounts, are not permitted. The connected admin '$($Array.Username)' is static-model, so the context '$names' would return 'Operation not permitted' (code 20) on any cross-array call regardless of its value." } + +function Add-PfbContextErrorAnnotation { + <# + .SYNOPSIS + Annotates a context-targeting API failure with the active context and how to change it. + .DESCRIPTION + The array answers an unresolvable context with code 42 "Cannot find array in fleet", + which reaches the caller as a bare message naming neither the offending value nor the + fact that a session default set several calls earlier is responsible. With contextScope + in hand the annotation can also name the required KIND, not merely the value that + failed. + + THE code 20 CASE IS THE REACTIVE HALF OF Assert-PfbContextAuthorizationModel, not a + duplicate of it. That gate can only throw proactively when the authorization model is + known, and it is NOT known for an -ApiToken session (no Username to look up, so + Resolve-PfbAuthorizationModel returns $null and the gate fails open) nor for a session + that only ever supplies a context through Invoke-PfbInContext. In both cases the wire's + bare code 20 "Operation not permitted" is the only signal the user ever gets. Do not + remove this branch on the grounds that Task 11's gate "already covers it". + + Keying on a bare 'Operation not permitted' is safe HERE specifically because the function + has already returned unless a context is active: a permission failure with a context set + is overwhelmingly this cause. The annotation is advisory and hedged with "may be" -- a + wrong hint costs nothing, a missing one costs a support case -- so do not try to narrow + it further. + .OUTPUTS + [string] + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][AllowEmptyString()][string]$Message, + [Parameter()][AllowNull()]$Context, + [Parameter(Mandatory)][string]$Method, + [Parameter(Mandatory)][string]$Endpoint, + [Parameter()][AllowNull()]$CapabilityMap + ) + + # Tri-state, same predicate as Invoke-PfbApiRequest's $hasContext: $null is unset and an + # existing context with no entries is an explicit "run locally". Neither has a context value + # to name, so both leave the message untouched. Never truthiness on the context OBJECT. + if ($null -eq $Context -or @($Context.Entries).Count -eq 0) { return $Message } + + # A permission failure gets a DIFFERENT explanation from a targeting failure, so it is + # matched separately rather than folded into the alternation below. + $isPermissionFailure = $Message -match 'Operation not permitted' + + # Only the server's context-targeting failures. Matching more broadly would append + # context noise to unrelated errors. + if (-not $isPermissionFailure -and + $Message -notmatch 'Cannot find array in fleet|Executor not found|Invalid context|Cannot specify (parameter|context)') { + return $Message + } + + $names = @($Context.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ', ' + $key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint + $scope = Get-PfbEndpointContextScope -Method $Method -Endpoint $Endpoint -CapabilityMap $CapabilityMap + + $requirement = if ($isPermissionFailure) { + " The connected admin may be a static-authorization-model account -- Fusion contexts require a dynamic-model (LDAP/SAML) admin, and local users and service accounts are all static." + } + else { + switch ($scope) { + 'fleet' { " $key targets a fleet-scoped resource, which requires a bare fleet context." } + 'array' { " $key is array-scoped: use a member array name, or '.arrays' to target every array in a fleet." } + default { '' } + } + } + + "$Message (active context: $names.$requirement Change it with Set-PfbContext, remove it with Clear-PfbContext, or override it for one call with Invoke-PfbInContext.)" +} diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index bfb7a4e7..d348520a 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -226,6 +226,19 @@ function Invoke-PfbApiRequest { $statusCode = [int]$_.Exception.Response.StatusCode } + # One site, both throws. Built here rather than at each throw so the two paths cannot + # drift, and so $_ is unambiguously the OUTER catch's error record -- at the + # reconnect-failed throw below we sit after an inner try/catch, where which error $_ + # names is a question nobody should have to answer. + # + # $resolvedContext and $capabilityMap are the function-scoped locals resolved once at + # the top of the request path. Do NOT re-resolve or re-fetch either here: a second + # resolution could disagree with the one that was actually sent on the wire, which + # would make the annotation name a context the failing call never used. + $apiError = ConvertTo-PfbApiError -Method $Method -Endpoint $Endpoint -ErrorRecord $_ + $apiError = Add-PfbContextErrorAnnotation -Message $apiError -Context $resolvedContext ` + -Method $Method -Endpoint $Endpoint -CapabilityMap $capabilityMap + # Auto-reconnect on an auth failure: ApiToken/Credential/PSCredential sessions # have a cached long-lived API token to re-login with; Certificate sessions # refresh the OAuth2 access token instead (fallback for what the proactive check @@ -278,11 +291,11 @@ function Invoke-PfbApiRequest { } if (-not $reconnectSucceeded) { - throw (ConvertTo-PfbApiError -Method $Method -Endpoint $Endpoint -ErrorRecord $_) + throw $apiError } } else { - throw (ConvertTo-PfbApiError -Method $Method -Endpoint $Endpoint -ErrorRecord $_) + throw $apiError } } diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index 035fcfa5..c03a0cf4 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -388,4 +388,118 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $required[0] | Should -Be 'file-systems' } } + Context 'error annotation' { + It 'names the active context value in a code 42 failure' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q')) + $msg = Add-PfbContextErrorAnnotation -Message 'FlashBlade API error: Cannot find array in fleet' ` + -Context $ctx -Method 'GET' -Endpoint 'file-systems' -CapabilityMap (Get-PfbCapabilityMap) + $msg | Should -BeLike '*FB-Q*' + } + } + It 'names the cmdlets that set a context, so a stale session default is diagnosable' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q')) + $msg = Add-PfbContextErrorAnnotation -Message 'FlashBlade API error: Cannot find array in fleet' ` + -Context $ctx -Method 'GET' -Endpoint 'file-systems' -CapabilityMap (Get-PfbCapabilityMap) + $msg | Should -BeLike '*Set-PfbContext*' + } + } + It 'leaves an unrelated error message alone' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + $msg = Add-PfbContextErrorAnnotation -Message 'FlashBlade API error: File system already exists' ` + -Context $ctx -Method 'POST' -Endpoint 'file-systems' -CapabilityMap (Get-PfbCapabilityMap) + $msg | Should -Be 'FlashBlade API error: File system already exists' + } + } + It 'does not annotate when no context was active' { + InModuleScope 'PureStorageFlashBladePowerShell' { + Add-PfbContextErrorAnnotation -Message 'FlashBlade API error: Cannot find array in fleet' ` + -Context $null -Method 'GET' -Endpoint 'file-systems' -CapabilityMap (Get-PfbCapabilityMap) | + Should -Be 'FlashBlade API error: Cannot find array in fleet' + } + } + It 'does not annotate an EXPLICIT no-context (empty Entries), not just an unset one' { + # Tri-state, per Global Constraints: $null is unset, an empty Entries collection is an + # explicit "no context". Both must skip annotation, and the $null test above says nothing + # about the empty case -- the implementation's guard is a two-clause -or, so a mutation + # deleting the second clause survives without this test. + InModuleScope 'PureStorageFlashBladePowerShell' { + $ctx = New-PfbContext -Entries @() + @($ctx.Entries).Count | Should -Be 0 + Add-PfbContextErrorAnnotation -Message 'FlashBlade API error: Cannot find array in fleet' ` + -Context $ctx -Method 'GET' -Endpoint 'file-systems' -CapabilityMap (Get-PfbCapabilityMap) | + Should -Be 'FlashBlade API error: Cannot find array in fleet' + } + } + # Step 3a. Task 11's proactive authorization-model gate cannot fire for an -ApiToken session + # (no Username to look up) or for a session that only ever uses Invoke-PfbInContext, so for + # those the wire's bare code 20 "Operation not permitted" is the ONLY signal the user gets. + It 'explains a code 20 permission failure as a likely static-authorization-model admin' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q')) + $msg = Add-PfbContextErrorAnnotation -Message 'FlashBlade API error (HTTP 400): Operation not permitted' ` + -Context $ctx -Method 'GET' -Endpoint 'file-systems' -CapabilityMap (Get-PfbCapabilityMap) + $msg | Should -BeLike '*static-authorization-model*' + $msg | Should -BeLike '*FB-Q*' + } + } + It 'leaves a code 20 permission failure alone when no context is active' { + InModuleScope 'PureStorageFlashBladePowerShell' { + Add-PfbContextErrorAnnotation -Message 'FlashBlade API error (HTTP 400): Operation not permitted' ` + -Context $null -Method 'GET' -Endpoint 'file-systems' -CapabilityMap (Get-PfbCapabilityMap) | + Should -Be 'FlashBlade API error (HTTP 400): Operation not permitted' + } + } + } + # Step 4a. Unit tests of Add-PfbContextErrorAnnotation prove nothing about the CALL SITE, so + # these two mock at the Invoke-RestMethod boundary and let the real request path run. There are + # two throw sites in the catch and both must annotate. + Context 'error annotation is actually wired into the request path' { + It 'annotates a context-targeting failure on the plain throw site' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + ApiToken = $null + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q'))) + ContextOverride = $null; AuthorizationModel = $null + } + # No Response member on the exception, so the status is $null: the reconnect gate + # cannot fire and the failure takes the else branch. + Mock -CommandName Invoke-RestMethod -MockWith { throw 'Cannot find array in fleet' } + + { Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' } | + Should -Throw -ExpectedMessage '*FB-Q*' + { Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' } | + Should -Throw -ExpectedMessage '*Clear-PfbContext*' + } + } + It 'annotates on the reconnect-failed throw site too (403 with the reconnect unavailable)' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + ApiToken = 'T-fake-token' + DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q'))) + ContextOverride = $null; AuthorizationModel = $null + } + # A 403 on a reconnectable session enters the reconnect block; the re-login then + # fails, so the throw comes from inside that block rather than the else branch. + Mock -CommandName Invoke-RestMethod -MockWith { + $ex = New-Object System.Exception('Operation not permitted') + $response = [PSCustomObject]@{ StatusCode = [System.Net.HttpStatusCode]403 } + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $response -Force + throw $ex + } + Mock -CommandName Connect-PfbArrayInternal -MockWith { throw 'reconnect unavailable' } + + { Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' } | + Should -Throw -ExpectedMessage '*FB-Q*' + { Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' } | + Should -Throw -ExpectedMessage '*(HTTP 403)*' + } + } + } } From 6a824bac10a2477cff331e9f4c4c16cf18a7550e Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 23:15:56 -0700 Subject: [PATCH 30/53] test(context): pin the hoisted error source and both scope-advice branches --- Private/Invoke-PfbApiRequest.ps1 | 5 +++ ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 36 +++++++++++++++++++ .../Invoke-PfbApiRequest.Reconnect.Tests.ps1 | 35 ++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index d348520a..e82917c8 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -235,6 +235,11 @@ function Invoke-PfbApiRequest { # the top of the request path. Do NOT re-resolve or re-fetch either here: a second # resolution could disagree with the one that was actually sent on the wire, which # would make the annotation name a context the failing call never used. + # + # Cost-only consequence, accepted deliberately: this also runs when the reconnect below + # goes on to SUCCEED, so a recovering request pays two side-effect-free helper calls it + # does not use. Both helpers must therefore stay non-throwing -- a throw in either would + # convert a request that was about to recover into a hard failure. $apiError = ConvertTo-PfbApiError -Method $Method -Endpoint $Endpoint -ErrorRecord $_ $apiError = Add-PfbContextErrorAnnotation -Message $apiError -Context $resolvedContext ` -Method $Method -Endpoint $Endpoint -CapabilityMap $capabilityMap diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index c03a0cf4..a865c07c 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -445,6 +445,42 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $msg | Should -BeLike '*FB-Q*' } } + # The scope advice is the feature's actual value, and every assertion above is satisfied by + # the surrounding sentence alone -- so without these two, swapping the switch's branch + # strings or deleting $requirement entirely survives the whole file. Each It below picks an + # endpoint whose REAL contextScope in Data/PfbCapabilityMap.json selects the branch it + # names: GET /file-systems is scope 'array' (provenance 'default') and GET /presets/workload + # is scope 'fleet' (provenance 'declared'). Verified against the checked-in map, not assumed. + It 'names the FLEET requirement on a fleet-scoped endpoint' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + # Guard the premise: if this endpoint stops being fleet-scoped the test would + # silently start exercising the array branch instead. + (Get-PfbEndpointContextScope -Method 'GET' -Endpoint 'presets/workload' -CapabilityMap $map) | + Should -Be 'fleet' + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q')) + $msg = Add-PfbContextErrorAnnotation -Message 'FlashBlade API error: Cannot find array in fleet' ` + -Context $ctx -Method 'GET' -Endpoint 'presets/workload' -CapabilityMap $map + $msg | Should -BeLike '*requires a bare fleet context*' + $msg | Should -BeLike '*GET /presets/workload*' + # The array branch's wording must NOT appear, or a swap of the two strings passes. + $msg | Should -Not -BeLike '*is array-scoped*' + } + } + It 'names the ARRAY-scoped guidance on an array-scoped endpoint' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $map = Get-PfbCapabilityMap + (Get-PfbEndpointContextScope -Method 'GET' -Endpoint 'file-systems' -CapabilityMap $map) | + Should -Be 'array' + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q')) + $msg = Add-PfbContextErrorAnnotation -Message 'FlashBlade API error: Cannot find array in fleet' ` + -Context $ctx -Method 'GET' -Endpoint 'file-systems' -CapabilityMap $map + $msg | Should -BeLike '*is array-scoped*' + $msg | Should -BeLike "*use a member array name*" + $msg | Should -BeLike "*.arrays*" + $msg | Should -Not -BeLike '*requires a bare fleet context*' + } + } It 'leaves a code 20 permission failure alone when no context is active' { InModuleScope 'PureStorageFlashBladePowerShell' { Add-PfbContextErrorAnnotation -Message 'FlashBlade API error (HTTP 400): Operation not permitted' ` diff --git a/Tests/Invoke-PfbApiRequest.Reconnect.Tests.ps1 b/Tests/Invoke-PfbApiRequest.Reconnect.Tests.ps1 index 3cc1199b..b1cfff9f 100644 --- a/Tests/Invoke-PfbApiRequest.Reconnect.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.Reconnect.Tests.ps1 @@ -184,4 +184,39 @@ Describe 'Invoke-PfbApiRequest reconnect on session-token rejection' { } } | Should -Throw -ExpectedMessage '*(HTTP 403)*' } + + It 'reports the ORIGINAL failure, not the reconnect failure, when the two are distinguishable' { + # The It above cannot detect which error is reported: its fixture makes BOTH the original + # rejection and the retry a 403, so '*(HTTP 403)*' passes either way. This one makes them + # tell apart -- the array refuses with a 403, and the re-login then fails with a plain + # non-HTTP error (DNS/connection class, no .Response at all). + # + # WHY THE ORIGINAL IS THE RIGHT ANSWER, and why this test exists at all. `catch` does not + # push a new scope, so before the message construction was hoisted to the top of the outer + # catch, `$_` at the reconnect-failed throw still held the INNER catch's error record -- + # meaning that site formatted the RETRY's error and silently discarded the array's actual + # rejection. The comment on the It above always claimed otherwise. Hoisting made the claim + # true; this pins it, because nothing else does. + $array = New-TestConnection + Mock -ModuleName PureStorageFlashBladePowerShell Connect-PfbArrayInternal { + throw 'no such host is known: fb.test' + } + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + throw (New-MockHttpError -StatusCode 403 -Message 'the array refused the session token') + } -ParameterFilter { $Uri -like '*file-systems*' } + + $thrown = $null + try { + InModuleScope PureStorageFlashBladePowerShell -Parameters @{ array = $array } { + Invoke-PfbApiRequest -Array $array -Method GET -Endpoint 'file-systems' | Out-Null + } + } + catch { $thrown = $_.Exception.Message } + + # Sanctioned idiom rather than -Not -BeNullOrEmpty, which this branch bans outright. + ($null -ne $thrown) | Should -BeTrue -Because 'the call must fail once the reconnect fails' + $thrown | Should -BeLike '*the array refused the session token*' + $thrown | Should -BeLike '*(HTTP 403)*' -Because 'the status belongs to the original response' + $thrown | Should -Not -BeLike '*no such host*' -Because 'the reconnect failure must not replace the original' + } } From 09b7d59a6871eb1e655c56e335fbb39628bb48b1 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 23:19:51 -0700 Subject: [PATCH 31/53] fix(context): give the permission case a remedy that can actually work --- Private/Assert-PfbContextSupported.ps1 | 14 +++++++- ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index ec838d57..dc34529f 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -552,5 +552,17 @@ function Add-PfbContextErrorAnnotation { } } - "$Message (active context: $names.$requirement Change it with Set-PfbContext, remove it with Clear-PfbContext, or override it for one call with Invoke-PfbInContext.)" + # The REMEDY is branch-specific, and must stay that way. Offering the context cmdlets to a + # static-model admin points them at the one lever that cannot help: no context VALUE works for + # that account, so "change it / clear it / override it" is wrong advice delivered immediately + # after correctly explaining that the account is the problem. Naming the active context value + # is still right there -- that is diagnostic, not advice. Do NOT re-merge these two clauses. + $remedy = if ($isPermissionFailure) { + ' Reconnect as a dynamic-model (LDAP/SAML) admin to use a context at all; changing or clearing the context will not help.' + } + else { + ' Change it with Set-PfbContext, remove it with Clear-PfbContext, or override it for one call with Invoke-PfbInContext.' + } + + "$Message (active context: $names.$requirement$remedy)" } diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index a865c07c..94700526 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -489,6 +489,39 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { } } } + # Deliberately a SEPARATE Context from the scope-advice assertions above. Those pin WHICH SCOPE + # ADVICE appears; these pin WHICH REMEDY appears. Folding both into one It would leave a future + # reader unable to tell which of the two behaviours a failure refers to. + Context 'remedy advice is branch-specific' { + It 'does NOT offer the context cmdlets to a likely static-model admin' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # A static-authorization-model admin cannot fix a code 20 by changing, clearing or + # overriding the context -- no context VALUE works for that account. This negative + # assertion is the load-bearing one: without it, a future edit that re-merges the + # two closing clauses passes silently. + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q')) + $msg = Add-PfbContextErrorAnnotation -Message 'FlashBlade API error (HTTP 400): Operation not permitted' ` + -Context $ctx -Method 'GET' -Endpoint 'file-systems' -CapabilityMap (Get-PfbCapabilityMap) + $msg | Should -Not -BeLike '*Set-PfbContext*' + $msg | Should -Not -BeLike '*Clear-PfbContext*' + $msg | Should -Not -BeLike '*Invoke-PfbInContext*' + $msg | Should -BeLike '*dynamic-model (LDAP/SAML) admin*' + # Naming the value is diagnostic, not advice, so it must still be there. + $msg | Should -BeLike '*FB-Q*' + } + } + It 'still offers the context cmdlets for a targeting failure, where the context IS the fix' { + InModuleScope 'PureStorageFlashBladePowerShell' { + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q')) + $msg = Add-PfbContextErrorAnnotation -Message 'FlashBlade API error: Cannot find array in fleet' ` + -Context $ctx -Method 'GET' -Endpoint 'file-systems' -CapabilityMap (Get-PfbCapabilityMap) + $msg | Should -BeLike '*Set-PfbContext*' + $msg | Should -BeLike '*Clear-PfbContext*' + $msg | Should -BeLike '*Invoke-PfbInContext*' + $msg | Should -Not -BeLike '*Reconnect as a dynamic-model*' + } + } + } # Step 4a. Unit tests of Add-PfbContextErrorAnnotation prove nothing about the CALL SITE, so # these two mock at the Invoke-RestMethod boundary and let the real request path run. There are # two throw sites in the catch and both must annotate. From 044d7880caa292f41003b6eaee0b8ef2c6140458 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 5 Aug 2026 23:28:35 -0700 Subject: [PATCH 32/53] docs(test): record the measured $_ scoping, not the falsified leakage story --- .../Invoke-PfbApiRequest.Reconnect.Tests.ps1 | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Tests/Invoke-PfbApiRequest.Reconnect.Tests.ps1 b/Tests/Invoke-PfbApiRequest.Reconnect.Tests.ps1 index b1cfff9f..5dd6b4de 100644 --- a/Tests/Invoke-PfbApiRequest.Reconnect.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.Reconnect.Tests.ps1 @@ -191,12 +191,20 @@ Describe 'Invoke-PfbApiRequest reconnect on session-token rejection' { # tell apart -- the array refuses with a 403, and the re-login then fails with a plain # non-HTTP error (DNS/connection class, no .Response at all). # - # WHY THE ORIGINAL IS THE RIGHT ANSWER, and why this test exists at all. `catch` does not - # push a new scope, so before the message construction was hoisted to the top of the outer - # catch, `$_` at the reconnect-failed throw still held the INNER catch's error record -- - # meaning that site formatted the RETRY's error and silently discarded the array's actual - # rejection. The comment on the It above always claimed otherwise. Hoisting made the claim - # true; this pins it, because nothing else does. + # WHY THE ORIGINAL IS THE RIGHT ANSWER, and why this test exists at all. + # + # MEASURED, because the intuition here is wrong in both directions. `catch` DOES scope $_: + # after an inner try/catch completes, $_ reverts to the enclosing catch's error record. + # Confirmed on pwsh 7 and WinPS 5.1 with + # try { throw 'OUTER' } catch { try { throw 'INNER' } catch { }; $_.Exception.Message } + # which prints OUTER on both. So the reconnect-failed site reported the ORIGINAL error both + # before and after the message construction was hoisted to the top of the outer catch -- the + # hoist is de-duplication, NOT a fix for a $_-leakage bug. Do not "restore" a bug here that + # never existed. + # + # What was genuinely missing is a test: the It above cannot detect a regression in WHICH + # error is reported, because its fixture makes both a 403. This one can, and reds if the + # construction is ever moved into the inner catch. $array = New-TestConnection Mock -ModuleName PureStorageFlashBladePowerShell Connect-PfbArrayInternal { throw 'no such host is known: fb.test' From a7cba8dfcd6e40038e814b1aac9b4903b48d72bb Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 00:35:21 -0700 Subject: [PATCH 33/53] fix(context): gate Fusion contexts on admin locality, not authorization model A controlled experiment on FB-A (REST 2.26) falsified Task 11's premise: flipping authorization_model on a remote admin changed nothing, while the local pureuser was denied cross-array regardless. Remote => permitted, local => cross-array denied. The shipped gate emitted false positives that blocked working sessions. Retarget the resolver at Admin.is_local (present from 2.17, the same floor as context_names, so no version guard), rename Resolve-PfbAuthorizationModel -> Resolve-PfbAdminLocality, Assert-PfbContextAuthorizationModel -> Assert-PfbContextAdminLocality and the connection property AuthorizationModel -> AdminLocality, and correct the docstrings, throw message and error annotation that asserted the falsified rule. Values stay tri-state strings 'local'/'remote'/$null. Update-PfbAdmin is untouched: authorization_model is a real request-body parameter there. Co-Authored-By: Claude Opus 5 --- Private/Assert-PfbContextSupported.ps1 | 96 ++++++++------ Private/Invoke-PfbApiRequest.ps1 | 8 +- Public/Connection/Connect-PfbArray.ps1 | 16 +-- Public/Context/Set-PfbContext.ps1 | 22 ++-- Tests/Assert-PfbContextSupported.Tests.ps1 | 54 ++++---- Tests/Connect-PfbArray.Context.Tests.ps1 | 118 +++++++++++------- ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 74 +++++------ Tests/Set-PfbContext.Tests.ps1 | 50 ++++---- 8 files changed, 248 insertions(+), 190 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index dc34529f..e2b104cb 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -321,17 +321,31 @@ function Assert-PfbContextRequired { throw "$key targets a fleet-scoped resource and requires a fleet context, but none is set. Set one with Set-PfbContext -Context -Kind Fleet, or run this call in one with Invoke-PfbInContext -Context -Kind Fleet { ... }. Get the fleet name from Get-PfbFleet." } -function Resolve-PfbAuthorizationModel { +function Resolve-PfbAdminLocality { <# .SYNOPSIS - Best-effort read of the connected admin's authorization_model. + Best-effort read of whether the connected admin authenticates locally or remotely. .DESCRIPTION - Only LDAP/SAML remote admins are 'dynamic'. Since 4.5.0 an admin can create additional - named LOCAL users with the same privileges, and the 4.8.1 service-account admin type - is also local -- so pureuser, custom local users and service accounts are ALL 'static'. - This is not "pureuser vs everyone". - - Returns $null rather than throwing on any failure. An indeterminate model must never fail + Reads `is_local` from the admin's own row and returns 'local' or 'remote'. MEASURED RULE + (controlled experiment on FB-A, REST 2.26, 2026-08-06): remote => cross-array contexts are + PERMITTED; local => cross-array is denied with 'Operation not permitted' (code 20). + Since 4.5.0 an admin can create additional named LOCAL users with the same privileges, and + the 4.8.1 service-account admin type is also local -- so pureuser, custom local users and + service accounts are ALL local. This is not "pureuser vs everyone". + + DO NOT switch this back to `authorization_model`. That was the original design and it was + falsified: a STATIC-REMOTE admin is is_local=$false with authorization_model='static', and + the array SERVES its context calls. Flipping the model on a remote admin changed nothing in + either direction. `authorization_model` only says where POLICIES are read from, which is + orthogonal to whether the fleet recognizes the identity, so reading it here produced false + positives that blocked working sessions. + + `is_local` is on Admin from REST 2.17 -- the same floor as context_names -- so no version + guard is needed or wanted here. Do not use `admin_type` (same signal, but 2.24+, which + would reintroduce the version-era problem for nothing); `user_source` does not exist in the + REST spec at any version (verified 2.17/2.24/2.26/2.28). + + Returns $null rather than throwing on any failure. An indeterminate locality must never fail a Connect-PfbArray, because this data drives a diagnostic and not a correctness gate. THREE distinct routes reach indeterminate, and the third is the common one: 1. GET /admins 403s under a restrictive management-access policy. @@ -340,8 +354,8 @@ function Resolve-PfbAuthorizationModel { early return below fires and the gate is permanently inert for it. Only the Credential, PSCredential and Certificate sets normalize Username (Connect-PfbArray.ps1:206-212). This is a correct application of the fail-open - ruling (no username, no evidence), not a bug: do NOT "fix" it by inferring a model - from the token or by defaulting to 'static'. + ruling (no username, no evidence), not a bug: do NOT "fix" it by inferring a + locality from the token or by defaulting to 'local'. NO .items UNWRAP. Invoke-PfbApiRequest already unwraps the envelope itself -- it collects $response.items into $allItems and returns $allItems.ToArray(), an object[] of admin @@ -351,9 +365,9 @@ function Resolve-PfbAuthorizationModel { pagination and error handling below its early return, and no other list read in the module uses it. Never reintroduce an .items read here. - Matches on name rather than taking row 0: reading the WRONG admin's model is worse than - reading none, because a 'static' read off a peer's row would hard-throw a legitimate LDAP - session out of Set-PfbContext. + Matches on name rather than taking row 0: reading the WRONG admin's locality is worse + than reading none, because an is_local=$true read off a peer's row would hard-throw a + legitimate LDAP session out of Set-PfbContext. Cost on a 403: more than one round trip. Invoke-PfbApiRequest's auto-reconnect gate fires on 403 as well as 401 by design (:223-232 -- real arrays answer 403, not 401, for a bad @@ -376,7 +390,7 @@ function Resolve-PfbAuthorizationModel { ?names=&context_names=. Three outcomes, all wrong: an Array/.arrays context routed the identity probe to a REMOTE array's admin table; a bare Fleet context made the kind/scope gate throw INSIDE this function, so the catch below silently downgraded a - known 'dynamic' to $null and the gate failed open for the rest of that connection's life; + known 'remote' to $null and the gate failed open for the rest of that connection's life; an unreachable member did the same. Stripping the context makes the invariant a property of this function rather than a property of where it is called from. @@ -386,7 +400,7 @@ function Resolve-PfbAuthorizationModel { No recursion risk: because the probe carries no context, Invoke-PfbApiRequest's $hasContext is false for it, so none of the four shape gates -- including - Assert-PfbContextAuthorizationModel, the only one that would read back into this state -- + Assert-PfbContextAdminLocality, the only one that would read back into this state -- can fire. That now FOLLOWS FROM the stripping below rather than from when we happen to be called, which is the whole point of moving the guarantee in here. .OUTPUTS @@ -416,7 +430,7 @@ function Resolve-PfbAuthorizationModel { # - The cache substitution only PERSISTS if the gate then throws. Otherwise # Update-PfbConnectionCache (Set-PfbContext) or the tail-end cache assignment # (Connect-PfbArray) runs afterwards and overwrites the slot with the right object. - # - A gate throw requires a STATIC admin, and a static admin can never have had a context + # - A gate throw requires a LOCAL admin, and a local admin can never have had a context # cached in the first place: Connect-PfbArray gates before its cache write, and # Set-PfbContext gates before Update-PfbConnectionCache. So a stripped copy left in the # cache is never a LOSS of context relative to what was cached -- it is a valid @@ -424,7 +438,7 @@ function Resolve-PfbAuthorizationModel { # - The stale token self-heals on the next request via the reactive 401 path. # # THE DEPENDENCY, stated so it can be checked rather than re-derived: this stops being - # benign if a future change ever (a) lets a static-model admin hold a context, or (b) moves + # benign if a future change ever (a) lets a local admin hold a context, or (b) moves # either gate to AFTER its cache write. Either one makes a stripped probe copy able to # persist in the caches in place of a connection that legitimately had a context. $probe = $Array.PSObject.Copy() @@ -432,36 +446,40 @@ function Resolve-PfbAuthorizationModel { $probe.ContextOverride = $null $admins = @(Invoke-PfbApiRequest -Array $probe -Method 'GET' -Endpoint 'admins' -QueryParams @{ names = $Array.Username }) - $model = ($admins | Where-Object { $_.name -eq $Array.Username } | Select-Object -First 1).authorization_model - if ($model) { return [string]$model } + $row = $admins | Where-Object { $_.name -eq $Array.Username } | Select-Object -First 1 + # $null -ne, never truthiness: is_local is a BOOLEAN, so $false is a real answer ('remote') + # and must not collapse into the indeterminate case the way -not $row.is_local would. + if ($null -ne $row -and $null -ne $row.is_local) { + return $(if ($row.is_local) { 'local' } else { 'remote' }) + } return $null } catch { - Write-Verbose "Could not determine the authorization model for '$($Array.Username)' on $($Array.Endpoint): $($_.Exception.Message). Cross-array context checks will not be pre-validated." + Write-Verbose "Could not determine whether '$($Array.Username)' on $($Array.Endpoint) is a local or remote admin: $($_.Exception.Message). Cross-array context checks will not be pre-validated." return $null } } -function Assert-PfbContextAuthorizationModel { +function Assert-PfbContextAdminLocality { <# .SYNOPSIS - Throws when a static-authorization-model admin sets any Fusion context. + Throws when a LOCALLY authenticated admin sets any Fusion context. .DESCRIPTION - Diagnostic, never a security boundary. A static-model admin's cross-array call fails + Diagnostic, never a security boundary. A local admin's cross-array call fails loudly on the wire with 'Operation not permitted' (code 20), so this gate can never turn a would-be wrong-target success into a failure -- it only replaces an opaque server error with the actionable reason. - Fails OPEN on an indeterminate model and CLOSED on a known-static one. Those are not in + Fails OPEN on an indeterminate locality and CLOSED on a known-local one. Those are not in tension: $null means no evidence (an OAuth2 client with no username, or GET /admins 403 - under a restrictive management-access policy), while 'static' is positive evidence the - call cannot work. Failing closed on the unknown case would block legitimate OAuth2 and + under a restrictive management-access policy), while 'local' is positive evidence the + cross-array call cannot work. Failing closed on the unknown case would block legitimate OAuth2 and restricted-policy sessions while protecting nothing. - Takes -Array, unlike the two pure shape gates, because the model is a property of the + Takes -Array, unlike the two pure shape gates, because the locality is a property of the SESSION rather than of the endpoint -- and takes no -Endpoint or -CapabilityMap for the same reason. -Context does not affect WHETHER this throws (there is no local-array - exemption, so every context is rejected once the model is static) but it is named in the + exemption, so every context is rejected once the admin is local) but it is named in the message, which is what earns it its mandatory slot: the caller sees which values were rejected rather than a generic complaint. #> @@ -471,17 +489,17 @@ function Assert-PfbContextAuthorizationModel { [Parameter(Mandatory)]$Context ) - # Fail OPEN on an indeterminate model. See Resolve-PfbAuthorizationModel. - if ($Array.AuthorizationModel -ne 'static') { return } + # Fail OPEN on an indeterminate locality. See Resolve-PfbAdminLocality. + if ($Array.AdminLocality -ne 'local') { return } - # NO local-array exemption. An earlier draft let a static admin through when the context + # NO local-array exemption. An earlier draft let a local admin through when the context # named only the connected array, but (a) the connection object carries no array NAME to # compare against -- it has Endpoint, an IP or hostname -- so the check could only ever have - # worked against an invented test fixture, and (b) maintainer ruling 2026-08-05: a static + # worked against an invented test fixture, and (b) maintainer ruling 2026-08-05: a local # user has no business setting a context at all. Naming your own array buys nothing anyway, # since the server short-circuits it. Do not reintroduce the exemption or an ArrayName field. $names = @($Context.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ', ' - throw "Setting a Fusion context requires a dynamic-authorization-model (LDAP/SAML) admin; static-model admins, including pureuser and other local accounts such as custom local users and service accounts, are not permitted. The connected admin '$($Array.Username)' is static-model, so the context '$names' would return 'Operation not permitted' (code 20) on any cross-array call regardless of its value." + throw "Setting a Fusion context requires a remotely authenticated (LDAP/SAML) admin; local accounts, including pureuser, custom local users and service accounts, are not permitted. The connected admin '$($Array.Username)' is a local account, so the context '$names' would return 'Operation not permitted' (code 20) on any cross-array call." } function Add-PfbContextErrorAnnotation { @@ -495,10 +513,10 @@ function Add-PfbContextErrorAnnotation { in hand the annotation can also name the required KIND, not merely the value that failed. - THE code 20 CASE IS THE REACTIVE HALF OF Assert-PfbContextAuthorizationModel, not a - duplicate of it. That gate can only throw proactively when the authorization model is + THE code 20 CASE IS THE REACTIVE HALF OF Assert-PfbContextAdminLocality, not a + duplicate of it. That gate can only throw proactively when the admin locality is known, and it is NOT known for an -ApiToken session (no Username to look up, so - Resolve-PfbAuthorizationModel returns $null and the gate fails open) nor for a session + Resolve-PfbAdminLocality returns $null and the gate fails open) nor for a session that only ever supplies a context through Invoke-PfbInContext. In both cases the wire's bare code 20 "Operation not permitted" is the only signal the user ever gets. Do not remove this branch on the grounds that Task 11's gate "already covers it". @@ -542,7 +560,7 @@ function Add-PfbContextErrorAnnotation { $scope = Get-PfbEndpointContextScope -Method $Method -Endpoint $Endpoint -CapabilityMap $CapabilityMap $requirement = if ($isPermissionFailure) { - " The connected admin may be a static-authorization-model account -- Fusion contexts require a dynamic-model (LDAP/SAML) admin, and local users and service accounts are all static." + " The connected admin may be a local account -- Fusion contexts require a remotely authenticated (LDAP/SAML) admin; pureuser, custom local users and service accounts are all local." } else { switch ($scope) { @@ -553,12 +571,12 @@ function Add-PfbContextErrorAnnotation { } # The REMEDY is branch-specific, and must stay that way. Offering the context cmdlets to a - # static-model admin points them at the one lever that cannot help: no context VALUE works for + # local admin points them at the one lever that cannot help: no context VALUE works for # that account, so "change it / clear it / override it" is wrong advice delivered immediately # after correctly explaining that the account is the problem. Naming the active context value # is still right there -- that is diagnostic, not advice. Do NOT re-merge these two clauses. $remedy = if ($isPermissionFailure) { - ' Reconnect as a dynamic-model (LDAP/SAML) admin to use a context at all; changing or clearing the context will not help.' + ' Reconnect as a remotely authenticated (LDAP/SAML) admin to use a context at all; changing or clearing the context will not help.' } else { ' Change it with Set-PfbContext, remove it with Clear-PfbContext, or override it for one call with Invoke-PfbInContext.' diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index e82917c8..e78e10a2 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -96,14 +96,14 @@ function Invoke-PfbApiRequest { # established this. if ($hasContext) { # Fourth shape gate, and the only one placed here rather than above the injection: a - # static-authorization-model admin cannot use a context at all, on any endpoint. Placed - # above the injection it reintroduced exactly the failure Task 10 measured -- a static + # LOCALLY authenticated admin cannot use a context across arrays at all, on any endpoint. Placed + # above the injection it reintroduced exactly the failure Task 10 measured -- a local # admin on a REST 2.20 array calling a context-capable endpoint that needs 2.23 was told # to go obtain an LDAP admin, and only after doing so learned the real blocker was # firmware. Assert-PfbContextCapability defers "recorded but array too old" to # Assert-PfbApiCapability by design, so gates 1-3 all pass in that scenario and this one - # got the last word. Fails open on an indeterminate model. - Assert-PfbContextAuthorizationModel -Array $Array -Context $resolvedContext + # got the last word. Fails open on an indeterminate locality. + Assert-PfbContextAdminLocality -Array $Array -Context $resolvedContext } else { # A fleet-scoped endpoint has no usable no-context default for a mutation or a diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index b10f6c23..01d0fad7 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -474,7 +474,7 @@ function Connect-PfbArray { ContextOverride = $null # Reserved: populated in a later phase. Declared here so every connection object has a # uniform shape. - AuthorizationModel = $null + AdminLocality = $null } # Hide secrets from default display. Sensitive fields (ApiToken, AuthToken, @@ -513,7 +513,7 @@ function Connect-PfbArray { # since the context strip is the resolver's PROBE COPY, not $connection. So a subsequent gate # throw still leaves the caches pointing at an object this cmdlet never returned. Narrow (needs # an already-cached endpoint plus a transient auth failure on the probe) and fail-safe in the - # common case -- see the strip site in Resolve-PfbAuthorizationModel for why the probe copy + # common case -- see the strip site in Resolve-PfbAdminLocality for why the probe copy # landing there is benign, and on what that depends. Closing it properly means capturing and # restoring both cache slots in a catch -- the very unwind the reordering was chosen to avoid -- # so it is recorded here rather than fixed. Do not delete this paragraph believing the @@ -523,7 +523,7 @@ function Connect-PfbArray { # ($null -ne $resolvedContext -and @(...Entries).Count -gt 0). $contextRequested alone is # TRUE for -Context @(), which in this codebase is the first-class "explicitly no context" # state, NOT "a context is being set". Gating only on the flag made - # `-Credential -Context @()` pay a GET /admins probe and then hard-throw with + # `-Credential -Context @()` pay a GET /admins probe and then hard-throw with # an empty value interpolated into the message ("the context '' would return..."), failing # a connect that asked for no context at all. Keep this recognisably the same predicate as # the request path's -- a different shape here is how the two drift apart. @@ -531,17 +531,17 @@ function Connect-PfbArray { # Resolved HERE, not unconditionally at connect: a session that never touches Fusion # must not pay for a GET /admins round trip (maintainer ruling 2026-08-05). The other # site is Set-PfbContext's end{}. Exactly two sites, both one-shot session setup -- - # which is why no cache is needed and AuthorizationModel stays two-state-plus-null - # ('static'/'dynamic' known, $null indeterminate -> fail open). Do NOT add a per-call + # which is why no cache is needed and AdminLocality stays two-state-plus-null + # ('local'/'remote' known, $null indeterminate -> fail open). Do NOT add a per-call # site such as Invoke-PfbInContext: it mutates ContextOverride in place on the shared # connection and a thousand-iteration loop would mean a thousand probes. Do NOT # memoize, and do NOT introduce a third "not yet asked" state to make memoizing safe. # - # Best-effort and non-fatal: see Resolve-PfbAuthorizationModel, which also documents + # Best-effort and non-fatal: see Resolve-PfbAdminLocality, which also documents # that this is inert for the DEFAULT -ApiToken set (no Username to look up), costs ~3 # round trips rather than 1 on a management-access-policy 403, and strips the context # from its own probe so the identity question is never routed to another array. - $connection.AuthorizationModel = Resolve-PfbAuthorizationModel -Array $connection + $connection.AdminLocality = Resolve-PfbAdminLocality -Array $connection # Closes what the Task 11 review parked as a gap: the connect-time context path is now # exactly where resolution happens, so it is also where the gate can rule. @@ -552,7 +552,7 @@ function Connect-PfbArray { # The original error is captured first and re-thrown explicitly so a failure inside the # logout can never replace or mask it. try { - Assert-PfbContextAuthorizationModel -Array $connection -Context (New-PfbContext -Entries $contextEntries) + Assert-PfbContextAdminLocality -Array $connection -Context (New-PfbContext -Entries $contextEntries) } catch { $gateError = $_ diff --git a/Public/Context/Set-PfbContext.ps1 b/Public/Context/Set-PfbContext.ps1 index 398335ae..92aebbc3 100644 --- a/Public/Context/Set-PfbContext.ps1 +++ b/Public/Context/Set-PfbContext.ps1 @@ -14,12 +14,12 @@ function Set-PfbContext { locally -- see section 9 of the design. One network call IS made, and it is not about the context: a single best-effort - GET /admins reads the connected admin's authorization_model, because only a - dynamic-authorization-model (LDAP/SAML) admin can use a Fusion context at all, and this + GET /admins reads the connected admin's is_local, because only a remotely authenticated + (LDAP/SAML) admin can use a Fusion context across arrays at all, and this cmdlet refuses rather than letting every later request fail with an opaque 'Operation not permitted' (code 20). It is skipped entirely when the connection has no username -- which includes every -ApiToken session, the default parameter set -- and any - failure is swallowed, leaving the model indeterminate and this cmdlet permissive. Under a + failure is swallowed, leaving the locality indeterminate and this cmdlet permissive. Under a management-access policy that denies GET /admins it can cost ~3 round trips rather than 1. Worth knowing before calling this in a loop over many members: cheap, but not free. .NOTES @@ -100,24 +100,24 @@ function Set-PfbContext { # $copy.DefaultContext then receive a stringified array instead of a context. Measured. $newContext = New-PfbContext -Entries $entries -AllowErrors $allowErrors - # The copy is taken BEFORE resolving, so the model is written onto the copy and the + # The copy is taken BEFORE resolving, so the locality is written onto the copy and the # caller's connection is never mutated -- this cmdlet's copy-on-write contract holds for - # the model exactly as it does for the context. Resolving onto $target instead would have + # the locality exactly as it does for the context. Resolving onto $target instead would have # been simpler and wrong: it mutates an object the caller may still hold. $copy = Copy-PfbConnection -Array $target - # Resolve the admin's authorization model HERE rather than relying on connect having done + # Resolve the admin's locality HERE rather than relying on connect having done # it. Since the 2026-08-05 ruling Connect-PfbArray only resolves when -Context was # supplied, so on what is now the common path -- bare connect, then Set-PfbContext -- the - # model is still $null at this point, and skipping this would leave the gate permanently + # locality is still $null at this point, and skipping this would leave the gate permanently # failing open. This is the second and last of the two resolution sites; do not add a # third, and do not memoize (see Connect-PfbArray's note). - $copy.AuthorizationModel = Resolve-PfbAuthorizationModel -Array $copy + $copy.AdminLocality = Resolve-PfbAdminLocality -Array $copy - # A static-model admin cannot use a context at all, so say so here rather than letting - # every later call fail with an opaque code 20. Fails open on an indeterminate model. + # A LOCAL admin cannot use a context at all, so say so here rather than letting + # every later call fail with an opaque code 20. Fails open on an indeterminate locality. # Before the cache repoint below: a rejected context must leave no trace. - Assert-PfbContextAuthorizationModel -Array $copy -Context $newContext + Assert-PfbContextAdminLocality -Array $copy -Context $newContext $copy.DefaultContext = $newContext Update-PfbConnectionCache -Array $copy diff --git a/Tests/Assert-PfbContextSupported.Tests.ps1 b/Tests/Assert-PfbContextSupported.Tests.ps1 index be89a7b6..7133f8cf 100644 --- a/Tests/Assert-PfbContextSupported.Tests.ps1 +++ b/Tests/Assert-PfbContextSupported.Tests.ps1 @@ -460,38 +460,50 @@ Describe 'Assert-PfbContextRequired' { } } -Describe 'Assert-PfbContextAuthorizationModel' { - It 'throws for a static-model admin setting a cross-array context' { +Describe 'Assert-PfbContextAdminLocality' { + It 'throws for a local admin setting a cross-array context' { InModuleScope 'PureStorageFlashBladePowerShell' { - $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AuthorizationModel = 'static' } + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AdminLocality = 'local' } $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) - { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | - Should -Throw -ExpectedMessage '*dynamic-authorization-model*' + { Assert-PfbContextAdminLocality -Array $fb -Context $ctx } | + Should -Throw -ExpectedMessage '*remotely authenticated*' } } - It 'allows a dynamic-model admin' { + It 'allows a remote admin' { + # Measured on FB-A 2026-08-06: remote => permitted REGARDLESS of authorization_model. + # A static-REMOTE admin is served, which is why the gate reads locality and not the model. InModuleScope 'PureStorageFlashBladePowerShell' { - $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'juemerson'; AuthorizationModel = 'dynamic' } + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'juemerson'; AdminLocality = 'remote' } $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) - { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | Should -Not -Throw + { Assert-PfbContextAdminLocality -Array $fb -Context $ctx } | Should -Not -Throw } } - It 'FAILS OPEN when the model could not be determined' { + It 'FAILS OPEN when the locality could not be determined' { # OAuth2 client with no username, or GET /admins 403 under a restrictive access policy. # The gate is diagnostic, never a security boundary -- blocking here would deny # legitimate sessions and protect nothing, since the wire still answers code 20. InModuleScope 'PureStorageFlashBladePowerShell' { - $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = $null; AuthorizationModel = $null } + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = $null; AdminLocality = $null } $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) - { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | Should -Not -Throw + { Assert-PfbContextAdminLocality -Array $fb -Context $ctx } | Should -Not -Throw } } - It 'mentions that pureuser and other LOCAL accounts are all static' { + It 'mentions that pureuser, custom local users and service accounts are all local' { InModuleScope 'PureStorageFlashBladePowerShell' { - $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AuthorizationModel = 'static' } + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AdminLocality = 'local' } $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) - { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | - Should -Throw -ExpectedMessage '*local*' + { Assert-PfbContextAdminLocality -Array $fb -Context $ctx } | + Should -Throw -ExpectedMessage '*custom local users and service accounts*' + } + } + It 'qualifies the code 20 claim as CROSS-ARRAY only' { + # A local admin CAN target its own array; the measured denial is cross-array. Promising + # code 20 for every context would be a false claim in the error message. + InModuleScope 'PureStorageFlashBladePowerShell' { + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AdminLocality = 'local' } + $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) + { Assert-PfbContextAdminLocality -Array $fb -Context $ctx } | + Should -Throw -ExpectedMessage '*cross-array call*' } } It 'names the offending context values in the message' { @@ -499,24 +511,24 @@ Describe 'Assert-PfbContextAuthorizationModel' { # throws (there is no local-array exemption), but a caller who set several names needs to # see which ones were rejected, in their wire form. InModuleScope 'PureStorageFlashBladePowerShell' { - $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AuthorizationModel = 'static' } + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AdminLocality = 'local' } $ctx = New-PfbContext -Entries @( (New-PfbContextEntry -Name 'FB-B'), (New-PfbContextEntry -Name 'fleet-prod' -Kind 'Fleet' -Form 'AllArrays') ) - { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | + { Assert-PfbContextAdminLocality -Array $fb -Context $ctx } | Should -Throw -ExpectedMessage '*FB-B, fleet-prod.arrays*' } } - It 'throws for a static-model admin even when the context names only the connected array' { + It 'throws for a local admin even when the context names only the connected array' { # No local-array exemption (maintainer ruling 2026-08-05). The connection object carries # no array NAME to compare against -- only Endpoint, an IP or hostname -- so the old # exemption could only ever have matched an invented fixture. InModuleScope 'PureStorageFlashBladePowerShell' { - $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AuthorizationModel = 'static' } + $fb = [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'pureuser'; AdminLocality = 'local' } $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'fb.example')) - { Assert-PfbContextAuthorizationModel -Array $fb -Context $ctx } | - Should -Throw -ExpectedMessage '*dynamic-authorization-model*' + { Assert-PfbContextAdminLocality -Array $fb -Context $ctx } | + Should -Throw -ExpectedMessage '*remotely authenticated*' } } } diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index fc8ee694..a8468910 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -13,7 +13,7 @@ Describe 'connection context state' { ApiVersion = '2.26' DefaultContext = $null ContextOverride = $null - AuthorizationModel = $null + AdminLocality = $null } $copy = Copy-PfbConnection -Array $fake $copy.DefaultContext = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) @@ -150,14 +150,14 @@ Describe 'Connect-PfbArray -Context behaviour' { It 'exposes the three context state properties on the connection object' { $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' - foreach ($prop in 'DefaultContext', 'ContextOverride', 'AuthorizationModel') { + foreach ($prop in 'DefaultContext', 'ContextOverride', 'AdminLocality') { $conn.PSObject.Properties.Name | Should -Contain $prop } } - # THE detector for Connect-PfbArray.ps1's `$connection.AuthorizationModel = Resolve-...` line. - # Deleting that one line disables the whole authorization-model feature without breaking any - # gate call site, and nothing pinned it: the two pre-existing AuthorizationModel assertions in + # THE detector for Connect-PfbArray.ps1's `$connection.AdminLocality = Resolve-...` line. + # Deleting that one line disables the whole admin-locality feature without breaking any + # gate call site, and nothing pinned it: the two pre-existing AdminLocality assertions in # this file check that the PROPERTY EXISTS (declared in the object literal, so it passes # either way) and that it is $null (which passes either way too, because those harnesses # connect with -ApiToken and so Username is never populated). That gap is why the inert-gate @@ -166,16 +166,16 @@ Describe 'Connect-PfbArray -Context behaviour' { # Must use a username-bearing parameter set: -ApiToken never populates Username, so the # resolver early-returns and this test would pass vacuously against a deleted line. And must # supply -Context: since the 2026-08-05 ruling the resolution happens ONLY on that path. - It 'populates AuthorizationModel from the connected admin when -Context is supplied' { + It 'populates AdminLocality from the connected admin when -Context is supplied' { $cred = [System.Management.Automation.PSCredential]::new( 'jdoe', (ConvertTo-SecureString 'pw' -AsPlainText -Force)) # Boundary mocks, matched with -match so `?` is a literal and not the -like single-char # wildcard: '*/admins?*' would also match the api-tokens URI's '/admins/'. - # 'dynamic', not 'static': a static model plus a connect-time context now throws (see the - # test below), so a static fixture here would be asserting on an unreachable state. + # is_local=$false (remote), not local: a local admin plus a connect-time context now + # throws (see the test below), so a local fixture here would assert an unreachable state. Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { [PSCustomObject]@{ - items = @([PSCustomObject]@{ name = 'jdoe'; authorization_model = 'dynamic' }) + items = @([PSCustomObject]@{ name = 'jdoe'; is_local = $false }) total_item_count = 1 } } -ParameterFilter { $Uri -match '/admins\?' } @@ -187,7 +187,7 @@ Describe 'Connect-PfbArray -Context behaviour' { $conn = Connect-PfbArray -Endpoint 'fb.test' -Credential $cred -Context 'FB-B' - $conn.AuthorizationModel | Should -Be 'dynamic' -Because 'Connect-PfbArray must assign the resolver result onto the connection; a $null here means the capture line was never wired' + $conn.AdminLocality | Should -Be 'remote' -Because 'Connect-PfbArray must assign the resolver result onto the connection; a $null here means the capture line was never wired' @($conn.DefaultContext.Entries).Count | Should -Be 1 } @@ -208,12 +208,12 @@ Describe 'Connect-PfbArray -Context behaviour' { $conn = Connect-PfbArray -Endpoint 'fb.test' -Credential $cred # -Times 0 is the real assertion. The throwing mock body above is NOT sufficient on its - # own: Resolve-PfbAuthorizationModel catches everything and returns $null, so the throw + # own: Resolve-PfbAdminLocality catches everything and returns $null, so the throw # would be swallowed and this test would pass with the call still being made. Should -Invoke -ModuleName PureStorageFlashBladePowerShell -CommandName Invoke-RestMethod ` -ParameterFilter { $Uri -match '/admins\?' } -Times 0 ` - -Because 'a connect with no -Context must not resolve the authorization model at all' - $null -eq $conn.AuthorizationModel | Should -BeTrue + -Because 'a connect with no -Context must not resolve the admin locality at all' + $null -eq $conn.AdminLocality | Should -BeTrue } # The tri-state case and the model-resolving case were covered by DISJOINT sets of tests, and @@ -223,11 +223,11 @@ Describe 'Connect-PfbArray -Context behaviour' { It 'treats -Context @() as no context: no admin probe, no gate, no throw' { $cred = [System.Management.Automation.PSCredential]::new( 'pureuser', (ConvertTo-SecureString 'pw' -AsPlainText -Force)) - # A STATIC admin: were the gate to run it would throw -- and with @() it would interpolate + # A LOCAL admin: were the gate to run it would throw -- and with @() it would interpolate # an empty value into the message ("the context '' would return ..."). Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { [PSCustomObject]@{ - items = @([PSCustomObject]@{ name = 'pureuser'; authorization_model = 'static' }) + items = @([PSCustomObject]@{ name = 'pureuser'; is_local = $true }) total_item_count = 1 } } -ParameterFilter { $Uri -match '/admins\?' } @@ -245,17 +245,17 @@ Describe 'Connect-PfbArray -Context behaviour' { @($conn.DefaultContext.Entries).Count | Should -Be 0 } - It 'resolves the authorization model exactly once per connect' { + It 'resolves the admin locality exactly once per connect' { # Pins the cost as "one lookup per connect, not one per request". InModuleScope PureStorageFlashBladePowerShell { - Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'dynamic' } + Mock -CommandName Resolve-PfbAdminLocality -MockWith { 'remote' } $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context 'FB-B' - $conn.AuthorizationModel | Should -Be 'dynamic' - Should -Invoke -CommandName Resolve-PfbAuthorizationModel -Times 1 -Exactly + $conn.AdminLocality | Should -Be 'remote' + Should -Invoke -CommandName Resolve-PfbAdminLocality -Times 1 -Exactly } } - It 'rejects a connect-time context for a static-model admin, releases the session, and installs nothing in the caches' { + It 'rejects a connect-time context for a local admin, releases the session, and installs nothing in the caches' { # The connect-time context path is now gated -- it is where resolution happens, so it is # where the gate can rule. The cache half of this assertion is the one that matters: the # caches used to be repointed BEFORE this block, so a rejected context left a connection @@ -265,14 +265,14 @@ Describe 'Connect-PfbArray -Context behaviour' { # assertions all still hold with the entire try/catch deleted, so without this line the # release would be untested while looking covered. InModuleScope PureStorageFlashBladePowerShell { - Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'static' } + Mock -CommandName Resolve-PfbAdminLocality -MockWith { 'local' } Mock -CommandName Invoke-RestMethod -MockWith {} -ParameterFilter { $Uri -match '/api/logout' } $sentinel = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'sentinel' } $script:PfbArrays = @{ 'sentinel' = $sentinel } $script:PfbDefaultArray = $sentinel { Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context 'FB-B' } | - Should -Throw -ExpectedMessage '*dynamic-authorization-model*' + Should -Throw -ExpectedMessage '*remotely authenticated*' Should -Invoke -CommandName Invoke-RestMethod -ParameterFilter { $Uri -match '/api/logout' } -Times 1 -Exactly ` -Because 'this is the cmdlet first throw after a successful login, so the session it minted must be released rather than abandoned' @@ -319,7 +319,7 @@ Describe 'Connect-PfbArray -Context behaviour' { $null -ne $conn | Should -BeTrue $null -eq $conn.DefaultContext | Should -BeTrue $null -eq $conn.ContextOverride | Should -BeTrue - $null -eq $conn.AuthorizationModel | Should -BeTrue + $null -eq $conn.AdminLocality | Should -BeTrue } It 'stores a single Array/Object entry for -Context with no -Kind or -AllArrays' { @@ -380,12 +380,12 @@ Describe 'Connect-PfbArray -Context behaviour' { } } -Describe 'Resolve-PfbAuthorizationModel' { - It 'leaves AuthorizationModel null when the admin lookup fails' { +Describe 'Resolve-PfbAdminLocality' { + It 'leaves AdminLocality null when the admin lookup fails' { InModuleScope 'PureStorageFlashBladePowerShell' { Mock -CommandName Invoke-PfbApiRequest -MockWith { throw 'HTTP 403' } - { Resolve-PfbAuthorizationModel -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'u' }) } | Should -Not -Throw - $null -eq (Resolve-PfbAuthorizationModel -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'u' })) | Should -BeTrue + { Resolve-PfbAdminLocality -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'u' }) } | Should -Not -Throw + $null -eq (Resolve-PfbAdminLocality -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'u' })) | Should -BeTrue } } # Mocked at the Invoke-RestMethod boundary, NOT at Invoke-PfbApiRequest. A mock of the thing @@ -395,49 +395,77 @@ Describe 'Resolve-PfbAuthorizationModel' { # hands back an object[] of admin objects. The resolver read .items off that array, got $null # on every real array, and the whole gate was inert in production behind a green suite. # Letting the real Invoke-PfbApiRequest do the unwrap is what makes this a contract test. - It 'reads authorization_model for the connecting username through the real items unwrap' { + It 'reads is_local for the connecting username through the real items unwrap' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' Username = 'juemerson' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + DefaultContext = $null; ContextOverride = $null; AdminLocality = $null } Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ - items = @([PSCustomObject]@{ name = 'juemerson'; authorization_model = 'dynamic' }) + items = @([PSCustomObject]@{ name = 'juemerson'; is_local = $false }) total_item_count = 1 } } - Resolve-PfbAuthorizationModel -Array $fb | Should -Be 'dynamic' + Resolve-PfbAdminLocality -Array $fb | Should -Be 'remote' + } + } + It 'resolves a local admin to local' { + InModuleScope 'PureStorageFlashBladePowerShell' { + Mock -CommandName Invoke-PfbApiRequest -MockWith { @([PSCustomObject]@{ name = 'u'; is_local = $true }) } + Resolve-PfbAdminLocality -Array ([PSCustomObject]@{ Endpoint = 'fb'; Username = 'u'; DefaultContext = $null; ContextOverride = $null }) | + Should -Be 'local' + } + } + It 'resolves a STATIC REMOTE admin to remote' { + # The case that falsified the old design. A static-remote admin is is_local=$false with + # authorization_model='static', and the array SERVES its context calls. If this returns + # 'local', the gate blocks a working session -- the exact defect this task exists to fix. + # Standing regression guard: do NOT switch the resolver back to authorization_model. + InModuleScope 'PureStorageFlashBladePowerShell' { + Mock -CommandName Invoke-PfbApiRequest -MockWith { + @([PSCustomObject]@{ name = 'u'; is_local = $false; authorization_model = 'static' }) + } + Resolve-PfbAdminLocality -Array ([PSCustomObject]@{ Endpoint = 'fb'; Username = 'u'; DefaultContext = $null; ContextOverride = $null }) | + Should -Be 'remote' + } + } + It 'returns null when the row carries no is_local property' { + # $null -ne, never truthiness: absent must yield the indeterminate $null, not 'remote'. + InModuleScope 'PureStorageFlashBladePowerShell' { + Mock -CommandName Invoke-PfbApiRequest -MockWith { @([PSCustomObject]@{ name = 'u' }) } + $null -eq (Resolve-PfbAdminLocality -Array ([PSCustomObject]@{ Endpoint = 'fb'; Username = 'u'; DefaultContext = $null; ContextOverride = $null })) | + Should -BeTrue } } It 'ignores an admin row whose name does not match the connecting username' { - # A wrong-row read is worse than no read: 'static' off a peer's row would hard-throw a - # legitimate LDAP session out of Set-PfbContext. names= is a documented exact-match + # A wrong-row read is worse than no read: is_local=$true off a peer's row would hard-throw + # a legitimate LDAP session out of Set-PfbContext. names= is a documented exact-match # filter, so this is defence in depth rather than an observed server behaviour. InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' Username = 'juemerson' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + DefaultContext = $null; ContextOverride = $null; AdminLocality = $null } Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ - items = @([PSCustomObject]@{ name = 'pureuser'; authorization_model = 'static' }) + items = @([PSCustomObject]@{ name = 'pureuser'; is_local = $true }) total_item_count = 1 } } - $null -eq (Resolve-PfbAuthorizationModel -Array $fb) | Should -BeTrue + $null -eq (Resolve-PfbAdminLocality -Array $fb) | Should -BeTrue } } It 'returns null without a network call when the connection has no username' { InModuleScope 'PureStorageFlashBladePowerShell' { Mock -CommandName Invoke-PfbApiRequest -MockWith { throw 'must not be called' } - $null -eq (Resolve-PfbAuthorizationModel -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = $null })) | Should -BeTrue + $null -eq (Resolve-PfbAdminLocality -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = $null })) | Should -BeTrue Should -Invoke -CommandName Invoke-PfbApiRequest -Times 0 } } @@ -452,26 +480,26 @@ Describe 'Resolve-PfbAuthorizationModel' { Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' Username = 'juemerson' # A bare Fleet context: the case that made the kind/scope gate throw INSIDE the - # resolver, whose catch then silently downgraded a known 'dynamic' to $null. + # resolver, whose catch then silently downgraded a known 'remote' to $null. DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'fleet-prod' -Kind 'Fleet'))) - ContextOverride = $null; AuthorizationModel = $null + ContextOverride = $null; AdminLocality = $null } $uris = [System.Collections.Generic.List[object]]::new() Mock -CommandName Invoke-RestMethod -MockWith { $uris.Add($Uri) [PSCustomObject]@{ - items = @([PSCustomObject]@{ name = 'juemerson'; authorization_model = 'dynamic' }) + items = @([PSCustomObject]@{ name = 'juemerson'; is_local = $false }) total_item_count = 1 } } - $model = Resolve-PfbAuthorizationModel -Array $fb + $locality = Resolve-PfbAdminLocality -Array $fb @($uris).Count | Should -Be 1 $uris[0] | Should -Not -Match 'context_names' -Because 'the identity probe must never be context-scoped, or it is answered by another array' # The downgrade guard: without the strip this returns $null, because the kind gate # throws inside the resolver and the catch swallows it. - $model | Should -Be 'dynamic' -Because 'an existing context must not be able to downgrade a known model to indeterminate' + $locality | Should -Be 'remote' -Because 'an existing context must not be able to downgrade a known locality to indeterminate' # And the caller's connection is untouched -- the strip works on a copy. @($fb.DefaultContext.Entries).Count | Should -Be 1 } @@ -486,18 +514,18 @@ Describe 'Resolve-PfbAuthorizationModel' { Username = 'juemerson' DefaultContext = $null ContextOverride = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) - AuthorizationModel = $null + AdminLocality = $null } $uris = [System.Collections.Generic.List[object]]::new() Mock -CommandName Invoke-RestMethod -MockWith { $uris.Add($Uri) [PSCustomObject]@{ - items = @([PSCustomObject]@{ name = 'juemerson'; authorization_model = 'dynamic' }) + items = @([PSCustomObject]@{ name = 'juemerson'; is_local = $false }) total_item_count = 1 } } - Resolve-PfbAuthorizationModel -Array $fb | Should -Be 'dynamic' + Resolve-PfbAdminLocality -Array $fb | Should -Be 'remote' @($uris).Count | Should -Be 1 $uris[0] | Should -Not -Match 'context_names' -Because 'an Array-kind override would route the identity probe to a remote array, and it is read before DefaultContext' diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index 94700526..de210201 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -33,7 +33,7 @@ Describe 'context injection in Invoke-PfbApiRequest' { PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) - ContextOverride = $null; AuthorizationModel = $null + ContextOverride = $null; AdminLocality = $null } $seen = [System.Collections.Generic.List[object]]::new() Mock -CommandName Assert-PfbApiCapability -MockWith { @@ -51,7 +51,7 @@ Describe 'context injection in Invoke-PfbApiRequest' { PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet' -Form 'AllArrays'))) - ContextOverride = $null; AuthorizationModel = $null + ContextOverride = $null; AdminLocality = $null } $uris = [System.Collections.Generic.List[string]]::new() Mock -CommandName Assert-PfbApiCapability -MockWith {} @@ -66,7 +66,7 @@ Describe 'context injection in Invoke-PfbApiRequest' { PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) - ContextOverride = $null; AuthorizationModel = $null + ContextOverride = $null; AdminLocality = $null } $uris = [System.Collections.Generic.List[string]]::new() Mock -CommandName Assert-PfbApiCapability -MockWith {} @@ -85,7 +85,7 @@ Describe 'context injection in Invoke-PfbApiRequest' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + DefaultContext = $null; ContextOverride = $null; AdminLocality = $null } $uris = [System.Collections.Generic.List[string]]::new() Mock -CommandName Assert-PfbApiCapability -MockWith {} @@ -102,7 +102,7 @@ Describe 'context injection in Invoke-PfbApiRequest' { PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) - ContextOverride = (New-PfbContext -Entries @()); AuthorizationModel = $null + ContextOverride = (New-PfbContext -Entries @()); AdminLocality = $null } $uris = [System.Collections.Generic.List[string]]::new() # The URI alone CANNOT pin this: both downstream sinks (ConvertTo-PfbQueryString and @@ -131,7 +131,7 @@ Describe 'context injection in Invoke-PfbApiRequest' { PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) - ContextOverride = $null; AuthorizationModel = $null + ContextOverride = $null; AdminLocality = $null } $callerParams = @{ limit = 5 } Mock -CommandName Assert-PfbApiCapability -MockWith {} @@ -147,13 +147,13 @@ Describe 'context injection in Invoke-PfbApiRequest' { # unwired from the request path unnoticed. Tasks 10 and 11 add two more gates to this same site, # so the wiring gets its own detector now. Describe 'context gate wiring in Invoke-PfbApiRequest' { - It 'calls all four shape gates in order, capability before cardinality before kindMatchesScope before authorizationModel' { + It 'calls all four shape gates in order, capability before cardinality before kindMatchesScope before adminLocality' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) - ContextOverride = $null; AuthorizationModel = $null + ContextOverride = $null; AdminLocality = $null } # A List with .Add() -- an assignment inside a Mock body does not propagate out, and # `$script:` inside InModuleScope would write to the MODULE's script scope. @@ -161,7 +161,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { Mock -CommandName Assert-PfbContextCapability -MockWith { $calls.Add('capability') } Mock -CommandName Assert-PfbContextCardinality -MockWith { $calls.Add('cardinality') } Mock -CommandName Assert-PfbContextKindMatchesScope -MockWith { $calls.Add('kindMatchesScope') } - Mock -CommandName Assert-PfbContextAuthorizationModel -MockWith { $calls.Add('authorizationModel') } + Mock -CommandName Assert-PfbContextAdminLocality -MockWith { $calls.Add('adminLocality') } Mock -CommandName Assert-PfbApiCapability -MockWith {} Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } @@ -171,37 +171,37 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $calls[0] | Should -Be 'capability' -Because 'the capability gate must rule on "endpoint takes no context at all" first' $calls[1] | Should -Be 'cardinality' $calls[2] | Should -Be 'kindMatchesScope' -Because 'it runs after cardinality: a wrong-KIND context aimed at an endpoint that takes no context at all should hear about capability first, not about scope' - $calls[3] | Should -Be 'authorizationModel' -Because 'the authorization-model gate is diagnostic and endpoint-independent, so the three endpoint-specific gates rule first' + $calls[3] | Should -Be 'adminLocality' -Because 'the admin-locality gate is diagnostic and endpoint-independent, so the three endpoint-specific gates rule first' } } # The It above records only the context gates, so it holds regardless of where the VERSION - # gate sits among them -- it cannot detect the authorization-model gate drifting back above + # gate sits among them -- it cannot detect the admin-locality gate drifting back above # Assert-PfbApiCapability. This one adds the version gate to the recording and pins the whole # sequence. The ruling it encodes was measured in Task 10: a gate that injects nothing and # consults no endpoint must run BELOW the version gate, or a static admin on a REST 2.20 array # calling an endpoint that needs 2.23 is told to go obtain an LDAP admin and only afterwards # learns the real blocker was firmware. Assert-PfbContextCapability defers "recorded but array # too old" to Assert-PfbApiCapability by design, so gates 1-3 do not catch that case. - It 'runs the version gate after the three injecting gates but BEFORE the authorization-model gate' { + It 'runs the version gate after the three injecting gates but BEFORE the admin-locality gate' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) - ContextOverride = $null; AuthorizationModel = $null + ContextOverride = $null; AdminLocality = $null } $calls = [System.Collections.Generic.List[object]]::new() Mock -CommandName Assert-PfbContextCapability -MockWith { $calls.Add('capability') } Mock -CommandName Assert-PfbContextCardinality -MockWith { $calls.Add('cardinality') } Mock -CommandName Assert-PfbContextKindMatchesScope -MockWith { $calls.Add('kindMatchesScope') } - Mock -CommandName Assert-PfbContextAuthorizationModel -MockWith { $calls.Add('authorizationModel') } + Mock -CommandName Assert-PfbContextAdminLocality -MockWith { $calls.Add('adminLocality') } # Recording, not silent: its POSITION is the thing under test here. Mock -CommandName Assert-PfbApiCapability -MockWith { $calls.Add('versionGate') } Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' | Out-Null - @($calls) -join ',' | Should -Be 'capability,cardinality,kindMatchesScope,versionGate,authorizationModel' -Because 'the three injecting gates must precede the version gate (it has to see the injected context_names), and the endpoint-independent authorization-model gate must follow it so a firmware blocker wins over an admin-model one' + @($calls) -join ',' | Should -Be 'capability,cardinality,kindMatchesScope,versionGate,adminLocality' -Because 'the three injecting gates must precede the version gate (it has to see the injected context_names), and the endpoint-independent admin-locality gate must follow it so a firmware blocker wins over an admin-locality one' } } It 'passes each gate the resolved context and the shared capability map' { @@ -210,7 +210,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) - ContextOverride = $null; AuthorizationModel = $null + ContextOverride = $null; AdminLocality = $null } $seen = [System.Collections.Generic.List[object]]::new() Mock -CommandName Assert-PfbContextCapability -MockWith { @@ -222,12 +222,12 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { Mock -CommandName Assert-PfbContextKindMatchesScope -MockWith { $seen.Add([PSCustomObject]@{ Gate = 'kindMatchesScope'; Names = @($Context.Entries.Name) -join ','; Endpoint = $Endpoint; HasMap = ($null -ne $CapabilityMap) }) } - # The authorization-model gate has a DIFFERENT signature from the other three: it + # The admin-locality gate has a DIFFERENT signature from the other three: it # takes -Array (which only the capability gate also takes) and neither -Endpoint nor # -CapabilityMap, because the admin's model is a property of the session, not of the # endpoint. Record what it actually receives rather than forcing it into their shape. $authSeen = [System.Collections.Generic.List[object]]::new() - Mock -CommandName Assert-PfbContextAuthorizationModel -MockWith { + Mock -CommandName Assert-PfbContextAdminLocality -MockWith { $authSeen.Add([PSCustomObject]@{ Names = @($Context.Entries.Name) -join ','; ArrayEndpoint = $Array.Endpoint }) } Mock -CommandName Assert-PfbApiCapability -MockWith {} @@ -242,8 +242,8 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $record.HasMap | Should -BeTrue -Because "$($record.Gate) must receive the capability map, or it silently no-ops" } @($authSeen).Count | Should -Be 1 - $authSeen[0].Names | Should -Be 'FB-B' -Because 'the authorization-model gate must see the RESOLVED context too' - $authSeen[0].ArrayEndpoint | Should -Be 'fb.example' -Because 'it must receive the CONNECTION, which is where AuthorizationModel lives; without -Array it can only ever no-op' + $authSeen[0].Names | Should -Be 'FB-B' -Because 'the admin-locality gate must see the RESOLVED context too' + $authSeen[0].ArrayEndpoint | Should -Be 'fb.example' -Because 'it must receive the CONNECTION, which is where AdminLocality lives; without -Array it can only ever no-op' } } It 'calls none of the four shape gates when no context is set' { @@ -251,13 +251,13 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + DefaultContext = $null; ContextOverride = $null; AdminLocality = $null } $calls = [System.Collections.Generic.List[object]]::new() Mock -CommandName Assert-PfbContextCapability -MockWith { $calls.Add('capability') } Mock -CommandName Assert-PfbContextCardinality -MockWith { $calls.Add('cardinality') } Mock -CommandName Assert-PfbContextKindMatchesScope -MockWith { $calls.Add('kindMatchesScope') } - Mock -CommandName Assert-PfbContextAuthorizationModel -MockWith { $calls.Add('authorizationModel') } + Mock -CommandName Assert-PfbContextAdminLocality -MockWith { $calls.Add('adminLocality') } Mock -CommandName Assert-PfbApiCapability -MockWith {} Mock -CommandName Invoke-RestMethod -MockWith { [PSCustomObject]@{ items = @() } } @@ -269,20 +269,20 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { # An un-mocked companion to the wiring tests above: every one of those mocks the gate, so they # pin that it is CALLED and say nothing about its effect through this function. This one also # fails if the call is ever moved outside the $hasContext branch. - It 'actually throws for a static-model admin with a context, gate un-mocked' { + It 'actually throws for a local admin with a context, gate un-mocked' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' Username = 'pureuser' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) - ContextOverride = $null; AuthorizationModel = 'static' + ContextOverride = $null; AdminLocality = 'local' } Mock -CommandName Assert-PfbApiCapability -MockWith {} Mock -CommandName Invoke-RestMethod -MockWith { throw 'the request must never be attempted' } { Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' } | - Should -Throw -ExpectedMessage '*dynamic-authorization-model*' + Should -Throw -ExpectedMessage '*remotely authenticated*' } } # The It above counts only the four SHAPE gates, so it passes whether or not the @@ -293,7 +293,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + DefaultContext = $null; ContextOverride = $null; AdminLocality = $null } $required = [System.Collections.Generic.List[object]]::new() Mock -CommandName Assert-PfbContextRequired -MockWith { @@ -328,7 +328,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'cc-test-fleet' -Kind 'Fleet'))) - ContextOverride = $null; AuthorizationModel = $null + ContextOverride = $null; AdminLocality = $null } $required = [System.Collections.Generic.List[object]]::new() Mock -CommandName Assert-PfbContextRequired -MockWith { $required.Add($Endpoint) } @@ -355,7 +355,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.20'; AuthToken = 't'; AuthMethod = 'ApiToken' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + DefaultContext = $null; ContextOverride = $null; AdminLocality = $null } Mock -CommandName Invoke-RestMethod -MockWith { throw 'the request must never be attempted' } @@ -375,7 +375,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B'))) - ContextOverride = (New-PfbContext -Entries @()); AuthorizationModel = $null + ContextOverride = (New-PfbContext -Entries @()); AdminLocality = $null } $required = [System.Collections.Generic.List[object]]::new() Mock -CommandName Assert-PfbContextRequired -MockWith { $required.Add($Endpoint) } @@ -433,15 +433,15 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { Should -Be 'FlashBlade API error: Cannot find array in fleet' } } - # Step 3a. Task 11's proactive authorization-model gate cannot fire for an -ApiToken session + # Step 3a. Task 11's proactive admin-locality gate cannot fire for an -ApiToken session # (no Username to look up) or for a session that only ever uses Invoke-PfbInContext, so for # those the wire's bare code 20 "Operation not permitted" is the ONLY signal the user gets. - It 'explains a code 20 permission failure as a likely static-authorization-model admin' { + It 'explains a code 20 permission failure as a likely LOCAL admin' { InModuleScope 'PureStorageFlashBladePowerShell' { $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q')) $msg = Add-PfbContextErrorAnnotation -Message 'FlashBlade API error (HTTP 400): Operation not permitted' ` -Context $ctx -Method 'GET' -Endpoint 'file-systems' -CapabilityMap (Get-PfbCapabilityMap) - $msg | Should -BeLike '*static-authorization-model*' + $msg | Should -BeLike '*may be a local account*' $msg | Should -BeLike '*FB-Q*' } } @@ -493,9 +493,9 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { # ADVICE appears; these pin WHICH REMEDY appears. Folding both into one It would leave a future # reader unable to tell which of the two behaviours a failure refers to. Context 'remedy advice is branch-specific' { - It 'does NOT offer the context cmdlets to a likely static-model admin' { + It 'does NOT offer the context cmdlets to a likely local admin' { InModuleScope 'PureStorageFlashBladePowerShell' { - # A static-authorization-model admin cannot fix a code 20 by changing, clearing or + # A LOCAL admin cannot fix a code 20 by changing, clearing or # overriding the context -- no context VALUE works for that account. This negative # assertion is the load-bearing one: without it, a future edit that re-merges the # two closing clauses passes silently. @@ -505,7 +505,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $msg | Should -Not -BeLike '*Set-PfbContext*' $msg | Should -Not -BeLike '*Clear-PfbContext*' $msg | Should -Not -BeLike '*Invoke-PfbInContext*' - $msg | Should -BeLike '*dynamic-model (LDAP/SAML) admin*' + $msg | Should -BeLike '*remotely authenticated (LDAP/SAML) admin*' # Naming the value is diagnostic, not advice, so it must still be there. $msg | Should -BeLike '*FB-Q*' } @@ -533,7 +533,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' ApiToken = $null DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q'))) - ContextOverride = $null; AuthorizationModel = $null + ContextOverride = $null; AdminLocality = $null } # No Response member on the exception, so the status is $null: the reconnect gate # cannot fire and the failure takes the else branch. @@ -552,7 +552,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' ApiToken = 'T-fake-token' DefaultContext = (New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q'))) - ContextOverride = $null; AuthorizationModel = $null + ContextOverride = $null; AdminLocality = $null } # A 403 on a reconnectable session enters the reconnect block; the re-login then # fails, so the throw comes from inside that block rather than the else branch. diff --git a/Tests/Set-PfbContext.Tests.ps1 b/Tests/Set-PfbContext.Tests.ps1 index 6f249f2d..40ad257b 100644 --- a/Tests/Set-PfbContext.Tests.ps1 +++ b/Tests/Set-PfbContext.Tests.ps1 @@ -13,7 +13,7 @@ Describe 'Set-PfbContext' { $script:fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; Username = 'jdoe' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + DefaultContext = $null; ContextOverride = $null; AdminLocality = $null } } It 'returns a new connection and leaves the original untouched' { @@ -59,9 +59,9 @@ Describe 'Set-PfbContext' { # for a reason that had nothing to do with the cmdlet's behaviour. Now stated as what is # actually guaranteed -- the CONTEXT is never resolved on the wire -- and pinned against a # username-bearing fixture, so exactly one admin read is expected and nothing more. - It 'reads the admin model exactly once and never resolves the context name on the wire' { + It 'reads the admin locality exactly once and never resolves the context name on the wire' { Mock -CommandName Invoke-PfbApiRequest -ModuleName 'PureStorageFlashBladePowerShell' -MockWith { - @([PSCustomObject]@{ name = 'jdoe'; authorization_model = 'dynamic' }) + @([PSCustomObject]@{ name = 'jdoe'; is_local = $false }) } Mock -CommandName Invoke-RestMethod -ModuleName 'PureStorageFlashBladePowerShell' -MockWith { throw 'Set-PfbContext must not call Invoke-RestMethod directly' @@ -75,75 +75,75 @@ Describe 'Set-PfbContext' { Should -Invoke -CommandName Invoke-PfbApiRequest -ModuleName 'PureStorageFlashBladePowerShell' -Times 1 -Exactly Should -Invoke -CommandName Invoke-PfbApiRequest -ModuleName 'PureStorageFlashBladePowerShell' ` -ParameterFilter { $Endpoint -eq 'admins' } -Times 1 -Exactly ` - -Because 'the only permitted call is the authorization-model probe' + -Because 'the only permitted call is the admin-locality probe' Should -Invoke -CommandName Invoke-RestMethod -ModuleName 'PureStorageFlashBladePowerShell' -Times 0 } # A SEPARATE call site from Invoke-PfbApiRequest's request path -- the wiring test over there # says nothing about this one, and vice versa. - It 'calls the authorization-model gate with the target connection and the composed context' { + It 'calls the admin-locality gate with the target connection and the composed context' { InModuleScope 'PureStorageFlashBladePowerShell' { - # AuthorizationModel starts $null, which is now the COMMON state: since the + # AdminLocality starts $null, which is now the COMMON state: since the # 2026-08-05 ruling a bare connect resolves nothing. Set-PfbContext must resolve it - # itself, so the gate seeing 'dynamic' below is evidence it did. + # itself, so the gate seeing 'remote' below is evidence it did. $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; Username = 'jdoe' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + DefaultContext = $null; ContextOverride = $null; AdminLocality = $null } - Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'dynamic' } + Mock -CommandName Resolve-PfbAdminLocality -MockWith { 'remote' } $seen = [System.Collections.Generic.List[object]]::new() - Mock -CommandName Assert-PfbContextAuthorizationModel -MockWith { - $seen.Add([PSCustomObject]@{ Model = $Array.AuthorizationModel; Names = @($Context.Entries.Name) -join ',' }) + Mock -CommandName Assert-PfbContextAdminLocality -MockWith { + $seen.Add([PSCustomObject]@{ Locality = $Array.AdminLocality; Names = @($Context.Entries.Name) -join ',' }) } Set-PfbContext -Array $fb -Context 'FB-B' | Out-Null @($seen).Count | Should -Be 1 -Because "Set-PfbContext's end{} must call the gate; a count of 0 means the call was deleted or never wired" - $seen[0].Model | Should -Be 'dynamic' -Because 'Set-PfbContext must resolve the model itself and hand the gate a connection carrying it; $null here means the resolution was deleted or never wired' + $seen[0].Locality | Should -Be 'remote' -Because 'Set-PfbContext must resolve the locality itself and hand the gate a connection carrying it; $null here means the resolution was deleted or never wired' $seen[0].Names | Should -Be 'FB-B' } } # THE detector for Set-PfbContext's own resolution site, independent of the gate wiring above. - It 'resolves the authorization model itself, exactly once, without mutating the caller' { + It 'resolves the admin locality itself, exactly once, without mutating the caller' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; Username = 'jdoe' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + DefaultContext = $null; ContextOverride = $null; AdminLocality = $null } - Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'dynamic' } + Mock -CommandName Resolve-PfbAdminLocality -MockWith { 'remote' } $new = Set-PfbContext -Array $fb -Context 'FB-B' - Should -Invoke -CommandName Resolve-PfbAuthorizationModel -Times 1 -Exactly - $new.AuthorizationModel | Should -Be 'dynamic' - # Copy-on-write covers the model too: it is written onto the copy, never onto the + Should -Invoke -CommandName Resolve-PfbAdminLocality -Times 1 -Exactly + $new.AdminLocality | Should -Be 'remote' + # Copy-on-write covers the locality too: it is written onto the copy, never onto the # object the caller still holds. - $null -eq $fb.AuthorizationModel | Should -BeTrue -Because 'the model is written onto the copy, not the caller connection' + $null -eq $fb.AdminLocality | Should -BeTrue -Because 'the locality is written onto the copy, not the caller connection' } } - It 'refuses to set a context for a static-model admin' { + It 'refuses to set a context for a local admin' { InModuleScope 'PureStorageFlashBladePowerShell' { $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection' Endpoint = 'fb.example'; ApiVersion = '2.26'; Username = 'pureuser' - DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null + DefaultContext = $null; ContextOverride = $null; AdminLocality = $null } - Mock -CommandName Resolve-PfbAuthorizationModel -MockWith { 'static' } + Mock -CommandName Resolve-PfbAdminLocality -MockWith { 'local' } { Set-PfbContext -Array $fb -Context 'FB-B' } | - Should -Throw -ExpectedMessage '*dynamic-authorization-model*' + Should -Throw -ExpectedMessage '*remotely authenticated*' } } It 'repoints the module caches at the copy' { InModuleScope 'PureStorageFlashBladePowerShell' { $originalArrays = $script:PfbArrays; $originalDefault = $script:PfbDefaultArray try { - # AuthorizationModel is declared because the real connection object declares it + # AdminLocality is declared because the real connection object declares it # (Connect-PfbArray.ps1:477) and Set-PfbContext now WRITES it. Omitting it made # this fixture pass while the property was only ever read; a write to a property a # PSCustomObject does not have is a hard error, so the omission was latent # infidelity rather than a harmless shortcut. - $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null; AuthorizationModel = $null } + $fb = [PSCustomObject]@{ PSTypeName = 'PureStorage.FlashBlade.Connection'; Endpoint = 'fb.example'; DefaultContext = $null; ContextOverride = $null; AdminLocality = $null } $script:PfbArrays = @{ 'fb.example' = $fb }; $script:PfbDefaultArray = $fb $new = Set-PfbContext -Array $fb -Context 'FB-B' [object]::ReferenceEquals($script:PfbDefaultArray, $new) | Should -BeTrue From ca48c7ffbbfed095b531bfd06d1dbfa0dd9ed35a Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 00:57:41 -0700 Subject: [PATCH 34/53] fix(context): re-arm the disarmed remedy assertion, fix the vacuous 403 fixture, correct the falsified gate docstring Task 12c review round 1. Three medium findings, all mechanical: 1. Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 asserted Should -Not -BeLike '*Reconnect as a dynamic-model*', which the rename made unmatchable and therefore unfalsifiable. It is the mirror guard for the "do NOT re-merge these two clauses" invariant. Retargeted at the current production wording and verified armed by temporarily merging the clauses: it reds, on both editions. 2. Tests/Connect-PfbArray.Context.Tests.ps1's 'leaves AdminLocality null when the admin lookup fails' fixture still lacked DefaultContext and ContextOverride, so the resolver's probe strip crashed into its own catch and returned $null before the mocked 403 was reached. Declared both properties and added Should -Invoke Invoke-PfbApiRequest -Times 2 -Exactly, which reports "called 0 times" without them. 3. Assert-PfbContextAdminLocality's .DESCRIPTION claimed the gate "can never turn a would-be wrong-target success into a failure". Live testing falsified that: a local admin can successfully target its own array, so for a self-targeting context the gate does reject a call the server would have served. Stated honestly, with the ruling that makes it intended. Also: three stale model/cross-array wordings reconciled with Set-PfbContext's framing, AdminLocality hashtable alignment, and a pre-existing banned Should -BeNullOrEmpty in Tests/Set-PfbContext.Tests.ps1 converted to the tri-state idiom. The report's false claim that finding 2 was already fixed is corrected in place, marked as a correction. Co-Authored-By: Claude Opus 5 --- Private/Assert-PfbContextSupported.ps1 | 17 +++++++++++++---- Private/Invoke-PfbApiRequest.ps1 | 3 ++- Public/Connection/Connect-PfbArray.ps1 | 2 +- Tests/Connect-PfbArray.Context.Tests.ps1 | 15 ++++++++++++--- ...oke-PfbApiRequest.ContextInjection.Tests.ps1 | 11 ++++++++--- Tests/Set-PfbContext.Tests.ps1 | 4 +++- 6 files changed, 39 insertions(+), 13 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index e2b104cb..a4494ab3 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -465,10 +465,19 @@ function Assert-PfbContextAdminLocality { .SYNOPSIS Throws when a LOCALLY authenticated admin sets any Fusion context. .DESCRIPTION - Diagnostic, never a security boundary. A local admin's cross-array call fails - loudly on the wire with 'Operation not permitted' (code 20), so this gate can never turn - a would-be wrong-target success into a failure -- it only replaces an opaque server error - with the actionable reason. + Diagnostic, never a security boundary. For a CROSS-ARRAY context a local admin's call + fails loudly on the wire with 'Operation not permitted' (code 20), so there this gate + only replaces an opaque server error with the actionable reason. + + It is NOT free of behavioural cost, and this docstring must not claim otherwise: a local + admin CAN successfully target its OWN array. Measured on FB-A 2026-08-06 -- pureuser with + context_names=FB-A returned data; only cross-array attracts code 20. So for a + self-targeting context this gate DOES convert a would-be success into a failure. That is + INTENDED -- maintainer ruling 2026-08-05, no local-array exemption; see the comment at the + throw below, which states the same thing from the other direction -- but it is a real + rejection of a call the server would have served, not merely a nicer error message. An + earlier revision of this paragraph asserted the opposite as a safety property; it was + falsified by live testing. Do not restore it. Fails OPEN on an indeterminate locality and CLOSED on a known-local one. Those are not in tension: $null means no evidence (an OAuth2 client with no username, or GET /admins 403 diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index e78e10a2..2ca0c9ac 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -96,7 +96,8 @@ function Invoke-PfbApiRequest { # established this. if ($hasContext) { # Fourth shape gate, and the only one placed here rather than above the injection: a - # LOCALLY authenticated admin cannot use a context across arrays at all, on any endpoint. Placed + # LOCALLY authenticated admin cannot use a context at all, on any endpoint -- there is no + # local-array exemption, so every context is rejected once the admin is local. Placed # above the injection it reintroduced exactly the failure Task 10 measured -- a local # admin on a REST 2.20 array calling a context-capable endpoint that needs 2.23 was told # to go obtain an LDAP admin, and only after doing so learned the real blocker was diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index 01d0fad7..b0d52ac8 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -474,7 +474,7 @@ function Connect-PfbArray { ContextOverride = $null # Reserved: populated in a later phase. Declared here so every connection object has a # uniform shape. - AdminLocality = $null + AdminLocality = $null } # Hide secrets from default display. Sensitive fields (ApiToken, AuthToken, diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index a8468910..17072e71 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -13,7 +13,7 @@ Describe 'connection context state' { ApiVersion = '2.26' DefaultContext = $null ContextOverride = $null - AdminLocality = $null + AdminLocality = $null } $copy = Copy-PfbConnection -Array $fake $copy.DefaultContext = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-B')) @@ -382,10 +382,19 @@ Describe 'Connect-PfbArray -Context behaviour' { Describe 'Resolve-PfbAdminLocality' { It 'leaves AdminLocality null when the admin lookup fails' { + # DefaultContext/ContextOverride must be DECLARED on the fixture. The resolver takes + # $Array.PSObject.Copy() and then assigns both to $null; assigning an absent property on a + # PSCustomObject is a hard error, so without them the probe strip crashes into the + # resolver's own catch and returns $null BEFORE the mocked 403 is ever reached -- the right + # value for entirely the wrong reason. The Should -Invoke below is what distinguishes the + # two, and it is the load-bearing line here: the two $null assertions hold either way. InModuleScope 'PureStorageFlashBladePowerShell' { + $fixture = { [PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'u'; DefaultContext = $null; ContextOverride = $null } } Mock -CommandName Invoke-PfbApiRequest -MockWith { throw 'HTTP 403' } - { Resolve-PfbAdminLocality -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'u' }) } | Should -Not -Throw - $null -eq (Resolve-PfbAdminLocality -Array ([PSCustomObject]@{ Endpoint = 'fb.example'; Username = 'u' })) | Should -BeTrue + { Resolve-PfbAdminLocality -Array (& $fixture) } | Should -Not -Throw + $null -eq (Resolve-PfbAdminLocality -Array (& $fixture)) | Should -BeTrue + Should -Invoke -CommandName Invoke-PfbApiRequest -Times 2 -Exactly ` + -Because 'the 403 catch is the path under test; a count below the number of resolver calls means the probe strip crashed first and the $null came from a different failure entirely' } } # Mocked at the Invoke-RestMethod boundary, NOT at Invoke-PfbApiRequest. A mock of the thing diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index de210201..afe66290 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -178,7 +178,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { # gate sits among them -- it cannot detect the admin-locality gate drifting back above # Assert-PfbApiCapability. This one adds the version gate to the recording and pins the whole # sequence. The ruling it encodes was measured in Task 10: a gate that injects nothing and - # consults no endpoint must run BELOW the version gate, or a static admin on a REST 2.20 array + # consults no endpoint must run BELOW the version gate, or a local admin on a REST 2.20 array # calling an endpoint that needs 2.23 is told to go obtain an LDAP admin and only afterwards # learns the real blocker was firmware. Assert-PfbContextCapability defers "recorded but array # too old" to Assert-PfbApiCapability by design, so gates 1-3 do not catch that case. @@ -224,7 +224,7 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { } # The admin-locality gate has a DIFFERENT signature from the other three: it # takes -Array (which only the capability gate also takes) and neither -Endpoint nor - # -CapabilityMap, because the admin's model is a property of the session, not of the + # -CapabilityMap, because the admin's locality is a property of the session, not of the # endpoint. Record what it actually receives rather than forcing it into their shape. $authSeen = [System.Collections.Generic.List[object]]::new() Mock -CommandName Assert-PfbContextAdminLocality -MockWith { @@ -518,7 +518,12 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { $msg | Should -BeLike '*Set-PfbContext*' $msg | Should -BeLike '*Clear-PfbContext*' $msg | Should -BeLike '*Invoke-PfbInContext*' - $msg | Should -Not -BeLike '*Reconnect as a dynamic-model*' + # Retargeted at the CURRENT production wording. The old pattern + # ('*Reconnect as a dynamic-model*') could no longer match anything after the + # rename, so this mirror guard for the "do NOT re-merge these two clauses" + # invariant was silently unfalsifiable. Verified armed by mutation: merging the + # two clauses in Add-PfbContextErrorAnnotation reds this line. + $msg | Should -Not -BeLike '*Reconnect as a remotely authenticated*' } } } diff --git a/Tests/Set-PfbContext.Tests.ps1 b/Tests/Set-PfbContext.Tests.ps1 index 40ad257b..5f92090e 100644 --- a/Tests/Set-PfbContext.Tests.ps1 +++ b/Tests/Set-PfbContext.Tests.ps1 @@ -19,7 +19,9 @@ Describe 'Set-PfbContext' { It 'returns a new connection and leaves the original untouched' { $new = Set-PfbContext -Array $script:fb -Context 'FB-B' $new.DefaultContext.Entries[0].Name | Should -Be 'FB-B' - $script:fb.DefaultContext | Should -BeNullOrEmpty + # Not -BeNullOrEmpty: it cannot tell $null (unset) from an entry list with zero entries + # (explicit no-context), and that distinction is the whole tri-state. Banned project-wide. + $null -eq $script:fb.DefaultContext | Should -BeTrue [object]::ReferenceEquals($new, $script:fb) | Should -BeFalse } It 'emits exactly ONE connection for N piped members, scoped to the union' { From 1f106daa280fce030eb2dc5bafc013f2f8b88b83 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 01:09:09 -0700 Subject: [PATCH 35/53] test(context): guard per-item context attribution through the response layer --- Private/Invoke-PfbApiRequest.ps1 | 9 +++++- ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index 2ca0c9ac..ed074fd1 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -312,7 +312,14 @@ function Invoke-PfbApiRequest { return $response } - # Collect items + # Collect items. Items are added AS RECEIVED -- never project or rebuild them into a + # new PSCustomObject. A fanned-out (multi-array context) response carries a per-item + # `context` field naming the source array, and that is the caller's only way to tell + # which array an item came from. A "tidy up the response shape" refactor that rebuilt + # each item would silently destroy that attribution; the response layer deliberately + # reads only items / total_item_count / continuation_token off the body and leaves the + # items themselves alone. Guarded by the per-item-context test in + # Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1. if ($null -ne $response.items) { foreach ($item in $response.items) { $allItems.Add($item) diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index afe66290..a5c524c7 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -140,6 +140,35 @@ Describe 'context injection in Invoke-PfbApiRequest' { $callerParams.ContainsKey($script:PfbContextParameterName) | Should -BeFalse } } + It 'preserves the per-item context field on a fanned-out response' { + InModuleScope 'PureStorageFlashBladePowerShell' { + # Fan-out attribution: each item carries a `context` object naming its source + # array (measured on the wire as `context`, not `_context`). The response layer + # must pass items through as received -- projecting or rebuilding them would + # silently destroy per-item attribution. + Mock -CommandName Assert-PfbApiCapability -MockWith {} + Mock -CommandName Invoke-RestMethod -MockWith { + [PSCustomObject]@{ + items = @( + [PSCustomObject]@{ name = 'fs1'; context = [PSCustomObject]@{ name = 'FB-B' } }, + [PSCustomObject]@{ name = 'fs2'; context = [PSCustomObject]@{ name = 'FB-C' } } + ) + total_item_count = 2 + } + } + $fb = [PSCustomObject]@{ + PSTypeName = 'PureStorage.FlashBlade.Connection' + Endpoint = 'fb.example'; ApiVersion = '2.26'; AuthToken = 't'; AuthMethod = 'ApiToken' + DefaultContext = (New-PfbContext -Entries @( + (New-PfbContextEntry -Name 'FB-B'), (New-PfbContextEntry -Name 'FB-C'))) + ContextOverride = $null; AdminLocality = $null + } + $result = Invoke-PfbApiRequest -Array $fb -Method 'GET' -Endpoint 'file-systems' + @($result).Count | Should -Be 2 + $result[0].context.name | Should -Be 'FB-B' + $result[1].context.name | Should -Be 'FB-C' + } + } } # Fix round 1, Important 2. Both context gates were entirely unpinned at the call site: deleting From c9dc30b0bc60847eb6623d86585d3ddd7c0d56e8 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 01:17:03 -0700 Subject: [PATCH 36/53] docs(context): narrow the items-pass-through comment to what the test pins Task 13 review Minor 1: the comment named the per-item-context test as the guard for "added AS RECEIVED -- never project or rebuild", but that test only pins that the per-item `context` field survives. A PSObject.Copy() or a rebuild forwarding `context` would both pass it, so a maintainer could read the comment as enforcing reference identity when nothing does. Reference identity is not a spec requirement, so narrow the comment rather than over-constrain the implementation with a new assertion. Comment-only; no executable change. Co-Authored-By: Claude Opus 5 --- Private/Invoke-PfbApiRequest.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index ed074fd1..b94f67ca 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -318,8 +318,11 @@ function Invoke-PfbApiRequest { # which array an item came from. A "tidy up the response shape" refactor that rebuilt # each item would silently destroy that attribution; the response layer deliberately # reads only items / total_item_count / continuation_token off the body and leaves the - # items themselves alone. Guarded by the per-item-context test in - # Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1. + # items themselves alone. The per-item-context test in + # Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 fails if the per-item `context` + # field is dropped. That guard is deliberately narrower than the rule above: a rebuild + # that happened to forward `context` would still pass it. Treat the no-rebuild rule as + # the standard and the test as the backstop, not the definition. if ($null -ne $response.items) { foreach ($item in $response.items) { $allItems.Add($item) From dc3fc29ca84e1cc644a3639b4b37510be47a3e99 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 01:37:44 -0700 Subject: [PATCH 37/53] feat(connection): populate Username from the login response on every auth path POST /api/login returns the authenticated admin's name in its 200 body, and has done in every REST version 2.0-2.28. Both login paths captured only the x-auth-token header and discarded the body, so Username was whatever the caller typed -- and on the default -ApiToken set, which has no -Username parameter, it was never populated at all. Resolve-PfbAdminLocality early-returns without a username, so the Fusion admin-locality gate was permanently inert for the most common way people connect. Invoke-PfbApiTokenLogin now returns @{ AuthToken; Username } instead of a bare token string, and the native username/password path parses the body too. Where both a response username and a caller-supplied one exist the response wins: the array's own spelling is what GET /admins?names= has to match. Certificate/OAuth2 keeps its parameter value -- a JWT exchange returns no username and there is no /user endpoint to look one up from. Username is also normalized to $null rather than the '' an unbound [string] parameter yields, so "no username known" is one value on every path. Comments in Resolve-PfbAdminLocality, Add-PfbContextErrorAnnotation and Connect-PfbArray that documented the gate as ApiToken-inert are corrected, since this change falsifies them. Co-Authored-By: Claude Opus 5 --- Private/Assert-PfbContextSupported.ps1 | 38 ++-- Private/Invoke-PfbApiTokenLogin.ps1 | 72 ++++++- Public/Connection/Connect-PfbArray.ps1 | 60 +++++- Tests/Connect-PfbArray.Context.Tests.ps1 | 198 ++++++++++++++++-- ...e-PfbApiRequest.ContextInjection.Tests.ps1 | 9 +- Tests/Invoke-PfbApiTokenLogin.Tests.ps1 | 90 +++++++- 6 files changed, 429 insertions(+), 38 deletions(-) diff --git a/Private/Assert-PfbContextSupported.ps1 b/Private/Assert-PfbContextSupported.ps1 index a4494ab3..762a3150 100644 --- a/Private/Assert-PfbContextSupported.ps1 +++ b/Private/Assert-PfbContextSupported.ps1 @@ -347,15 +347,22 @@ function Resolve-PfbAdminLocality { Returns $null rather than throwing on any failure. An indeterminate locality must never fail a Connect-PfbArray, because this data drives a diagnostic and not a correctness gate. - THREE distinct routes reach indeterminate, and the third is the common one: + Two routes reach indeterminate, both narrow: 1. GET /admins 403s under a restrictive management-access policy. - 2. An OAuth2 client has no username to match. - 3. -ApiToken -- the DEFAULT parameter set -- never populates Username at all, so the - early return below fires and the gate is permanently inert for it. Only the - Credential, PSCredential and Certificate sets normalize Username - (Connect-PfbArray.ps1:206-212). This is a correct application of the fail-open - ruling (no username, no evidence), not a bug: do NOT "fix" it by inferring a - locality from the token or by defaulting to 'local'. + 2. There is no Username to match, so the early return below fires. Since Task 12b that + means only a Certificate/OAuth2 session whose -Username somehow never bound, or a + login whose 200 body carried no `username` at all -- malformed, not any supported + version. Every parameter set now populates Username: ApiToken, Credential and + PSCredential take it from the /api/login response body (which returns it in every + REST version 2.0-2.28), and Certificate has -Username as Mandatory. + This is a correct application of the fail-open ruling (no username, no evidence), not + a bug: do NOT "fix" it by inferring a locality from the token or by defaulting to + 'local'. + + The DEFAULT -ApiToken set used to be a third, and by far the commonest, route here: Username + was only the caller's typed value and that set has no -Username parameter, so this function + early-returned and the gate was permanently inert for it. That is fixed (Task 12b). Do not + reintroduce a note claiming ApiToken cannot reach the lookup. NO .items UNWRAP. Invoke-PfbApiRequest already unwraps the envelope itself -- it collects $response.items into $allItems and returns $allItems.ToArray(), an object[] of admin @@ -524,11 +531,16 @@ function Add-PfbContextErrorAnnotation { THE code 20 CASE IS THE REACTIVE HALF OF Assert-PfbContextAdminLocality, not a duplicate of it. That gate can only throw proactively when the admin locality is - known, and it is NOT known for an -ApiToken session (no Username to look up, so - Resolve-PfbAdminLocality returns $null and the gate fails open) nor for a session - that only ever supplies a context through Invoke-PfbInContext. In both cases the wire's - bare code 20 "Operation not permitted" is the only signal the user ever gets. Do not - remove this branch on the grounds that Task 11's gate "already covers it". + known, and it is NOT known for a session that only ever supplies a context through + Invoke-PfbInContext (no resolution site is ever reached), nor when GET /admins 403s under + a restrictive management-access policy. In those cases the wire's bare code 20 "Operation + not permitted" is the only signal the user ever gets. Do not remove this branch on the + grounds that Task 11's gate "already covers it". + + It is NO LONGER also needed for the -ApiToken set specifically: Task 12b populates Username + from the /api/login response body there, so that session now resolves its locality and the + proactive gate does fire. The Invoke-PfbInContext hole is untouched by that and is why this + branch stays. Keying on a bare 'Operation not permitted' is safe HERE specifically because the function has already returned unless a context is active: a permission failure with a context set diff --git a/Private/Invoke-PfbApiTokenLogin.ps1 b/Private/Invoke-PfbApiTokenLogin.ps1 index 9ea8a8dc..248a329b 100644 --- a/Private/Invoke-PfbApiTokenLogin.ps1 +++ b/Private/Invoke-PfbApiTokenLogin.ps1 @@ -1,3 +1,62 @@ +function Get-PfbLoginResponseUsername { + <# + .SYNOPSIS + Reads the authenticated admin's name out of a POST /api/login 200 response body. + .DESCRIPTION + `POST /api/login` returns the authenticated admin's name in its 200 body, and has done in + EVERY REST version 2.0 through 2.28 -- charted across all 29 cached specs: the endpoint is + present in every version, its 200 response has a body in every version, and `username` is a + property of that body in every version. Only the schema ARRANGEMENT changed (inline, then a + named `Login` ref at 2.17, then `allOf: [Username]` at 2.26). What 2.26 added is acceptance + of a username/password REQUEST body -- neither the endpoint nor the response field. Do not + confuse the two, and do not add a version gate here: there is no version at which this + needs one. + + The $null return is therefore MALFORMED-BODY TOLERANCE and nothing else. Do not re-justify + it on version grounds. Nothing in here is allowed to throw: a login that already + authenticated must never fail because a proxy rewrote the body or a test double omitted it. + + Reads through PSObject.Properties rather than touching .Content / .username directly -- + under StrictMode a direct read of an absent property is a terminating PropertyNotFound + error, which is exactly the throw this function must not produce. + .PARAMETER Response + The full response object from Invoke-WebRequest. $null and a Content-less object are both + acceptable inputs and both yield $null. + .OUTPUTS + [string] -- the array's own spelling of the admin name, or $null if the body did not carry + one. Never an empty string: unset and explicit-empty must not collapse. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [AllowNull()] + $Response + ) + + if ($null -eq $Response) { return $null } + + $contentProperty = $Response.PSObject.Properties['Content'] + if ($null -eq $contentProperty) { return $null } + + # Invoke-WebRequest -UseBasicParsing gives Content as [string] on both editions; a byte[] + # body would simply fail the parse below and land on the $null return. + $content = [string]$contentProperty.Value + if ([string]::IsNullOrWhiteSpace($content)) { return $null } + + try { $parsed = $content | ConvertFrom-Json -ErrorAction Stop } + catch { return $null } + if ($null -eq $parsed) { return $null } + + # A JSON array (or a bare scalar) has no such property and correctly yields $null. + $nameProperty = $parsed.PSObject.Properties['username'] + if ($null -eq $nameProperty) { return $null } + + $name = [string]$nameProperty.Value + if ([string]::IsNullOrEmpty($name)) { return $null } + return $name +} + function Invoke-PfbApiTokenLogin { <# .SYNOPSIS @@ -12,9 +71,15 @@ function Invoke-PfbApiTokenLogin { The API token to exchange for a session token. .PARAMETER SkipCertificateCheck Bypass SSL certificate validation. + .OUTPUTS + [PSCustomObject] with AuthToken and Username. NOT a bare token string -- the 200 body + carries the array's own spelling of the admin name, which is what GET /admins?names= has + to match, and discarding it left the admin-locality gate inert for the default -ApiToken + parameter set. Username is $null when the body did not supply one; see + Get-PfbLoginResponseUsername. #> [CmdletBinding()] - [OutputType([string])] + [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [string]$Endpoint, [Parameter(Mandatory)] [string]$ApiToken, @@ -41,5 +106,8 @@ function Invoke-PfbApiTokenLogin { $authToken = $loginResponse.Headers['x-auth-token'] if ($authToken -is [array]) { $authToken = $authToken[0] } - return $authToken + return [PSCustomObject]@{ + AuthToken = $authToken + Username = Get-PfbLoginResponseUsername -Response $loginResponse + } } diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index b0d52ac8..d1ffc7ef 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -251,6 +251,20 @@ function Connect-PfbArray { $negotiatedVersion = $v2Versions[0].Version } + # The username that will land on the connection object. A SEPARATE LOCAL, deliberately not + # $Username itself: that parameter carries [ValidateNotNullOrEmpty()] on its PSVariable, which + # re-validates on ANY assignment regardless of which parameter set was bound -- so writing a + # $null back into it would throw on the ApiToken set, exactly the already-shipped crash + # documented at the $ApiToken note in the Certificate branch below. Seeded with the caller's + # value (which is $null on the ApiToken set, and Mandatory on Certificate) and then OVERWRITTEN + # by the login response wherever one supplies a name -- see the precedence note at each site. + # + # NORMALIZED TO $null, not left as ''. Measured, not assumed: an UNBOUND [string] parameter is + # the empty string rather than $null, so before this the ApiToken set shipped Username = '' on + # every connection. One value means "no username known" on every path, which is what makes the + # $null -ne guards below the whole story rather than half of it. + $resolvedUsername = if ([string]::IsNullOrEmpty($Username)) { $null } else { $Username } + # Authenticate based on method $authToken = $null $bearerToken = $null @@ -259,7 +273,12 @@ function Connect-PfbArray { if ($PSCmdlet.ParameterSetName -eq 'ApiToken') { # Direct API token login - $authToken = Invoke-PfbApiTokenLogin -Endpoint $Endpoint -ApiToken $ApiToken -SkipCertificateCheck:$IgnoreCertificateError -TimeoutSec $timeoutSec + $tokenLogin = Invoke-PfbApiTokenLogin -Endpoint $Endpoint -ApiToken $ApiToken -SkipCertificateCheck:$IgnoreCertificateError -TimeoutSec $timeoutSec + $authToken = $tokenLogin.AuthToken + # THE point of this set: it has no -Username parameter at all, so the login body is the + # only possible source. $null -ne, never truthiness -- and guarded rather than assigned + # unconditionally so a malformed body cannot erase a name from another source. + if ($null -ne $tokenLogin.Username) { $resolvedUsername = $tokenLogin.Username } } elseif ($PSCmdlet.ParameterSetName -eq 'Certificate') { # OAuth2 JWT certificate-based authentication @@ -338,10 +357,25 @@ function Connect-PfbArray { $authToken = $loginResponse.Headers['x-auth-token'] if ($authToken -is [array]) { $authToken = $authToken[0] } + # The RESPONSE WINS over the value the caller typed. This is the point, not a side + # effect: GET /admins?names= is matched by the array's own spelling, and case + # sensitivity has already bitten this project once (.arrays). A caller who typed + # PUREUSER against an array that calls the account pureuser must end up with + # pureuser on the connection. Guarded on $null so a malformed body falls back to the + # caller's value rather than destroying it. + $responseUsername = Get-PfbLoginResponseUsername -Response $loginResponse + if ($null -ne $responseUsername) { $resolvedUsername = $responseUsername } + # Try to retrieve (or mint) a long-lived API token for auto-reconnect. # Best-effort: succeeds for users with admin privileges; falls through silently otherwise. # Use a local variable since the $ApiToken parameter retains its [ValidateNotNullOrEmpty] # constraint and would reject a $null reassignment. + # + # This block deliberately still keys on $Username, NOT $resolvedUsername: it is + # pre-existing best-effort behaviour with its own tests, and switching it is a + # separate change with its own risk. Noted rather than done -- if the caller's + # spelling differs from the array's, the read/mint below can miss, which is exactly + # the behaviour it had before Task 12b. $cachedApiToken = $null $tokenHeaders = @{ 'x-auth-token' = $authToken } $encodedName = [System.Uri]::EscapeDataString($Username) @@ -402,7 +436,10 @@ function Connect-PfbArray { } $ApiToken = $mintedToken - $authToken = Invoke-PfbApiTokenLogin -Endpoint $Endpoint -ApiToken $ApiToken -SkipCertificateCheck:$IgnoreCertificateError -TimeoutSec $timeoutSec + $sshTokenLogin = Invoke-PfbApiTokenLogin -Endpoint $Endpoint -ApiToken $ApiToken -SkipCertificateCheck:$IgnoreCertificateError -TimeoutSec $timeoutSec + $authToken = $sshTokenLogin.AuthToken + # Same precedence as the native path above: the array's spelling wins. + if ($null -ne $sshTokenLogin.Username) { $resolvedUsername = $sshTokenLogin.Username } } } @@ -444,7 +481,10 @@ function Connect-PfbArray { PSTypeName = 'PureStorage.FlashBlade.Connection' # Pfa2-aligned properties HttpEndpoint = "https://${Endpoint}" - Username = $Username + # ARRAY-AUTHORITATIVE where a login response supplied a name (ApiToken, Credential, + # PSCredential), the parameter value on Certificate, which has no /api/login response to + # read. Never $Username directly -- see the $resolvedUsername note above. + Username = $resolvedUsername ApiToken = $ApiToken RestApiVersion = $negotiatedVersion # Internal properties used by Invoke-PfbApiRequest / Disconnect-PfbArray @@ -537,10 +577,16 @@ function Connect-PfbArray { # connection and a thousand-iteration loop would mean a thousand probes. Do NOT # memoize, and do NOT introduce a third "not yet asked" state to make memoizing safe. # - # Best-effort and non-fatal: see Resolve-PfbAdminLocality, which also documents - # that this is inert for the DEFAULT -ApiToken set (no Username to look up), costs ~3 - # round trips rather than 1 on a management-access-policy 403, and strips the context - # from its own probe so the identity question is never routed to another array. + # Best-effort and non-fatal: see Resolve-PfbAdminLocality, which also documents that it + # costs ~3 round trips rather than 1 on a management-access-policy 403, and strips the + # context from its own probe so the identity question is never routed to another array. + # + # This is LIVE FOR ALL FOUR PARAMETER SETS, including the default -ApiToken one. It used + # to be inert there -- Username was only ever the caller's typed value, and ApiToken has + # no -Username parameter, so the resolver early-returned and the gate below could never + # fire on the most common way people connect. Task 12b closed that by taking Username + # from the /api/login response body on every path that has one. Do not re-add a claim + # that this is ApiToken-inert. $connection.AdminLocality = Resolve-PfbAdminLocality -Array $connection # Closes what the Task 11 review parked as a gap: the connect-time context path is now diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index 17072e71..4b3f5ee1 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -159,13 +159,15 @@ Describe 'Connect-PfbArray -Context behaviour' { # Deleting that one line disables the whole admin-locality feature without breaking any # gate call site, and nothing pinned it: the two pre-existing AdminLocality assertions in # this file check that the PROPERTY EXISTS (declared in the object literal, so it passes - # either way) and that it is $null (which passes either way too, because those harnesses - # connect with -ApiToken and so Username is never populated). That gap is why the inert-gate - # defect shipped. + # either way) and that it is $null (which passes either way too, because this block's + # Invoke-WebRequest mock returns no login BODY, so no username is resolved from it). That gap + # is why the inert-gate defect shipped. # - # Must use a username-bearing parameter set: -ApiToken never populates Username, so the - # resolver early-returns and this test would pass vacuously against a deleted line. And must - # supply -Context: since the 2026-08-05 ruling the resolution happens ONLY on that path. + # Uses a credential set so the username is unambiguous. Since Task 12b -ApiToken populates + # Username too -- from the /api/login response body -- but only when the mocked response + # actually carries one, which this block's default mock does not; the Task 12b Describe below + # is where the ApiToken path is pinned end to end. Must supply -Context: since the 2026-08-05 + # ruling the resolution happens ONLY on that path. It 'populates AdminLocality from the connected admin when -Context is supplied' { $cred = [System.Management.Automation.PSCredential]::new( 'jdoe', (ConvertTo-SecureString 'pw' -AsPlainText -Force)) @@ -192,9 +194,10 @@ Describe 'Connect-PfbArray -Context behaviour' { } # THE detector for the whole point of the 2026-08-05 ruling: a session that never touches - # Fusion must not pay for a GET /admins round trip. Nothing else in the suite can see this -- - # every other test either supplies a context or connects with -ApiToken (no Username), so the - # resolution would be invisible to them whether it is conditional or unconditional. + # Fusion must not pay for a GET /admins round trip. Nothing else in this block can see it -- + # every other test here either supplies a context or connects against a login mock with no + # response body, so the resolution would be invisible to them whether it is conditional or + # unconditional. It 'makes NO admin call on a bare connect, even with a username-bearing credential' { $cred = [System.Management.Automation.PSCredential]::new( 'jdoe', (ConvertTo-SecureString 'pw' -AsPlainText -Force)) @@ -217,9 +220,11 @@ Describe 'Connect-PfbArray -Context behaviour' { } # The tri-state case and the model-resolving case were covered by DISJOINT sets of tests, and - # the defect lived in their intersection: the only -Context @() test connects with -ApiToken, - # so there was no Username, the resolver early-returned, and the gate failed open. A - # username-bearing @() connect is that missing intersection. + # the defect lived in their intersection: the only -Context @() test connected with -ApiToken, + # which at the time never populated Username, so the resolver early-returned and the gate + # failed open. A username-bearing @() connect is that missing intersection. (Task 12b has since + # given the ApiToken set a Username as well, which makes this coverage matter MORE, not less -- + # the @() early-out is now the only thing keeping the probe off that path.) It 'treats -Context @() as no context: no admin probe, no gate, no throw' { $cred = [System.Management.Automation.PSCredential]::new( 'pureuser', (ConvertTo-SecureString 'pw' -AsPlainText -Force)) @@ -380,6 +385,175 @@ Describe 'Connect-PfbArray -Context behaviour' { } } +Describe 'Connect-PfbArray Username is array-authoritative' { + # Task 12b. `Username` used to be whatever the CALLER typed, and on the default -ApiToken set + # it was never populated at all -- which is why Resolve-PfbAdminLocality early-returned and + # the whole admin-locality gate was inert for the most common way people connect. + # + # Every mock here is at the Invoke-WebRequest / Invoke-RestMethod boundary. Mocking + # Invoke-PfbApiTokenLogin instead would assert nothing about where Username comes from. + # + # PINNED PER PARAMETER SET, not once: three of the four sets take the value from the login + # response and the fourth (Certificate) structurally cannot, so a single test could not + # distinguish them. + BeforeEach { + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + [PSCustomObject]@{ versions = @('2.26') } + } -ParameterFilter { $Uri -like '*api_version*' } + Mock -ModuleName PureStorageFlashBladePowerShell Get-PfbCapabilityMap { + [PSCustomObject]@{ schemaVersion = 2; generatedFrom = @('2.0', '2.26') } + } + # The best-effort API-token read/mint on the native credential paths. Answered with an + # empty list so it neither reaches the network nor supplies a token. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + [PSCustomObject]@{ items = @() } + } -ParameterFilter { $Uri -match '/admins/api-tokens' } + + $script:originalState = InModuleScope PureStorageFlashBladePowerShell { + @{ Arrays = @{} + $script:PfbArrays; Default = $script:PfbDefaultArray } + } + } + + AfterEach { + InModuleScope PureStorageFlashBladePowerShell -Parameters @{ state = $script:originalState } { + & { param($a, $d) $script:PfbArrays = $a; $script:PfbDefaultArray = $d } $state.Arrays $state.Default + } + } + + It 'populates Username from the login response on the ApiToken set' { + # THE headline case: the default parameter set, which has no -Username parameter at all. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'tok' }; Content = '{"username":"pureuser"}' } + } + + $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' + + $conn.Username | Should -Be 'pureuser' -Because 'the ApiToken set has no -Username parameter, so the login response is the only possible source' + } + + It 'prefers the response username over the one the caller supplied on the Credential set' { + # THE discriminating test. Without it nothing separates "populated" from "populated + # correctly": the caller typed PUREUSER, the array answers pureuser, and it is the array's + # spelling that GET /admins?names= has to match. Case sensitivity has already bitten this + # project once (.arrays), so the direction of this precedence is deliberate. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'tok' }; Content = '{"username":"pureuser"}' } + } + + $conn = Connect-PfbArray -Endpoint 'fb.test' -Username 'PUREUSER' ` + -Password (ConvertTo-SecureString 'pw' -AsPlainText -Force) + + $conn.Username | Should -BeExactly 'pureuser' -Because "the array's own spelling wins; -BeExactly is the assertion, since -Be is case-insensitive and would pass either way" + } + + It 'prefers the response username over the credential on the PSCredential set' { + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'tok' }; Content = '{"username":"jdoe"}' } + } + $cred = [System.Management.Automation.PSCredential]::new( + 'JDOE', (ConvertTo-SecureString 'pw' -AsPlainText -Force)) + + $conn = Connect-PfbArray -Endpoint 'fb.test' -Credential $cred + + $conn.Username | Should -BeExactly 'jdoe' + } + + It 'keeps the parameter-supplied Username on the Certificate set' { + # No /api/login response exists on this path -- OAuth2 is a JWT exchange and returns only + # AccessToken/ExpiresAt/TtlSeconds. -Username is Mandatory here, so it can never be empty, + # and there is no /user endpoint to look one up from (probed: absent at every version). + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-PfbOAuth2Login { + [PSCustomObject]@{ + AccessToken = 'oauth-token' + ExpiresAt = (Get-Date).ToUniversalTime().AddHours(1) + TtlSeconds = 3600 + } + } + + $conn = Connect-PfbArray -Endpoint 'fb.test' -Username 'svc-jdoe' -ClientId 'client-1' ` + -Issuer 'myapp' -KeyId 'key-1' -PrivateKeyFile 'C:\keys\fake.pem' + + $conn.Username | Should -Be 'svc-jdoe' + } + + It 'populates Username from the post-SSH token login on the pre-2.26 fallback path' { + # The SSH fallback ends in the SAME Invoke-PfbApiTokenLogin call, so it gets the response + # username too -- the second of that function's exactly two call sites. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + [PSCustomObject]@{ versions = @('2.25') } + } -ParameterFilter { $Uri -like '*api_version*' } + Mock -ModuleName PureStorageFlashBladePowerShell Get-PfbApiTokenViaSsh { 'T-minted' } + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'tok' }; Content = '{"username":"pureuser"}' } + } + + $conn = Connect-PfbArray -Endpoint 'fb.test' -Username 'PUREUSER' ` + -Password (ConvertTo-SecureString 'pw' -AsPlainText -Force) + + $conn.Username | Should -BeExactly 'pureuser' + } + + It 'falls back to the caller value, not to $null, when the login body carries no username' { + # Defensive: a malformed body must not DESTROY a username the caller did supply. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'tok' }; Content = '{}' } + } + + $conn = Connect-PfbArray -Endpoint 'fb.test' -Username 'PUREUSER' ` + -Password (ConvertTo-SecureString 'pw' -AsPlainText -Force) + + $conn.Username | Should -BeExactly 'PUREUSER' + } + + It 'leaves Username $null on the ApiToken set when the login body carries no username' { + # The one remaining route to an indeterminate locality on this set. $null, never '' -- + # unset and explicit-empty must not collapse. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'tok' }; Content = '{}' } + } + + $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' + + $null -eq $conn.Username | Should -BeTrue + } + + It 'now makes the admin-locality gate fire for the DEFAULT -ApiToken set' { + # THE BEHAVIOURAL PAYOFF of Task 12b. Before it, this connect could not throw: there was + # no Username, Resolve-PfbAdminLocality early-returned $null, and the gate failed open. + # + # Deliberately NOT mocking Resolve-PfbAdminLocality (the sibling test above does that to + # pin the call count). Here the whole chain runs for real off the wire boundary -- + # login body -> Username -> GET /admins?names= -> is_local -> gate -- which is the only + # way to show the hole is actually closed. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'tok' }; Content = '{"username":"pureuser"}' } + } + # Matched with -match so '?' is literal: '*/admins?*' would also match '/admins/api-tokens'. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + [PSCustomObject]@{ + items = @([PSCustomObject]@{ name = 'pureuser'; is_local = $true }) + total_item_count = 1 + } + } -ParameterFilter { $Uri -match '/admins\?' } + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod {} -ParameterFilter { $Uri -match '/api/logout' } + + { Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' -Context 'FB-B' } | + Should -Throw -ExpectedMessage "*The connected admin 'pureuser' is a local account*" + + # The load-bearing half: the probe must actually have gone out. Without it this test would + # also pass if the gate threw for some unrelated reason. + Should -Invoke -ModuleName PureStorageFlashBladePowerShell -CommandName Invoke-RestMethod ` + -ParameterFilter { $Uri -match '/admins\?' } -Times 1 -Exactly ` + -Because 'the ApiToken set must now have a Username to look up, which is the whole point of Task 12b' + } + + It 'has left no fb.test entry in the module connection cache' { + InModuleScope PureStorageFlashBladePowerShell { + $script:PfbArrays.ContainsKey('fb.test') | Should -BeFalse + } + } +} + Describe 'Resolve-PfbAdminLocality' { It 'leaves AdminLocality null when the admin lookup fails' { # DefaultContext/ContextOverride must be DECLARED on the fixture. The resolver takes diff --git a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 index a5c524c7..7f351858 100644 --- a/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 +++ b/Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 @@ -462,9 +462,12 @@ Describe 'context gate wiring in Invoke-PfbApiRequest' { Should -Be 'FlashBlade API error: Cannot find array in fleet' } } - # Step 3a. Task 11's proactive admin-locality gate cannot fire for an -ApiToken session - # (no Username to look up) or for a session that only ever uses Invoke-PfbInContext, so for - # those the wire's bare code 20 "Operation not permitted" is the ONLY signal the user gets. + # Step 3a. Task 11's proactive admin-locality gate cannot fire for a session that only ever + # supplies its context through Invoke-PfbInContext (no resolution site is reached), nor when + # GET /admins 403s under a restrictive management-access policy, so for those the wire's bare + # code 20 "Operation not permitted" is the ONLY signal the user gets. It DOES now fire for an + # -ApiToken session -- Task 12b populates Username from the /api/login body -- so that is no + # longer one of the cases this annotation covers. It 'explains a code 20 permission failure as a likely LOCAL admin' { InModuleScope 'PureStorageFlashBladePowerShell' { $ctx = New-PfbContext -Entries @((New-PfbContextEntry -Name 'FB-Q')) diff --git a/Tests/Invoke-PfbApiTokenLogin.Tests.ps1 b/Tests/Invoke-PfbApiTokenLogin.Tests.ps1 index 52eff143..76a6dee8 100644 --- a/Tests/Invoke-PfbApiTokenLogin.Tests.ps1 +++ b/Tests/Invoke-PfbApiTokenLogin.Tests.ps1 @@ -16,7 +16,9 @@ Describe 'Invoke-PfbApiTokenLogin' { Invoke-PfbApiTokenLogin -Endpoint 'fb.test' -ApiToken 'T-fake' } - $result | Should -Be 'session-token-123' + # Returns an OBJECT, not a bare string: the login body carries the array's own spelling of + # the admin name and the whole point of Task 12b is not to discard it. + $result.AuthToken | Should -Be 'session-token-123' } It 'throws a clear error when the login call fails' { @@ -31,6 +33,92 @@ Describe 'Invoke-PfbApiTokenLogin' { } } +Describe 'Invoke-PfbApiTokenLogin - login response username' { + # Mocked at the Invoke-WebRequest boundary. The plan's global constraint names + # Invoke-RestMethod; the equivalent boundary HERE is Invoke-WebRequest, because that is what + # the login functions call -- the header is only reachable through a full response object. A + # mock of Invoke-PfbApiTokenLogin ITSELF cannot prove this contract: that exact mistake left + # the admin-locality feature inert in production behind a fully green suite. + It 'returns the username the array reported in the /api/login body' { + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ + StatusCode = 200 + Headers = @{ 'x-auth-token' = 'sess-tok' } + Content = '{"username":"pureuser"}' + } + } -ParameterFilter { $Uri -eq 'https://fb.test/api/login' } + + $result = InModuleScope PureStorageFlashBladePowerShell { + Invoke-PfbApiTokenLogin -Endpoint 'fb.test' -ApiToken 'T-1234' + } + + $result.AuthToken | Should -Be 'sess-tok' + $result.Username | Should -Be 'pureuser' + } + + It 'still returns the token, with a $null Username, when the body carries no username' { + # DEFENSIVE ONLY -- malformed-body tolerance. NOT a version concern: /api/login returns + # username in every REST version 2.0 through 2.28, so there is no version in which this + # branch is the expected path. A login that authenticated must never fail because a proxy + # rewrote the body. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'sess-tok' }; Content = '{}' } + } -ParameterFilter { $Uri -eq 'https://fb.test/api/login' } + + $result = InModuleScope PureStorageFlashBladePowerShell { + Invoke-PfbApiTokenLogin -Endpoint 'fb.test' -ApiToken 'T-1234' + } + + $result.AuthToken | Should -Be 'sess-tok' + $null -eq $result.Username | Should -BeTrue + } + + It 'still returns the token, with a $null Username, when the body is not JSON at all' { + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'sess-tok' }; Content = 'nope' } + } -ParameterFilter { $Uri -eq 'https://fb.test/api/login' } + + $result = InModuleScope PureStorageFlashBladePowerShell { + Invoke-PfbApiTokenLogin -Endpoint 'fb.test' -ApiToken 'T-1234' + } + + $result.AuthToken | Should -Be 'sess-tok' + $null -eq $result.Username | Should -BeTrue + } + + It 'still returns the token, with a $null Username, when the response has no Content member' { + # A response object with no Content property at all: a direct .Content read would be a + # PropertyNotFound error under StrictMode, so the parse must go through PSObject.Properties. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'sess-tok' } } + } -ParameterFilter { $Uri -eq 'https://fb.test/api/login' } + + $result = InModuleScope PureStorageFlashBladePowerShell { + Invoke-PfbApiTokenLogin -Endpoint 'fb.test' -ApiToken 'T-1234' + } + + $result.AuthToken | Should -Be 'sess-tok' + $null -eq $result.Username | Should -BeTrue + } + + It 'still unwraps an x-auth-token returned as a header array' { + # WinPS 5.1 hands back header values as string[]. Pre-existing behaviour, pinned here + # because the return shape changed around it. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ + Headers = @{ 'x-auth-token' = @('sess-tok', 'ignored') } + Content = '{"username":"pureuser"}' + } + } -ParameterFilter { $Uri -eq 'https://fb.test/api/login' } + + $result = InModuleScope PureStorageFlashBladePowerShell { + Invoke-PfbApiTokenLogin -Endpoint 'fb.test' -ApiToken 'T-1234' + } + + $result.AuthToken | Should -Be 'sess-tok' + } +} + Describe 'Invoke-PfbApiTokenLogin - TimeoutSec' { It 'defaults to 30 seconds when -TimeoutSec is not specified' { Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { From 533fdd4437a146d4020694f2676f9c492abdeb37 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 02:06:48 -0700 Subject: [PATCH 38/53] docs(connection): correct the StrictMode claim and strengthen two login assertions Task 12b review minors, all comment- or assertion-level: - The docstring on Get-PfbLoginResponseUsername justified its PSObject.Properties read as required under StrictMode. That was false: Set-StrictMode appears nowhere in the module or the Pester harness, so a direct .Content read would return $null rather than throwing. The read is worth keeping, but as a deliberate defence against test doubles and rewritten responses -- not as a forced workaround. The test comment that repeated the claim is corrected too, and now says what the test actually pins: the observable contract, not the implementation choice. - 'leaves Username $null on the ApiToken set' asserted the exact value any swallowed failure would also produce. Added an AuthToken co-assertion so a green proves the login succeeded and the $null is the parse's answer rather than wreckage. - Two Username assertions used -Be where their four siblings use -BeExactly. Case is the whole point of this property; the file now teaches one idiom. - Connect-PfbArrayInternal is a third /api/login site and is now the only one that does not read the username from the body. Documented why that is correct (reconnect mutates the connection in place, so there is nothing to refresh) plus the one marginal gap it leaves, so the next reader does not take it for an oversight. --- Private/Invoke-PfbApiRequest.ps1 | 8 ++++++++ Private/Invoke-PfbApiTokenLogin.ps1 | 13 ++++++++++--- Tests/Connect-PfbArray.Context.Tests.ps1 | 9 +++++++-- Tests/Invoke-PfbApiTokenLogin.Tests.ps1 | 7 +++++-- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/Private/Invoke-PfbApiRequest.ps1 b/Private/Invoke-PfbApiRequest.ps1 index b94f67ca..50310530 100644 --- a/Private/Invoke-PfbApiRequest.ps1 +++ b/Private/Invoke-PfbApiRequest.ps1 @@ -410,6 +410,14 @@ function Connect-PfbArrayInternal { $authToken = $loginResponse.Headers['x-auth-token'] if ($authToken -is [array]) { $authToken = $authToken[0] } + # The response BODY is deliberately not parsed here, unlike the two /api/login sites in + # Connect-PfbArray, which take the admin's name from it (see Get-PfbLoginResponseUsername). + # This is a RECONNECT: the caller mutates the existing connection object in place, assigning + # only AuthToken and ConnectedAt, so Username and AdminLocality survive untouched and there is + # nothing here to refresh. The divergence is intentional -- do not read it as an oversight. + # Known marginal gap: an ApiToken session whose FIRST login body was malformed carries + # Username = $null forever, because a later well-formed reconnect body is never read. Fixing + # that means refreshing Username on the reconnect path, which is its own decision. return [PSCustomObject]@{ AuthToken = $authToken ConnectedAt = [datetime]::UtcNow diff --git a/Private/Invoke-PfbApiTokenLogin.ps1 b/Private/Invoke-PfbApiTokenLogin.ps1 index 248a329b..985b4514 100644 --- a/Private/Invoke-PfbApiTokenLogin.ps1 +++ b/Private/Invoke-PfbApiTokenLogin.ps1 @@ -16,9 +16,16 @@ function Get-PfbLoginResponseUsername { it on version grounds. Nothing in here is allowed to throw: a login that already authenticated must never fail because a proxy rewrote the body or a test double omitted it. - Reads through PSObject.Properties rather than touching .Content / .username directly -- - under StrictMode a direct read of an absent property is a terminating PropertyNotFound - error, which is exactly the throw this function must not produce. + Reads through PSObject.Properties rather than touching .Content / .username directly. + This is defensive BY CHOICE, not forced: this module does not set StrictMode anywhere, so + a direct read of an absent property would return $null rather than throwing. The reason to + keep it is that a real Invoke-WebRequest response always carries .Content while test + doubles and proxied/rewritten responses may not, and the property-bag read states that + expectation instead of relying on the absence of StrictMode to stay true. + (An earlier version of this comment claimed the module runs under StrictMode and that the + direct read would therefore be a terminating PropertyNotFound error. That was false -- + `Set-StrictMode` appears nowhere in the module or the Pester harness. Do not reintroduce + the claim; if StrictMode is ever adopted, this read is already correct for it.) .PARAMETER Response The full response object from Invoke-WebRequest. $null and a Content-less object are both acceptable inputs and both yield $null. diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index 4b3f5ee1..5c3494ab 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -428,7 +428,7 @@ Describe 'Connect-PfbArray Username is array-authoritative' { $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' - $conn.Username | Should -Be 'pureuser' -Because 'the ApiToken set has no -Username parameter, so the login response is the only possible source' + $conn.Username | Should -BeExactly 'pureuser' -Because 'the ApiToken set has no -Username parameter, so the login response is the only possible source' } It 'prefers the response username over the one the caller supplied on the Credential set' { @@ -473,7 +473,7 @@ Describe 'Connect-PfbArray Username is array-authoritative' { $conn = Connect-PfbArray -Endpoint 'fb.test' -Username 'svc-jdoe' -ClientId 'client-1' ` -Issuer 'myapp' -KeyId 'key-1' -PrivateKeyFile 'C:\keys\fake.pem' - $conn.Username | Should -Be 'svc-jdoe' + $conn.Username | Should -BeExactly 'svc-jdoe' } It 'populates Username from the post-SSH token login on the pre-2.26 fallback path' { @@ -514,6 +514,11 @@ Describe 'Connect-PfbArray Username is array-authoritative' { $conn = Connect-PfbArray -Endpoint 'fb.test' -ApiToken 'T-fake' + # The AuthToken co-assertion is what makes the $null above mean something. On its own, + # `$null -eq Username` is the value ANY swallowed failure in the username chain would also + # produce, so it would pass for the wrong reason; pinning the token proves the login + # actually succeeded and the $null is the parse's considered answer, not wreckage. + $conn.AuthToken | Should -BeExactly 'tok' $null -eq $conn.Username | Should -BeTrue } diff --git a/Tests/Invoke-PfbApiTokenLogin.Tests.ps1 b/Tests/Invoke-PfbApiTokenLogin.Tests.ps1 index 76a6dee8..e97d626c 100644 --- a/Tests/Invoke-PfbApiTokenLogin.Tests.ps1 +++ b/Tests/Invoke-PfbApiTokenLogin.Tests.ps1 @@ -87,8 +87,11 @@ Describe 'Invoke-PfbApiTokenLogin - login response username' { } It 'still returns the token, with a $null Username, when the response has no Content member' { - # A response object with no Content property at all: a direct .Content read would be a - # PropertyNotFound error under StrictMode, so the parse must go through PSObject.Properties. + # A response object with no Content property at all must yield $null rather than throwing. + # Note what this does and does not pin: the module sets no StrictMode, so a direct .Content + # read would also return $null and this test would pass against it too. It pins the + # OBSERVABLE contract (no throw, token still returned), not the PSObject.Properties + # implementation choice. Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { [PSCustomObject]@{ Headers = @{ 'x-auth-token' = 'sess-tok' } } } -ParameterFilter { $Uri -eq 'https://fb.test/api/login' } From 6e8b66bbda485eac9bf3ae90133d1f4666381d61 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 08:07:16 -0700 Subject: [PATCH 39/53] fix(connection): key the API-token lookup on the array's admin name, not the caller's The best-effort read/mint of a long-lived API token matched $item.admin.name against $Username -- what the caller typed. That name comes from the same array as the login body, so the array's own spelling is the only one that can be relied on to match it. Now keys on $resolvedUsername. Maintainer's call. Be precise about what this closes, because the first description of it was wrong. PowerShell's -eq is CASE-INSENSITIVE, so 'pureuser' vs 'PUREUSER' always matched and case was never the failure mode. What did miss is a name differing beyond case: a directory-service admin logging in as 'jdoe' against an array that records 'jdoe@corp.example'. The match failed, no token was cached, and the session silently lost auto-reconnect with only a -Verbose line to say so. The new test therefore uses a directory-qualified name rather than a case difference. A case-only fixture would have passed against the old code too, proving nothing. ?names= on the mint POST is switched over for consistency only -- the endpoint ignores it and acts on the authenticated admin regardless. --- Public/Connection/Connect-PfbArray.ps1 | 27 ++++++++++------ Tests/Connect-PfbArray.Context.Tests.ps1 | 39 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/Public/Connection/Connect-PfbArray.ps1 b/Public/Connection/Connect-PfbArray.ps1 index d1ffc7ef..82480d25 100644 --- a/Public/Connection/Connect-PfbArray.ps1 +++ b/Public/Connection/Connect-PfbArray.ps1 @@ -371,14 +371,23 @@ function Connect-PfbArray { # Use a local variable since the $ApiToken parameter retains its [ValidateNotNullOrEmpty] # constraint and would reject a $null reassignment. # - # This block deliberately still keys on $Username, NOT $resolvedUsername: it is - # pre-existing best-effort behaviour with its own tests, and switching it is a - # separate change with its own risk. Noted rather than done -- if the caller's - # spelling differs from the array's, the read/mint below can miss, which is exactly - # the behaviour it had before Task 12b. + # Keys on $resolvedUsername -- the ARRAY's own spelling from the login body -- not on + # $Username, what the caller typed. Maintainer's call, 2026-08-06. The client-side + # match below is against $item.admin.name, which comes from the same array, so the + # array's spelling is by definition the one that can match it. + # + # Be precise about the failure mode this closes, because the first description of it + # was wrong: PowerShell's -eq is CASE-INSENSITIVE, so 'pureuser' vs 'PUREUSER' always + # matched and case was never the problem. What did miss is a name differing beyond + # case -- a directory-service admin who logs in as 'jdoe' while the array records + # 'jdoe@corp.example'. Then the match failed, no token was cached, and the session + # silently lost auto-reconnect with only a -Verbose line to say so. $cachedApiToken = $null $tokenHeaders = @{ 'x-auth-token' = $authToken } - $encodedName = [System.Uri]::EscapeDataString($Username) + # EscapeDataString is safe if this is still $null (measured: PowerShell binds $null to + # the [string] overload as '', it does not throw). Moot on the wire regardless -- + # /admins/api-tokens ignores ?names= and acts on the authenticated admin. + $encodedName = [System.Uri]::EscapeDataString($resolvedUsername) $tokenBaseUri = "https://${Endpoint}/api/${negotiatedVersion}/admins/api-tokens" $tokenInvokeArgs = @{} if ($IgnoreCertificateError -and $PSVersionTable.PSVersion.Major -ge 6) { @@ -389,7 +398,7 @@ function Connect-PfbArray { # silently ignores the names= / ids= filters and returns all admins, with the # caller's own token unmasked and other admins' tokens redacted to '****'. We must # filter client-side to avoid grabbing a peer admin's masked entry. - $isOurAdmin = { param($item) $item.admin -and $item.admin.name -eq $Username } + $isOurAdmin = { param($item) $item.admin -and $item.admin.name -eq $resolvedUsername } $isRealToken = { param($t) $t -and $t -ne '****' -and -not ($t -match '^\*+$') } try { @@ -401,7 +410,7 @@ function Connect-PfbArray { } } catch { - Write-Verbose "Could not read existing API token for '$Username': $($_.Exception.Message)" + Write-Verbose "Could not read existing API token for '$resolvedUsername': $($_.Exception.Message)" } if (-not $cachedApiToken) { try { @@ -413,7 +422,7 @@ function Connect-PfbArray { } } catch { - Write-Verbose "Could not mint API token for '$Username': $($_.Exception.Message)" + Write-Verbose "Could not mint API token for '$resolvedUsername': $($_.Exception.Message)" } } if ($cachedApiToken) { diff --git a/Tests/Connect-PfbArray.Context.Tests.ps1 b/Tests/Connect-PfbArray.Context.Tests.ps1 index 5c3494ab..26ea260e 100644 --- a/Tests/Connect-PfbArray.Context.Tests.ps1 +++ b/Tests/Connect-PfbArray.Context.Tests.ps1 @@ -458,6 +458,45 @@ Describe 'Connect-PfbArray Username is array-authoritative' { $conn.Username | Should -BeExactly 'jdoe' } + It 'caches the API token when the array spells the admin differently from the caller' { + # The api-token read/mint block keys on the ARRAY's spelling, not the caller's. Its match is + # against $item.admin.name, which comes from the same array as the login body, so only the + # resolved name can be relied on to match it. + # + # The fixture uses a DIRECTORY-QUALIFIED name, not a case difference, and that is + # load-bearing: PowerShell's -eq is case-insensitive, so 'jdoe' -eq 'JDOE' already matched + # and a case-only fixture would pass against the old code too -- a test that proves + # nothing. A caller who logs in as 'jdoe' against an array that records + # 'jdoe@corp.example' is the case that actually missed, silently costing the session its + # auto-reconnect token. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-WebRequest { + [PSCustomObject]@{ + Headers = @{ 'x-auth-token' = 'tok' } + Content = '{"username":"jdoe@corp.example"}' + } + } + # Overrides the empty-list mock in BeforeEach: this admin DOES have a long-lived token. + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-RestMethod { + [PSCustomObject]@{ + items = @( + [PSCustomObject]@{ + admin = [PSCustomObject]@{ name = 'jdoe@corp.example' } + api_token = [PSCustomObject]@{ token = 'T-longlived' } + } + ) + } + } -ParameterFilter { $Uri -match '/admins/api-tokens' } + + $conn = Connect-PfbArray -Endpoint 'fb.test' -Username 'jdoe' ` + -Password (ConvertTo-SecureString 'pw' -AsPlainText -Force) + + # Both halves matter. The Username assertion shows the resolved name reached the + # connection; the ApiToken assertion is the one that reds if the lookup keys on $Username, + # because 'jdoe@corp.example' -eq 'jdoe' is false and no token gets cached. + $conn.Username | Should -BeExactly 'jdoe@corp.example' + $conn.ApiToken | Should -BeExactly 'T-longlived' -Because 'the client-side admin match must use the array spelling, or auto-reconnect is silently lost' + } + It 'keeps the parameter-supplied Username on the Certificate set' { # No /api/login response exists on this path -- OAuth2 is a JWT exchange and returns only # AccessToken/ExpiresAt/TtlSeconds. -Username is Mandatory here, so it can never be empty, From 57af73c7a946fbbf15c373065ead038ecbebdd4f Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 08:28:03 -0700 Subject: [PATCH 40/53] docs(context): generate context-requirement NOTES for non-default-scope cmdlets Adds tools/Update-PfbContextHelp.ps1, which renders a delimited .NOTES block into every cmdlet whose endpoint carries a non-default (fleet or unknown) contextScope in Data/PfbCapabilityMap.json -- the same field the four client-side context gates validate against, so the help cannot drift from the enforced behaviour. 24 Public/ cmdlets affected (8 fleet-scoped preset endpoints, 16 unknown-scoped). Three fleet-scoped topology-group endpoints have no cmdlet in Public/ at all and are reported as MissingCmdlet rather than silently skipped. Co-Authored-By: Claude Opus 5 --- .../Array/Get-PfbArrayClientPerformance.ps1 | 8 + Public/Array/Get-PfbArrayHttpPerformance.ps1 | 8 + Public/Array/Get-PfbArrayNfsPerformance.ps1 | 8 + Public/Array/Get-PfbArrayPerformance.ps1 | 8 + .../Get-PfbArrayPerformanceReplication.ps1 | 8 + Public/Array/Get-PfbArrayS3Performance.ps1 | 8 + Public/Array/Get-PfbArraySpace.ps1 | 8 + Public/Array/Get-PfbArraySshCaPolicy.ps1 | 8 + Public/Array/New-PfbArraySshCaPolicy.ps1 | 8 + Public/Array/Remove-PfbArraySshCaPolicy.ps1 | 8 + Public/Array/Update-PfbArray.ps1 | 8 + .../Monitoring/Get-PfbLogTargetFileSystem.ps1 | 8 + .../Monitoring/New-PfbLogTargetFileSystem.ps1 | 8 + .../Update-PfbLogTargetFileSystem.ps1 | 8 + .../Get-PfbAuditFileSystemPolicyOperation.ps1 | 8 + ...Get-PfbStorageClassTieringPolicyMember.ps1 | 8 + Public/Policy/New-PfbSshCaPolicy.ps1 | 8 + Public/Policy/Update-PfbSshCaPolicy.ps1 | 8 + Public/Presets/Get-PfbPresetWorkload.ps1 | 7 + Public/Presets/New-PfbPresetWorkload.ps1 | 7 + Public/Presets/Remove-PfbPresetWorkload.ps1 | 7 + Public/Presets/Set-PfbPresetWorkload.ps1 | 7 + Public/Presets/Update-PfbPresetWorkload.ps1 | 7 + Public/Realm/Get-PfbRealm.ps1 | 8 + Tests/Update-PfbContextHelp.Tests.ps1 | 106 +++++++ tools/Update-PfbContextHelp.ps1 | 258 ++++++++++++++++++ 26 files changed, 551 insertions(+) create mode 100644 Tests/Update-PfbContextHelp.Tests.ps1 create mode 100644 tools/Update-PfbContextHelp.ps1 diff --git a/Public/Array/Get-PfbArrayClientPerformance.ps1 b/Public/Array/Get-PfbArrayClientPerformance.ps1 index 0c50ded7..4159a2d1 100644 --- a/Public/Array/Get-PfbArrayClientPerformance.ps1 +++ b/Public/Array/Get-PfbArrayClientPerformance.ps1 @@ -28,6 +28,14 @@ function Get-PfbArrayClientPerformance { .EXAMPLE Get-PfbArrayClientPerformance -Filter "name='10.0.0.1'" Returns performance metrics for a specific client IP. + .NOTES + + Context requirement (GET /arrays/clients/performance): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding()] param( diff --git a/Public/Array/Get-PfbArrayHttpPerformance.ps1 b/Public/Array/Get-PfbArrayHttpPerformance.ps1 index 179115d8..7106fcf5 100644 --- a/Public/Array/Get-PfbArrayHttpPerformance.ps1 +++ b/Public/Array/Get-PfbArrayHttpPerformance.ps1 @@ -28,6 +28,14 @@ function Get-PfbArrayHttpPerformance { .EXAMPLE Get-PfbArrayHttpPerformance -StartTime 1609459200000 -EndTime 1609545600000 Returns HTTP performance metrics for a specific time range. + .NOTES + + Context requirement (GET /arrays/http-specific-performance): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding()] param( diff --git a/Public/Array/Get-PfbArrayNfsPerformance.ps1 b/Public/Array/Get-PfbArrayNfsPerformance.ps1 index 5e8e576e..c6fba7c2 100644 --- a/Public/Array/Get-PfbArrayNfsPerformance.ps1 +++ b/Public/Array/Get-PfbArrayNfsPerformance.ps1 @@ -28,6 +28,14 @@ function Get-PfbArrayNfsPerformance { .EXAMPLE Get-PfbArrayNfsPerformance -StartTime 1609459200000 -Resolution 30000 Returns NFS performance metrics from a start time at 30-second resolution. + .NOTES + + Context requirement (GET /arrays/nfs-specific-performance): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding()] param( diff --git a/Public/Array/Get-PfbArrayPerformance.ps1 b/Public/Array/Get-PfbArrayPerformance.ps1 index 9313383f..8bd1e29b 100644 --- a/Public/Array/Get-PfbArrayPerformance.ps1 +++ b/Public/Array/Get-PfbArrayPerformance.ps1 @@ -19,6 +19,14 @@ function Get-PfbArrayPerformance { Get-PfbArrayPerformance .EXAMPLE Get-PfbArrayPerformance -Protocol nfs + .NOTES + + Context requirement (GET /arrays/performance): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding()] param( diff --git a/Public/Array/Get-PfbArrayPerformanceReplication.ps1 b/Public/Array/Get-PfbArrayPerformanceReplication.ps1 index 3c4ae1c2..870581fb 100644 --- a/Public/Array/Get-PfbArrayPerformanceReplication.ps1 +++ b/Public/Array/Get-PfbArrayPerformanceReplication.ps1 @@ -31,6 +31,14 @@ function Get-PfbArrayPerformanceReplication { .EXAMPLE Get-PfbArrayPerformanceReplication -StartTime 1609459200000 -EndTime 1609545600000 Returns replication performance metrics for a specific time range. + .NOTES + + Context requirement (GET /arrays/performance/replication): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding()] param( diff --git a/Public/Array/Get-PfbArrayS3Performance.ps1 b/Public/Array/Get-PfbArrayS3Performance.ps1 index ad9a7542..ae7a4ef4 100644 --- a/Public/Array/Get-PfbArrayS3Performance.ps1 +++ b/Public/Array/Get-PfbArrayS3Performance.ps1 @@ -28,6 +28,14 @@ function Get-PfbArrayS3Performance { .EXAMPLE Get-PfbArrayS3Performance -StartTime 1609459200000 -EndTime 1609545600000 Returns S3 performance metrics for a specific time range. + .NOTES + + Context requirement (GET /arrays/s3-specific-performance): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding()] param( diff --git a/Public/Array/Get-PfbArraySpace.ps1 b/Public/Array/Get-PfbArraySpace.ps1 index 41972daf..7ba11df3 100644 --- a/Public/Array/Get-PfbArraySpace.ps1 +++ b/Public/Array/Get-PfbArraySpace.ps1 @@ -11,6 +11,14 @@ function Get-PfbArraySpace { Defaults to 'array' if not specified. .EXAMPLE Get-PfbArraySpace + .NOTES + + Context requirement (GET /arrays/space): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding()] param( diff --git a/Public/Array/Get-PfbArraySshCaPolicy.ps1 b/Public/Array/Get-PfbArraySshCaPolicy.ps1 index 02861506..0f9d54f6 100644 --- a/Public/Array/Get-PfbArraySshCaPolicy.ps1 +++ b/Public/Array/Get-PfbArraySshCaPolicy.ps1 @@ -31,6 +31,14 @@ function Get-PfbArraySshCaPolicy { Get-PfbArraySshCaPolicy -MemberName "array1" -Limit 5 Retrieves up to 5 SSH CA policy associations for the specified array. + .NOTES + + Context requirement (GET /arrays/ssh-certificate-authority-policies): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding()] param( diff --git a/Public/Array/New-PfbArraySshCaPolicy.ps1 b/Public/Array/New-PfbArraySshCaPolicy.ps1 index 970495c1..790815c4 100644 --- a/Public/Array/New-PfbArraySshCaPolicy.ps1 +++ b/Public/Array/New-PfbArraySshCaPolicy.ps1 @@ -27,6 +27,14 @@ function New-PfbArraySshCaPolicy { New-PfbArraySshCaPolicy -PolicyName "ssh-ca-dev" -MemberName "dev-array" Associates "dev-array" with the SSH CA policy "ssh-ca-dev". + .NOTES + + Context requirement (POST /arrays/ssh-certificate-authority-policies): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( diff --git a/Public/Array/Remove-PfbArraySshCaPolicy.ps1 b/Public/Array/Remove-PfbArraySshCaPolicy.ps1 index 5de8126c..72bccf7f 100644 --- a/Public/Array/Remove-PfbArraySshCaPolicy.ps1 +++ b/Public/Array/Remove-PfbArraySshCaPolicy.ps1 @@ -27,6 +27,14 @@ function Remove-PfbArraySshCaPolicy { Remove-PfbArraySshCaPolicy -PolicyName "ssh-ca-test" -MemberName "test-array" Removes the association after prompting for confirmation. + .NOTES + + Context requirement (DELETE /arrays/ssh-certificate-authority-policies): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( diff --git a/Public/Array/Update-PfbArray.ps1 b/Public/Array/Update-PfbArray.ps1 index 70a380db..c0dac6be 100644 --- a/Public/Array/Update-PfbArray.ps1 +++ b/Public/Array/Update-PfbArray.ps1 @@ -22,6 +22,14 @@ function Update-PfbArray { Update-PfbArray -Attributes @{ time_zone = "America/New_York" } -WhatIf Shows what would happen without actually updating the array. + .NOTES + + Context requirement (PATCH /arrays): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( diff --git a/Public/Monitoring/Get-PfbLogTargetFileSystem.ps1 b/Public/Monitoring/Get-PfbLogTargetFileSystem.ps1 index 1ad5b55d..c5a2dbb2 100644 --- a/Public/Monitoring/Get-PfbLogTargetFileSystem.ps1 +++ b/Public/Monitoring/Get-PfbLogTargetFileSystem.ps1 @@ -30,6 +30,14 @@ function Get-PfbLogTargetFileSystem { Get-PfbLogTargetFileSystem -Filter "enabled='true'" -Sort "name" -Limit 10 Retrieves up to 10 enabled log-target file systems sorted by name. + .NOTES + + Context requirement (GET /log-targets/file-systems): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding(DefaultParameterSetName = 'List')] param( diff --git a/Public/Monitoring/New-PfbLogTargetFileSystem.ps1 b/Public/Monitoring/New-PfbLogTargetFileSystem.ps1 index c16bd982..49e33e4c 100644 --- a/Public/Monitoring/New-PfbLogTargetFileSystem.ps1 +++ b/Public/Monitoring/New-PfbLogTargetFileSystem.ps1 @@ -24,6 +24,14 @@ function New-PfbLogTargetFileSystem { New-PfbLogTargetFileSystem -Name "log-fs-target1" -Attributes @{ path = '/audit-logs' } Creates a log-target file system with a specific path. + .NOTES + + Context requirement (POST /log-targets/file-systems): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( diff --git a/Public/Monitoring/Update-PfbLogTargetFileSystem.ps1 b/Public/Monitoring/Update-PfbLogTargetFileSystem.ps1 index 150a8ec5..17d8d46d 100644 --- a/Public/Monitoring/Update-PfbLogTargetFileSystem.ps1 +++ b/Public/Monitoring/Update-PfbLogTargetFileSystem.ps1 @@ -43,6 +43,14 @@ function Update-PfbLogTargetFileSystem { Update-PfbLogTargetFileSystem -Name "log-fs-target1" -FileSystem 'new-fs' Updates the underlying file system reference using a typed parameter. + .NOTES + + Context requirement (PATCH /log-targets/file-systems): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'ByNameIndividual')] diff --git a/Public/Policy/Get-PfbAuditFileSystemPolicyOperation.ps1 b/Public/Policy/Get-PfbAuditFileSystemPolicyOperation.ps1 index 59e0fd04..6facb7bd 100644 --- a/Public/Policy/Get-PfbAuditFileSystemPolicyOperation.ps1 +++ b/Public/Policy/Get-PfbAuditFileSystemPolicyOperation.ps1 @@ -26,6 +26,14 @@ function Get-PfbAuditFileSystemPolicyOperation { Get-PfbAuditFileSystemPolicyOperation -Filter "name='read'" -Sort "name" Retrieves operations matching the filter, sorted by name. + .NOTES + + Context requirement (GET /audit-file-systems-policy-operations): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding()] param( diff --git a/Public/Policy/Get-PfbStorageClassTieringPolicyMember.ps1 b/Public/Policy/Get-PfbStorageClassTieringPolicyMember.ps1 index f2329da6..aa03e116 100644 --- a/Public/Policy/Get-PfbStorageClassTieringPolicyMember.ps1 +++ b/Public/Policy/Get-PfbStorageClassTieringPolicyMember.ps1 @@ -32,6 +32,14 @@ function Get-PfbStorageClassTieringPolicyMember { Get-PfbStorageClassTieringPolicyMember -MemberName "fs1" -Limit 10 Retrieves up to 10 tiering policy associations for the specified member. + .NOTES + + Context requirement (GET /storage-class-tiering-policies/members): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding()] param( diff --git a/Public/Policy/New-PfbSshCaPolicy.ps1 b/Public/Policy/New-PfbSshCaPolicy.ps1 index 64a18c8d..350d49bd 100644 --- a/Public/Policy/New-PfbSshCaPolicy.ps1 +++ b/Public/Policy/New-PfbSshCaPolicy.ps1 @@ -24,6 +24,14 @@ function New-PfbSshCaPolicy { New-PfbSshCaPolicy -Name "ssh-ca-dev" -Attributes @{ public_key = "ssh-ed25519 AAAA..." } Creates a new SSH CA policy with an Ed25519 public key. + .NOTES + + Context requirement (POST /ssh-certificate-authority-policies): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( diff --git a/Public/Policy/Update-PfbSshCaPolicy.ps1 b/Public/Policy/Update-PfbSshCaPolicy.ps1 index c6c86133..e8d67c64 100644 --- a/Public/Policy/Update-PfbSshCaPolicy.ps1 +++ b/Public/Policy/Update-PfbSshCaPolicy.ps1 @@ -43,6 +43,14 @@ function Update-PfbSshCaPolicy { Update-PfbSshCaPolicy -Name "ssh-ca-prod" -Attributes @{ enabled = $false } -WhatIf Shows what would happen without actually updating the policy. + .NOTES + + Context requirement (PATCH /ssh-certificate-authority-policies): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'ByNameIndividual')] diff --git a/Public/Presets/Get-PfbPresetWorkload.ps1 b/Public/Presets/Get-PfbPresetWorkload.ps1 index fb8896c3..a9d5d588 100644 --- a/Public/Presets/Get-PfbPresetWorkload.ps1 +++ b/Public/Presets/Get-PfbPresetWorkload.ps1 @@ -16,6 +16,13 @@ function Get-PfbPresetWorkload { Get-PfbPresetWorkload .EXAMPLE Get-PfbPresetWorkload -Name 'analytics-template' + .NOTES + + Context requirement (GET /presets/workload): this cmdlet targets a fleet-scoped resource + and requires a bare fleet context. Set one with + Set-PfbContext -Context -Kind Fleet, or scope a single call with + Invoke-PfbInContext. Get the fleet name from Get-PfbFleet. + #> [CmdletBinding(DefaultParameterSetName = 'List')] param( diff --git a/Public/Presets/New-PfbPresetWorkload.ps1 b/Public/Presets/New-PfbPresetWorkload.ps1 index 19b2511c..0d10742f 100644 --- a/Public/Presets/New-PfbPresetWorkload.ps1 +++ b/Public/Presets/New-PfbPresetWorkload.ps1 @@ -30,6 +30,13 @@ function New-PfbPresetWorkload { platform_features = @(@{ name = 'file' }) } New-PfbPresetWorkload -Name 'analytics-template' -Attributes $preset + .NOTES + + Context requirement (POST /presets/workload): this cmdlet targets a fleet-scoped resource + and requires a bare fleet context. Set one with + Set-PfbContext -Context -Kind Fleet, or scope a single call with + Invoke-PfbInContext. Get the fleet name from Get-PfbFleet. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( diff --git a/Public/Presets/Remove-PfbPresetWorkload.ps1 b/Public/Presets/Remove-PfbPresetWorkload.ps1 index b632ffd1..854403e4 100644 --- a/Public/Presets/Remove-PfbPresetWorkload.ps1 +++ b/Public/Presets/Remove-PfbPresetWorkload.ps1 @@ -13,6 +13,13 @@ function Remove-PfbPresetWorkload { FlashBlade connection. .EXAMPLE Remove-PfbPresetWorkload -Name 'analytics-template' + .NOTES + + Context requirement (DELETE /presets/workload): this cmdlet targets a fleet-scoped resource + and requires a bare fleet context. Set one with + Set-PfbContext -Context -Kind Fleet, or scope a single call with + Invoke-PfbInContext. Get the fleet name from Get-PfbFleet. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( diff --git a/Public/Presets/Set-PfbPresetWorkload.ps1 b/Public/Presets/Set-PfbPresetWorkload.ps1 index 9a4b7fa7..15436095 100644 --- a/Public/Presets/Set-PfbPresetWorkload.ps1 +++ b/Public/Presets/Set-PfbPresetWorkload.ps1 @@ -17,6 +17,13 @@ function Set-PfbPresetWorkload { FlashBlade connection. .EXAMPLE Set-PfbPresetWorkload -Name 'analytics-template' -Attributes $newBody + .NOTES + + Context requirement (PUT /presets/workload): this cmdlet targets a fleet-scoped resource + and requires a bare fleet context. Set one with + Set-PfbContext -Context -Kind Fleet, or scope a single call with + Invoke-PfbInContext. Get the fleet name from Get-PfbFleet. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( diff --git a/Public/Presets/Update-PfbPresetWorkload.ps1 b/Public/Presets/Update-PfbPresetWorkload.ps1 index aee4b481..f1f49634 100644 --- a/Public/Presets/Update-PfbPresetWorkload.ps1 +++ b/Public/Presets/Update-PfbPresetWorkload.ps1 @@ -15,6 +15,13 @@ function Update-PfbPresetWorkload { FlashBlade connection. .EXAMPLE Update-PfbPresetWorkload -Name 'analytics-template' -NewName 'analytics-template-v2' + .NOTES + + Context requirement (PATCH /presets/workload): this cmdlet targets a fleet-scoped resource + and requires a bare fleet context. Set one with + Set-PfbContext -Context -Kind Fleet, or scope a single call with + Invoke-PfbInContext. Get the fleet name from Get-PfbFleet. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( diff --git a/Public/Realm/Get-PfbRealm.ps1 b/Public/Realm/Get-PfbRealm.ps1 index ba29e16f..60e9213d 100644 --- a/Public/Realm/Get-PfbRealm.ps1 +++ b/Public/Realm/Get-PfbRealm.ps1 @@ -27,6 +27,14 @@ function Get-PfbRealm { Get-PfbRealm -Name "realm1", "realm2" .EXAMPLE "realm1" | Get-PfbRealm + .NOTES + + Context requirement (GET /realms): the context scope for this endpoint is not + recorded in the capability map, so the module will not pre-validate a context + for it. A fleet or array context may still be required by the array itself; if + a call fails with a context error, set one with Set-PfbContext or scope the + call with Invoke-PfbInContext. + #> [CmdletBinding(DefaultParameterSetName = 'List')] param( diff --git a/Tests/Update-PfbContextHelp.Tests.ps1 b/Tests/Update-PfbContextHelp.Tests.ps1 new file mode 100644 index 00000000..d8c05dbb --- /dev/null +++ b/Tests/Update-PfbContextHelp.Tests.ps1 @@ -0,0 +1,106 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } +<# +.SYNOPSIS + Tests for tools/Update-PfbContextHelp.ps1, the generator that writes the + context-requirement .NOTES block into every non-default-scope cmdlet's + comment-based help. +.DESCRIPTION + Notes on deliberate choices here: + + * No Should -BeNullOrEmpty anywhere. This whole feature turns on the difference + between $null (nothing recorded / nothing to emit) and an explicit empty value, + and -BeNullOrEmpty cannot tell them apart. Assert `$null -eq $x` instead. + + * Every path is derived from $PSScriptRoot. The Pester runner does not run with the + repo root as its working directory, so CWD-relative paths would silently fail. + + * The idempotency assertion runs the generator with -WhatIf against the real, + already-generated tree and asserts it reports zero would-be changes. That is a + fixed-point assertion with exactly the same strength as write-twice-and-compare, + but it cannot corrupt tracked Public/ files or leave the worktree dirty when it + fails. + + * InModuleScope appears INSIDE the It that needs it, never wrapped around a Describe + body (which fails at discovery time in this repo and silently never runs). +#> + +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + $script:generator = Join-Path $script:repoRoot 'tools/Update-PfbContextHelp.ps1' + $script:presetFile = Join-Path $script:repoRoot 'Public/Presets/New-PfbPresetWorkload.ps1' + $script:manifest = Join-Path $script:repoRoot 'PureStorageFlashBladePowerShell.psd1' + Import-Module $script:manifest -Force +} + +Describe 'Update-PfbContextHelp' { + It 'emits a fleet-context requirement line for a fleet-scoped endpoint' { + $line = & $script:generator -EmitLineOnly -Scope 'fleet' -EndpointKey 'POST /presets/workload' + $line | Should -BeLike '*fleet context*' + $line | Should -BeLike '*Set-PfbContext*' + # The endpoint key is quoted back so the reader can see which call is scoped. + $line.Contains('POST /presets/workload') | Should -BeTrue + } + + It 'emits a not-recorded / no-pre-validation line for an unknown-scoped endpoint' { + $line = & $script:generator -EmitLineOnly -Scope 'unknown' -EndpointKey 'GET /realms' + # Assert on substrings that do not straddle the block's own line wrapping -- + # "is not / recorded" is split across two lines in the rendered block. + $line.Contains('recorded in the capability map') | Should -BeTrue + $line.Contains('will not pre-validate') | Should -BeTrue + } + + It 'emits nothing for an array-scoped endpoint' { + $line = & $script:generator -EmitLineOnly -Scope 'array' -EndpointKey 'GET /file-systems' + $null -eq $line | Should -BeTrue + } + + It 'throws when -EmitLineOnly is used without -Scope' { + { & $script:generator -EmitLineOnly -EndpointKey 'GET /realms' } | + Should -Throw -ExpectedMessage '*-EmitLineOnly requires both -Scope and -EndpointKey*' + } + + It 'wraps the emitted block in the do-not-edit delimiters' { + $line = & $script:generator -EmitLineOnly -Scope 'fleet' -EndpointKey 'POST /presets/workload' + $line.Contains('') | Should -BeTrue + $line.Contains('Do not edit') | Should -BeTrue + } + + It 'is idempotent: a -WhatIf run against the generated tree reports zero changes' { + # The block is delimited, so a second run REPLACES rather than appends. An + # appending generator would grow the help on every regeneration and would show + # up here as a non-zero would-change count. + $summary = & $script:generator -WhatIf + $summary.Changed.Count | Should -Be 0 + } + + It 'reports every non-default-scope endpoint that has no matching cmdlet file' { + $summary = & $script:generator -WhatIf + # Reported, never silently skipped: a silent skip reads as "covered everything". + $summary.PSObject.Properties.Name | Should -Contain 'MissingCmdlet' + foreach ($entry in $summary.MissingCmdlet) { + $entry.EndpointKey | Should -Not -BeNullOrEmpty + } + } + + It 'generates the line from contextScope, so help cannot drift from validation' { + InModuleScope PureStorageFlashBladePowerShell { + $map = Get-PfbCapabilityMap + $map.endpoints.'POST /presets/workload'.contextScope.scope | Should -Be 'fleet' + } + (Get-Content $script:presetFile -Raw) | Should -BeLike '*fleet context*' + } + + It 'covers every non-default-scope endpoint that maps to a cmdlet' { + $summary = & $script:generator -WhatIf + $summary.Generated.Count | Should -BeGreaterThan 0 + foreach ($file in $summary.Generated) { + (Get-Content $file -Raw).Contains(' + ... + + + so a re-run REPLACES the block rather than appending to it. Running this script twice + produces byte-identical files (see Tests/Update-PfbContextHelp.Tests.ps1). + + Endpoint-to-cmdlet mapping is done by scanning each Public/**/*.ps1 for its + `Invoke-PfbApiRequest -Method ... -Endpoint ''` call. Any non-default-scope + endpoint with no matching cmdlet file is REPORTED (warning + MissingCmdlet in the + returned summary), never silently skipped -- a silent skip reads as "covered everything". +.PARAMETER EmitLineOnly + Diagnostic/test mode: emit the block that WOULD be generated for a single + -Scope / -EndpointKey pair and write no files. Returns $null for the default `array` + scope, since nothing is generated for it. +.PARAMETER Scope + With -EmitLineOnly, the contextScope value to render ('fleet', 'unknown', 'array'). + Deliberately not [Parameter(Mandatory)]: a mandatory parameter prompts and hangs under + -NonInteractive. Missing values throw explicitly instead. +.PARAMETER EndpointKey + With -EmitLineOnly, the endpoint key to render, e.g. 'POST /presets/workload'. +.PARAMETER CapabilityMapPath + Path to the capability map. Defaults to Data/PfbCapabilityMap.json under the repo root. +.PARAMETER PublicRoot + Directory holding the cmdlet files. Defaults to Public/ under the repo root. +.OUTPUTS + In generate mode, a summary object with: + Changed - files whose content this run changed (or, under -WhatIf, would change) + Generated - every file that carries a generated block after this run + Unchanged - count of target files already correct + MissingCmdlet - non-default-scope endpoints with no cmdlet file +.EXAMPLE + ./tools/Update-PfbContextHelp.ps1 -WhatIf + Report what would change without writing anything. +.EXAMPLE + ./tools/Update-PfbContextHelp.ps1 + Regenerate the blocks in place. +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [switch]$EmitLineOnly, + + [string]$Scope, + + [string]$EndpointKey, + + [string]$CapabilityMapPath, + + [string]$PublicRoot +) + +$ErrorActionPreference = 'Stop' + +$script:BlockOpen = '' +$script:BlockClose = '' + +function Get-PfbContextHelpBody { + <# + Returns the wrapped, indented block for one endpoint, or $null when the scope is + the default ('array') and therefore needs no note. Unrecognised scopes also return + $null -- the capability map is the source of truth and a new scope value must be + handled deliberately, not guessed at in help text. + #> + param( + [string]$Scope, + [string]$EndpointKey, + [string]$Indent = ' ' + ) + + $lines = switch ($Scope) { + 'fleet' { + @( + "Context requirement ($EndpointKey): this cmdlet targets a fleet-scoped resource" + 'and requires a bare fleet context. Set one with' + 'Set-PfbContext -Context -Kind Fleet, or scope a single call with' + 'Invoke-PfbInContext. Get the fleet name from Get-PfbFleet.' + ) + } + 'unknown' { + @( + "Context requirement ($EndpointKey): the context scope for this endpoint is not" + 'recorded in the capability map, so the module will not pre-validate a context' + 'for it. A fleet or array context may still be required by the array itself; if' + 'a call fails with a context error, set one with Set-PfbContext or scope the' + 'call with Invoke-PfbInContext.' + ) + } + default { $null } + } + + if ($null -eq $lines) { return $null } + + $all = @($script:BlockOpen) + $lines + @($script:BlockClose) + return (($all | ForEach-Object { $Indent + $_ }) -join "`r`n") +} + +# --- diagnostic single-line mode ------------------------------------------------------- +if ($EmitLineOnly) { + if ([string]::IsNullOrWhiteSpace($Scope) -or [string]::IsNullOrWhiteSpace($EndpointKey)) { + throw '-EmitLineOnly requires both -Scope and -EndpointKey.' + } + return (Get-PfbContextHelpBody -Scope $Scope -EndpointKey $EndpointKey) +} + +# --- generate mode --------------------------------------------------------------------- +$repoRoot = Split-Path -Parent $PSScriptRoot +if (-not $CapabilityMapPath) { $CapabilityMapPath = Join-Path $repoRoot 'Data/PfbCapabilityMap.json' } +if (-not $PublicRoot) { $PublicRoot = Join-Path $repoRoot 'Public' } + +if (-not (Test-Path -LiteralPath $CapabilityMapPath)) { + throw "Capability map not found at '$CapabilityMapPath'." +} +if (-not (Test-Path -LiteralPath $PublicRoot)) { + throw "Public cmdlet root not found at '$PublicRoot'." +} + +$map = Get-Content -LiteralPath $CapabilityMapPath -Raw | ConvertFrom-Json + +# Non-default-scope endpoints, keyed exactly as the capability map keys them. +$nonDefault = @{} +foreach ($prop in $map.endpoints.PSObject.Properties) { + $scopeValue = $prop.Value.contextScope.scope + if ($scopeValue -and $scopeValue -ne 'array') { $nonDefault[$prop.Name] = $scopeValue } +} + +# Endpoint key -> cmdlet file, by scanning each cmdlet's Invoke-PfbApiRequest call. +$endpointToFile = @{} +foreach ($file in (Get-ChildItem -LiteralPath $PublicRoot -Recurse -Filter '*.ps1')) { + $text = Get-Content -LiteralPath $file.FullName -Raw + foreach ($call in [regex]::Matches($text, 'Invoke-PfbApiRequest[^\r\n]*')) { + $method = [regex]::Match($call.Value, '-Method\s+([A-Za-z]+)').Groups[1].Value + $endpoint = [regex]::Match($call.Value, "-Endpoint\s+'([^']+)'").Groups[1].Value + if (-not $method -or -not $endpoint) { continue } + $key = '{0} /{1}' -f $method.ToUpperInvariant(), $endpoint + if (-not $nonDefault.ContainsKey($key)) { continue } + if (-not $endpointToFile.ContainsKey($key)) { $endpointToFile[$key] = @() } + if ($endpointToFile[$key] -notcontains $file.FullName) { $endpointToFile[$key] += $file.FullName } + } +} + +# Group the work by file: one file can legitimately own several non-default endpoints, in +# which case it gets one delimited block containing one paragraph per endpoint. +$fileToEndpoints = @{} +$missing = @() +foreach ($key in ($nonDefault.Keys | Sort-Object)) { + if (-not $endpointToFile.ContainsKey($key)) { + $missing += [PSCustomObject]@{ EndpointKey = $key; Scope = $nonDefault[$key] } + Write-Warning "No cmdlet file calls '$key' (contextScope '$($nonDefault[$key])'); no help generated for it." + continue + } + foreach ($f in $endpointToFile[$key]) { + if (-not $fileToEndpoints.ContainsKey($f)) { $fileToEndpoints[$f] = @() } + $fileToEndpoints[$f] += $key + } +} + +function Set-PfbContextHelpBlock { + <# + Returns the new content for one cmdlet file with its generated block inserted or + replaced. Two-phase and therefore idempotent: + 1. strip any existing block (delimiters inclusive), leaving a pre-existing + .NOTES header alone; + 2. insert the freshly rendered block immediately after the .NOTES header, + creating that header just before the help terminator if the file has none. + #> + param( + [string]$Content, + [string]$Block + ) + + # Phase 1: strip the previous generated block, if any. + $stripPattern = '(?ms)^[ \t]*' + [regex]::Escape($script:BlockOpen) + '.*?' + + [regex]::Escape($script:BlockClose) + '[ \t]*\r?\n' + $stripped = [regex]::Replace($Content, $stripPattern, '') + + # Phase 2: insert after an existing .NOTES header, else create one before the + # comment-based help terminator. + $notesMatch = [regex]::Match($stripped, '(?m)^([ \t]*)\.NOTES[ \t]*\r?\n') + if ($notesMatch.Success) { + $insertAt = $notesMatch.Index + $notesMatch.Length + return $stripped.Substring(0, $insertAt) + $Block + "`r`n" + $stripped.Substring($insertAt) + } + + $endMatch = [regex]::Match($stripped, '(?m)^([ \t]*)#>[ \t]*\r?\n') + if (-not $endMatch.Success) { + throw 'Could not locate the end of the comment-based help block (a line consisting of "#>").' + } + $indent = $endMatch.Groups[1].Value + $header = $indent + '.NOTES' + "`r`n" + return $stripped.Substring(0, $endMatch.Index) + $header + $Block + "`r`n" + $stripped.Substring($endMatch.Index) +} + +$changed = @() +$generated = @() +$unchanged = 0 + +foreach ($file in ($fileToEndpoints.Keys | Sort-Object)) { + $keys = $fileToEndpoints[$file] | Sort-Object -Unique + + $paragraphs = @() + foreach ($key in $keys) { + $body = Get-PfbContextHelpBody -Scope $nonDefault[$key] -EndpointKey $key + if ($null -eq $body) { continue } + # Strip each paragraph's own delimiters; one shared wrapper goes around them all. + $inner = ($body -split "`r`n") | Where-Object { + -not $_.Contains($script:BlockOpen) -and -not $_.Contains($script:BlockClose) + } + $paragraphs += ($inner -join "`r`n") + } + if (-not $paragraphs) { continue } + + $indent = ' ' + $block = (@($indent + $script:BlockOpen) + $paragraphs + @($indent + $script:BlockClose)) -join "`r`n" + + $original = Get-Content -LiteralPath $file -Raw + $updated = Set-PfbContextHelpBlock -Content $original -Block $block + + $generated += $file + if ($updated -eq $original) { + $unchanged++ + continue + } + + $changed += $file + if ($PSCmdlet.ShouldProcess($file, 'Update generated context-requirement help block')) { + # WriteAllText, not Set-Content: no Public/*.ps1 has a UTF-8 BOM, and Set-Content + # -Encoding UTF8 adds one under Windows PowerShell 5.1. $updated already carries + # the file's own trailing newline, so no extra newline is appended either. + [System.IO.File]::WriteAllText($file, $updated, (New-Object System.Text.UTF8Encoding($false))) + } +} + +Write-Verbose ("Context help: {0} changed, {1} already current, {2} endpoint(s) with no cmdlet." -f + $changed.Count, $unchanged, $missing.Count) + +[PSCustomObject]@{ + Changed = $changed + Generated = $generated + Unchanged = $unchanged + MissingCmdlet = $missing +} From 837cf216866427275019d179059c2214c256b9be Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 08:29:32 -0700 Subject: [PATCH 41/53] test(context): assert full accounting of non-default-scope endpoints The MissingCmdlet assertion only checked that the property existed, so deleting the reporting entirely would have survived it. Replaces that with an invariant: every non-array contextScope endpoint in the capability map must appear verbatim in a generated help block or in MissingCmdlet. Co-Authored-By: Claude Opus 5 --- Tests/Update-PfbContextHelp.Tests.ps1 | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Tests/Update-PfbContextHelp.Tests.ps1 b/Tests/Update-PfbContextHelp.Tests.ps1 index d8c05dbb..a8fd5548 100644 --- a/Tests/Update-PfbContextHelp.Tests.ps1 +++ b/Tests/Update-PfbContextHelp.Tests.ps1 @@ -83,6 +83,31 @@ Describe 'Update-PfbContextHelp' { } } + It 'accounts for every non-default-scope endpoint as either generated or missing' { + # The strong form of the reporting requirement: no non-default-scope endpoint may + # fall off the end. Each must appear verbatim in a generated block or in + # MissingCmdlet. Asserted as an invariant rather than a pinned count, because the + # capability map is regenerated whenever the specs are refreshed. + $summary = & $script:generator -WhatIf + $mapPath = Join-Path $script:repoRoot 'Data/PfbCapabilityMap.json' + $map = Get-Content $mapPath -Raw | ConvertFrom-Json + + $nonDefault = @( + $map.endpoints.PSObject.Properties | + Where-Object { $_.Value.contextScope.scope -and $_.Value.contextScope.scope -ne 'array' } | + ForEach-Object { $_.Name } + ) + $nonDefault.Count | Should -BeGreaterThan 0 + + $generatedText = ($summary.Generated | ForEach-Object { Get-Content $_ -Raw }) -join "`n" + $missingKeys = @($summary.MissingCmdlet | ForEach-Object { $_.EndpointKey }) + + foreach ($key in $nonDefault) { + $accounted = $generatedText.Contains("($key)") -or ($missingKeys -contains $key) + $accounted | Should -BeTrue -Because "$key must be documented or reported as having no cmdlet" + } + } + It 'generates the line from contextScope, so help cannot drift from validation' { InModuleScope PureStorageFlashBladePowerShell { $map = Get-PfbCapabilityMap From ea495badc111d20c25071ad2ef4f268311962e6c Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 08:55:02 -0700 Subject: [PATCH 42/53] fix(context): report unrenderable context scopes instead of dropping them Task 14 review findings I1, M1, M2 and M4. I1: a non-default contextScope value with no arm in Get-PfbContextHelpBody was dropped silently -- no warning, no summary entry -- which is the same "a silent skip reads as covered everything" failure the MissingCmdlet path already exists to prevent. The only difference is which half of the pairing is absent: there the cmdlet, here the render arm. Generate mode now warns and records the endpoint in a new UnrecognisedScope collection. -EmitLineOnly still returns a bare $null, which is what its array-scope test pins. This is defence in depth rather than a live bug: Build-PfbCapabilityMap maps an unrecognised domain token to 'unknown' rather than inventing a scope value, and Build-PfbCapabilityMap.Tests.ps1 asserts every endpoint's scope is one of fleet/array/unknown, so a new value cannot reach the shipped map without a deliberate edit in two other places. Hard to reach is not a reason to drop the endpoint quietly once it is reached. M2 and the phase's tri-state ruling on an explicitly empty scope: the selection test was a truthiness check, folding an empty scope in with an absent one and treating both as the 'array' default. An ABSENT scope is unset and correctly needs no note; a scope that is PRESENT and empty is a recorded value that says nothing, so it is now reported rather than guessed at. The test mirrors the generator's explicit null test for the same reason. M1: replaced the one banned Should -Not -BeNullOrEmpty, which also makes the file header's claim about it true. The negated form is unambiguous on its own, but the ban stays case-analysis-free. M4: the orphan-.NOTES limitation was documented only in the SDD report, which does not travel with the code. It is now in the generator's .DESCRIPTION. Tests: 13/13 both editions, 0 skipped (11 existing + 2 new). --- Tests/Update-PfbContextHelp.Tests.ps1 | 102 ++++++++++++++++++++++++-- tools/Update-PfbContextHelp.ps1 | 58 ++++++++++++--- 2 files changed, 141 insertions(+), 19 deletions(-) diff --git a/Tests/Update-PfbContextHelp.Tests.ps1 b/Tests/Update-PfbContextHelp.Tests.ps1 index a8fd5548..446debfb 100644 --- a/Tests/Update-PfbContextHelp.Tests.ps1 +++ b/Tests/Update-PfbContextHelp.Tests.ps1 @@ -7,9 +7,12 @@ .DESCRIPTION Notes on deliberate choices here: - * No Should -BeNullOrEmpty anywhere. This whole feature turns on the difference - between $null (nothing recorded / nothing to emit) and an explicit empty value, - and -BeNullOrEmpty cannot tell them apart. Assert `$null -eq $x` instead. + * No Should -BeNullOrEmpty anywhere, in either polarity. This whole feature turns on + the difference between $null (nothing recorded / nothing to emit) and an explicit + empty value, and -BeNullOrEmpty cannot tell them apart. Assert `$null -eq $x` + instead, or [string]::IsNullOrEmpty($x) when "neither null nor empty" really is the + claim. The negated -Not -BeNullOrEmpty is unambiguous on its own, but it is still + banned here so the rule needs no case analysis to apply. * Every path is derived from $PSScriptRoot. The Pester runner does not run with the repo root as its working directory, so CWD-relative paths would silently fail. @@ -79,7 +82,7 @@ Describe 'Update-PfbContextHelp' { # Reported, never silently skipped: a silent skip reads as "covered everything". $summary.PSObject.Properties.Name | Should -Contain 'MissingCmdlet' foreach ($entry in $summary.MissingCmdlet) { - $entry.EndpointKey | Should -Not -BeNullOrEmpty + [string]::IsNullOrEmpty($entry.EndpointKey) | Should -BeFalse } } @@ -94,17 +97,25 @@ Describe 'Update-PfbContextHelp' { $nonDefault = @( $map.endpoints.PSObject.Properties | - Where-Object { $_.Value.contextScope.scope -and $_.Value.contextScope.scope -ne 'array' } | + Where-Object { + # Mirrors the generator's own selection exactly, including the explicit + # null test -- a truthiness test here would fold an empty scope in with + # an absent one and quietly stop asserting on it. + $null -ne $_.Value.contextScope.scope -and $_.Value.contextScope.scope -ne 'array' + } | ForEach-Object { $_.Name } ) $nonDefault.Count | Should -BeGreaterThan 0 $generatedText = ($summary.Generated | ForEach-Object { Get-Content $_ -Raw }) -join "`n" $missingKeys = @($summary.MissingCmdlet | ForEach-Object { $_.EndpointKey }) + $unrenderedKeys = @($summary.UnrecognisedScope | ForEach-Object { $_.EndpointKey }) foreach ($key in $nonDefault) { - $accounted = $generatedText.Contains("($key)") -or ($missingKeys -contains $key) - $accounted | Should -BeTrue -Because "$key must be documented or reported as having no cmdlet" + $accounted = $generatedText.Contains("($key)") -or + ($missingKeys -contains $key) -or + ($unrenderedKeys -contains $key) + $accounted | Should -BeTrue -Because "$key must be documented, or reported as having no cmdlet or no render arm" } } @@ -128,4 +139,81 @@ Describe 'Update-PfbContextHelp' { $arrayScoped = Join-Path $script:repoRoot 'Public/FileSystem/Get-PfbFileSystem.ps1' (Get-Content $arrayScoped -Raw).Contains('", which still marks the text as generated without the maintainer-facing detail, and regenerated all 24 blocks. Because that line is now expected to be reworded again, the strip phase keys on the stable " + Context requirement (GET /arrays/clients/performance): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Array/Get-PfbArrayHttpPerformance.ps1 b/Public/Array/Get-PfbArrayHttpPerformance.ps1 index 7106fcf5..61007b8b 100644 --- a/Public/Array/Get-PfbArrayHttpPerformance.ps1 +++ b/Public/Array/Get-PfbArrayHttpPerformance.ps1 @@ -29,7 +29,7 @@ function Get-PfbArrayHttpPerformance { Get-PfbArrayHttpPerformance -StartTime 1609459200000 -EndTime 1609545600000 Returns HTTP performance metrics for a specific time range. .NOTES - + Context requirement (GET /arrays/http-specific-performance): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Array/Get-PfbArrayNfsPerformance.ps1 b/Public/Array/Get-PfbArrayNfsPerformance.ps1 index c6fba7c2..d90ad43a 100644 --- a/Public/Array/Get-PfbArrayNfsPerformance.ps1 +++ b/Public/Array/Get-PfbArrayNfsPerformance.ps1 @@ -29,7 +29,7 @@ function Get-PfbArrayNfsPerformance { Get-PfbArrayNfsPerformance -StartTime 1609459200000 -Resolution 30000 Returns NFS performance metrics from a start time at 30-second resolution. .NOTES - + Context requirement (GET /arrays/nfs-specific-performance): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Array/Get-PfbArrayPerformance.ps1 b/Public/Array/Get-PfbArrayPerformance.ps1 index 8bd1e29b..b766b77a 100644 --- a/Public/Array/Get-PfbArrayPerformance.ps1 +++ b/Public/Array/Get-PfbArrayPerformance.ps1 @@ -20,7 +20,7 @@ function Get-PfbArrayPerformance { .EXAMPLE Get-PfbArrayPerformance -Protocol nfs .NOTES - + Context requirement (GET /arrays/performance): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Array/Get-PfbArrayPerformanceReplication.ps1 b/Public/Array/Get-PfbArrayPerformanceReplication.ps1 index 870581fb..ead1d72f 100644 --- a/Public/Array/Get-PfbArrayPerformanceReplication.ps1 +++ b/Public/Array/Get-PfbArrayPerformanceReplication.ps1 @@ -32,7 +32,7 @@ function Get-PfbArrayPerformanceReplication { Get-PfbArrayPerformanceReplication -StartTime 1609459200000 -EndTime 1609545600000 Returns replication performance metrics for a specific time range. .NOTES - + Context requirement (GET /arrays/performance/replication): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Array/Get-PfbArrayS3Performance.ps1 b/Public/Array/Get-PfbArrayS3Performance.ps1 index ae7a4ef4..10a51510 100644 --- a/Public/Array/Get-PfbArrayS3Performance.ps1 +++ b/Public/Array/Get-PfbArrayS3Performance.ps1 @@ -29,7 +29,7 @@ function Get-PfbArrayS3Performance { Get-PfbArrayS3Performance -StartTime 1609459200000 -EndTime 1609545600000 Returns S3 performance metrics for a specific time range. .NOTES - + Context requirement (GET /arrays/s3-specific-performance): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Array/Get-PfbArraySpace.ps1 b/Public/Array/Get-PfbArraySpace.ps1 index 7ba11df3..87abb644 100644 --- a/Public/Array/Get-PfbArraySpace.ps1 +++ b/Public/Array/Get-PfbArraySpace.ps1 @@ -12,7 +12,7 @@ function Get-PfbArraySpace { .EXAMPLE Get-PfbArraySpace .NOTES - + Context requirement (GET /arrays/space): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Array/Get-PfbArraySshCaPolicy.ps1 b/Public/Array/Get-PfbArraySshCaPolicy.ps1 index 0f9d54f6..8700f4b8 100644 --- a/Public/Array/Get-PfbArraySshCaPolicy.ps1 +++ b/Public/Array/Get-PfbArraySshCaPolicy.ps1 @@ -32,7 +32,7 @@ function Get-PfbArraySshCaPolicy { Retrieves up to 5 SSH CA policy associations for the specified array. .NOTES - + Context requirement (GET /arrays/ssh-certificate-authority-policies): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Array/New-PfbArraySshCaPolicy.ps1 b/Public/Array/New-PfbArraySshCaPolicy.ps1 index 790815c4..2f040a0b 100644 --- a/Public/Array/New-PfbArraySshCaPolicy.ps1 +++ b/Public/Array/New-PfbArraySshCaPolicy.ps1 @@ -28,7 +28,7 @@ function New-PfbArraySshCaPolicy { Associates "dev-array" with the SSH CA policy "ssh-ca-dev". .NOTES - + Context requirement (POST /arrays/ssh-certificate-authority-policies): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Array/Remove-PfbArraySshCaPolicy.ps1 b/Public/Array/Remove-PfbArraySshCaPolicy.ps1 index 72bccf7f..805b564a 100644 --- a/Public/Array/Remove-PfbArraySshCaPolicy.ps1 +++ b/Public/Array/Remove-PfbArraySshCaPolicy.ps1 @@ -28,7 +28,7 @@ function Remove-PfbArraySshCaPolicy { Removes the association after prompting for confirmation. .NOTES - + Context requirement (DELETE /arrays/ssh-certificate-authority-policies): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Array/Update-PfbArray.ps1 b/Public/Array/Update-PfbArray.ps1 index c0dac6be..5764ab6d 100644 --- a/Public/Array/Update-PfbArray.ps1 +++ b/Public/Array/Update-PfbArray.ps1 @@ -23,7 +23,7 @@ function Update-PfbArray { Shows what would happen without actually updating the array. .NOTES - + Context requirement (PATCH /arrays): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Monitoring/Get-PfbLogTargetFileSystem.ps1 b/Public/Monitoring/Get-PfbLogTargetFileSystem.ps1 index c5a2dbb2..d8aea7b1 100644 --- a/Public/Monitoring/Get-PfbLogTargetFileSystem.ps1 +++ b/Public/Monitoring/Get-PfbLogTargetFileSystem.ps1 @@ -31,7 +31,7 @@ function Get-PfbLogTargetFileSystem { Retrieves up to 10 enabled log-target file systems sorted by name. .NOTES - + Context requirement (GET /log-targets/file-systems): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Monitoring/New-PfbLogTargetFileSystem.ps1 b/Public/Monitoring/New-PfbLogTargetFileSystem.ps1 index 49e33e4c..8df54a2d 100644 --- a/Public/Monitoring/New-PfbLogTargetFileSystem.ps1 +++ b/Public/Monitoring/New-PfbLogTargetFileSystem.ps1 @@ -25,7 +25,7 @@ function New-PfbLogTargetFileSystem { Creates a log-target file system with a specific path. .NOTES - + Context requirement (POST /log-targets/file-systems): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Monitoring/Update-PfbLogTargetFileSystem.ps1 b/Public/Monitoring/Update-PfbLogTargetFileSystem.ps1 index 17d8d46d..a819bd79 100644 --- a/Public/Monitoring/Update-PfbLogTargetFileSystem.ps1 +++ b/Public/Monitoring/Update-PfbLogTargetFileSystem.ps1 @@ -44,7 +44,7 @@ function Update-PfbLogTargetFileSystem { Updates the underlying file system reference using a typed parameter. .NOTES - + Context requirement (PATCH /log-targets/file-systems): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Policy/Get-PfbAuditFileSystemPolicyOperation.ps1 b/Public/Policy/Get-PfbAuditFileSystemPolicyOperation.ps1 index 6facb7bd..353dbfa5 100644 --- a/Public/Policy/Get-PfbAuditFileSystemPolicyOperation.ps1 +++ b/Public/Policy/Get-PfbAuditFileSystemPolicyOperation.ps1 @@ -27,7 +27,7 @@ function Get-PfbAuditFileSystemPolicyOperation { Retrieves operations matching the filter, sorted by name. .NOTES - + Context requirement (GET /audit-file-systems-policy-operations): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Policy/Get-PfbStorageClassTieringPolicyMember.ps1 b/Public/Policy/Get-PfbStorageClassTieringPolicyMember.ps1 index aa03e116..e5f717b0 100644 --- a/Public/Policy/Get-PfbStorageClassTieringPolicyMember.ps1 +++ b/Public/Policy/Get-PfbStorageClassTieringPolicyMember.ps1 @@ -33,7 +33,7 @@ function Get-PfbStorageClassTieringPolicyMember { Retrieves up to 10 tiering policy associations for the specified member. .NOTES - + Context requirement (GET /storage-class-tiering-policies/members): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Policy/New-PfbSshCaPolicy.ps1 b/Public/Policy/New-PfbSshCaPolicy.ps1 index 350d49bd..beaa7923 100644 --- a/Public/Policy/New-PfbSshCaPolicy.ps1 +++ b/Public/Policy/New-PfbSshCaPolicy.ps1 @@ -25,7 +25,7 @@ function New-PfbSshCaPolicy { Creates a new SSH CA policy with an Ed25519 public key. .NOTES - + Context requirement (POST /ssh-certificate-authority-policies): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Policy/Update-PfbSshCaPolicy.ps1 b/Public/Policy/Update-PfbSshCaPolicy.ps1 index e8d67c64..7d42e5aa 100644 --- a/Public/Policy/Update-PfbSshCaPolicy.ps1 +++ b/Public/Policy/Update-PfbSshCaPolicy.ps1 @@ -44,7 +44,7 @@ function Update-PfbSshCaPolicy { Shows what would happen without actually updating the policy. .NOTES - + Context requirement (PATCH /ssh-certificate-authority-policies): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Public/Presets/Get-PfbPresetWorkload.ps1 b/Public/Presets/Get-PfbPresetWorkload.ps1 index a9d5d588..64e5452c 100644 --- a/Public/Presets/Get-PfbPresetWorkload.ps1 +++ b/Public/Presets/Get-PfbPresetWorkload.ps1 @@ -17,7 +17,7 @@ function Get-PfbPresetWorkload { .EXAMPLE Get-PfbPresetWorkload -Name 'analytics-template' .NOTES - + Context requirement (GET /presets/workload): this cmdlet targets a fleet-scoped resource and requires a bare fleet context. Set one with Set-PfbContext -Context -Kind Fleet, or scope a single call with diff --git a/Public/Presets/New-PfbPresetWorkload.ps1 b/Public/Presets/New-PfbPresetWorkload.ps1 index 0d10742f..2d489d76 100644 --- a/Public/Presets/New-PfbPresetWorkload.ps1 +++ b/Public/Presets/New-PfbPresetWorkload.ps1 @@ -31,7 +31,7 @@ function New-PfbPresetWorkload { } New-PfbPresetWorkload -Name 'analytics-template' -Attributes $preset .NOTES - + Context requirement (POST /presets/workload): this cmdlet targets a fleet-scoped resource and requires a bare fleet context. Set one with Set-PfbContext -Context -Kind Fleet, or scope a single call with diff --git a/Public/Presets/Remove-PfbPresetWorkload.ps1 b/Public/Presets/Remove-PfbPresetWorkload.ps1 index 854403e4..aa78b875 100644 --- a/Public/Presets/Remove-PfbPresetWorkload.ps1 +++ b/Public/Presets/Remove-PfbPresetWorkload.ps1 @@ -14,7 +14,7 @@ function Remove-PfbPresetWorkload { .EXAMPLE Remove-PfbPresetWorkload -Name 'analytics-template' .NOTES - + Context requirement (DELETE /presets/workload): this cmdlet targets a fleet-scoped resource and requires a bare fleet context. Set one with Set-PfbContext -Context -Kind Fleet, or scope a single call with diff --git a/Public/Presets/Set-PfbPresetWorkload.ps1 b/Public/Presets/Set-PfbPresetWorkload.ps1 index 15436095..6e9da9d6 100644 --- a/Public/Presets/Set-PfbPresetWorkload.ps1 +++ b/Public/Presets/Set-PfbPresetWorkload.ps1 @@ -18,7 +18,7 @@ function Set-PfbPresetWorkload { .EXAMPLE Set-PfbPresetWorkload -Name 'analytics-template' -Attributes $newBody .NOTES - + Context requirement (PUT /presets/workload): this cmdlet targets a fleet-scoped resource and requires a bare fleet context. Set one with Set-PfbContext -Context -Kind Fleet, or scope a single call with diff --git a/Public/Presets/Update-PfbPresetWorkload.ps1 b/Public/Presets/Update-PfbPresetWorkload.ps1 index f1f49634..bbda4247 100644 --- a/Public/Presets/Update-PfbPresetWorkload.ps1 +++ b/Public/Presets/Update-PfbPresetWorkload.ps1 @@ -16,7 +16,7 @@ function Update-PfbPresetWorkload { .EXAMPLE Update-PfbPresetWorkload -Name 'analytics-template' -NewName 'analytics-template-v2' .NOTES - + Context requirement (PATCH /presets/workload): this cmdlet targets a fleet-scoped resource and requires a bare fleet context. Set one with Set-PfbContext -Context -Kind Fleet, or scope a single call with diff --git a/Public/Realm/Get-PfbRealm.ps1 b/Public/Realm/Get-PfbRealm.ps1 index 60e9213d..94223d2e 100644 --- a/Public/Realm/Get-PfbRealm.ps1 +++ b/Public/Realm/Get-PfbRealm.ps1 @@ -28,7 +28,7 @@ function Get-PfbRealm { .EXAMPLE "realm1" | Get-PfbRealm .NOTES - + Context requirement (GET /realms): the context scope for this endpoint is not recorded in the capability map, so the module will not pre-validate a context for it. A fleet or array context may still be required by the array itself; if diff --git a/Tests/Update-PfbContextHelp.Tests.ps1 b/Tests/Update-PfbContextHelp.Tests.ps1 index 446debfb..06b5cd13 100644 --- a/Tests/Update-PfbContextHelp.Tests.ps1 +++ b/Tests/Update-PfbContextHelp.Tests.ps1 @@ -64,9 +64,12 @@ Describe 'Update-PfbContextHelp' { It 'wraps the emitted block in the do-not-edit delimiters' { $line = & $script:generator -EmitLineOnly -Scope 'fleet' -EndpointKey 'POST /presets/workload' - $line.Contains('') | Should -BeTrue $line.Contains('') | Should -BeTrue - $line.Contains('Do not edit') | Should -BeTrue } It 'is idempotent: a -WhatIf run against the generated tree reports zero changes' { @@ -131,13 +134,15 @@ Describe 'Update-PfbContextHelp' { $summary = & $script:generator -WhatIf $summary.Generated.Count | Should -BeGreaterThan 0 foreach ($file in $summary.Generated) { - (Get-Content $file -Raw).Contains('' + + $root = Join-Path $TestDrive 'reworded' + $publicRoot = Join-Path $root 'Public' + New-Item -ItemType Directory -Path $publicRoot -Force | Out-Null + + $mapPath = Join-Path $root 'map.json' + @{ endpoints = @{ 'GET /zzz-gadgets' = @{ contextScope = @{ scope = 'fleet' } } } } | + ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $mapPath + + $cmdletPath = Join-Path $publicRoot 'Get-ZzzGadget.ps1' + @( + '<#' + '.SYNOPSIS' + ' Synthetic fixture carrying a stale, differently-worded block.' + '.NOTES' + " $legacyOpen" + ' Stale prose that must not survive the run.' + ' ' + '#>' + 'function Get-ZzzGadget {' + " Invoke-PfbApiRequest -Method GET -Endpoint 'zzz-gadgets'" + '}' + ) | Set-Content -LiteralPath $cmdletPath + + # Not -WhatIf: the strip-and-reinsert has to actually land on disk to be counted. + & $script:generator -CapabilityMapPath $mapPath -PublicRoot $publicRoot | + Out-Null + + $text = Get-Content $cmdletPath -Raw + [regex]::Matches($text, [regex]::Escape('')).Count | Should -Be 1 + $text.Contains($legacyOpen) | Should -BeFalse + $text.Contains('Stale prose that must not survive the run.') | Should -BeFalse + $text.Contains('') | Should -BeTrue + } + } } diff --git a/tools/Update-PfbContextHelp.ps1 b/tools/Update-PfbContextHelp.ps1 index 98f2295f..76188d07 100644 --- a/tools/Update-PfbContextHelp.ps1 +++ b/tools/Update-PfbContextHelp.ps1 @@ -15,13 +15,19 @@ The generated text lives between two delimiters: - + ... so a re-run REPLACES the block rather than appending to it. Running this script twice produces byte-identical files (see Tests/Update-PfbContextHelp.Tests.ps1). + The delimiters are rendered verbatim by `Get-Help -Full`, so the opening one is worded + for the person reading the help, not just the maintainer reading the .ps1. The strip + phase therefore matches on the stable `' +# $BlockOpenPrefix is the part of the opening delimiter the strip phase keys on, and the only +# part that is a contract. Everything after it is prose for whoever reads the rendered help. +$script:BlockOpenPrefix = '' $script:BlockClose = '' function Get-PfbContextHelpBody { @@ -209,9 +218,15 @@ function Set-PfbContextHelpBlock { [string]$Block ) - # Phase 1: strip the previous generated block, if any. - $stripPattern = '(?ms)^[ \t]*' + [regex]::Escape($script:BlockOpen) + '.*?' + - [regex]::Escape($script:BlockClose) + '[ \t]*\r?\n' + # Phase 1: strip the previous generated block, if any. Keyed on the OPEN PREFIX, not the + # full opening literal: the rest of that line is prose that Get-Help renders to the user + # and may be reworded. Matching the whole literal would make a reword strip nothing and + # insert a second block beside the first -- a silent doubling on every later re-run, which + # the idempotency test cannot see because it only ever runs against already-current text. + # The prefix cannot collide with the close delimiter (' - Context requirement (GET /presets/workload): this cmdlet targets a fleet-scoped resource - and requires a bare fleet context. Set one with - Set-PfbContext -Context -Kind Fleet, or scope a single call with - Invoke-PfbInContext. Get the fleet name from Get-PfbFleet. + Context requirement (GET /presets/workload): this cmdlet targets a + fleet-scoped resource, but reads on it are narrower than the + requirement. An unfiltered list works with NO context, served from + the array's locally replicated copy. Filtering by name or id needs a + bare fleet context, because that local copy is list-only: set one + with Set-PfbContext -Context -Kind Fleet, or scope a single + call with Invoke-PfbInContext. Get the fleet name from Get-PfbFleet. #> [CmdletBinding(DefaultParameterSetName = 'List')] diff --git a/Tests/Update-PfbContextHelp.Tests.ps1 b/Tests/Update-PfbContextHelp.Tests.ps1 index 06b5cd13..cb737020 100644 --- a/Tests/Update-PfbContextHelp.Tests.ps1 +++ b/Tests/Update-PfbContextHelp.Tests.ps1 @@ -130,6 +130,39 @@ Describe 'Update-PfbContextHelp' { (Get-Content $script:presetFile -Raw) | Should -BeLike '*fleet context*' } + It 'renders a fleet-scoped GET differently from a fleet-scoped write' { + # The runtime gate (Assert-PfbContextRequired) is narrower for GET than for the write + # verbs, twice over, and both narrowings are measured. Emitting the write wording for a + # GET told the reader an unfiltered list requires a fleet context, six lines below the + # .EXAMPLE showing exactly that context-free call. So the two must not read alike. + $write = & $script:generator -EmitLineOnly -Scope 'fleet' -EndpointKey 'POST /presets/workload' + $get = & $script:generator -EmitLineOnly -Scope 'fleet' -EndpointKey 'GET /presets/workload' + + $null -eq $get | Should -BeFalse + # Strip the endpoint key, which differs on its own, so this compares the WORDING. + ($get -replace 'GET /presets/workload', 'X') -eq ($write -replace 'POST /presets/workload', 'X') | + Should -BeFalse -Because 'a fleet-scoped GET must not inherit the write verbs'' wording' + + # The write arm states the requirement unconditionally; the GET arm must say the + # unfiltered read needs no context. + $write.Contains('requires a bare fleet context') | Should -BeTrue + $get.Contains('An unfiltered list works with NO context') | Should -BeTrue + } + + It 'requires a context for a name-scoped fleet GET only where that was measured' { + # GET /presets/workload is on $script:PfbNameScopedContextRequiredEndpoints; the three + # fleet-scoped topology-group GETs are deliberately NOT, because a name-scoped + # context-free read returns 200 on them. The help must reflect that split rather than + # asserting the requirement for every fleet-scoped GET -- and it must read the split + # from the module's own allowlist, so adding an endpoint there changes the help. + $onList = & $script:generator -EmitLineOnly -Scope 'fleet' -EndpointKey 'GET /presets/workload' + $offList = & $script:generator -EmitLineOnly -Scope 'fleet' -EndpointKey 'GET /topology-groups' + + $onList.Contains('Filtering by name or id needs a') | Should -BeTrue + $offList.Contains('None is required') | Should -BeTrue + $offList.Contains('Filtering by name or id needs a') | Should -BeFalse + } + It 'covers every non-default-scope endpoint that maps to a cmdlet' { $summary = & $script:generator -WhatIf $summary.Generated.Count | Should -BeGreaterThan 0 diff --git a/tools/Update-PfbContextHelp.ps1 b/tools/Update-PfbContextHelp.ps1 index 76188d07..fda8c9f2 100644 --- a/tools/Update-PfbContextHelp.ps1 +++ b/tools/Update-PfbContextHelp.ps1 @@ -94,6 +94,19 @@ $script:BlockOpenPrefix = '' $script:BlockClose = '' +# The fleet-scoped GET wording depends on the module's own measured allowlist of endpoints +# where a NAME-SCOPED read genuinely needs a context. Dot-source that list rather than +# copying it -- a local copy is exactly the help/validation drift this generator exists to +# prevent, and the constants file is written to be loaded this way (tools/ is not on the +# module's load path). Failing loudly beats silently rendering a guess. +$script:ConstantsPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Private/PfbContextConstants.ps1' +if (-not (Test-Path -LiteralPath $script:ConstantsPath)) { + throw ("Update-PfbContextHelp: cannot locate the module's context constants at " + + "'$script:ConstantsPath'. The fleet-scoped GET help is generated from " + + '$script:PfbNameScopedContextRequiredEndpoints and must never re-implement it.') +} +. $script:ConstantsPath + function Get-PfbContextHelpBody { <# Returns the wrapped, indented block for one endpoint, or $null when the scope is @@ -113,14 +126,52 @@ function Get-PfbContextHelpBody { [string]$Indent = ' ' ) + # The method the endpoint key names. The fleet arm needs it because the runtime gate + # (Assert-PfbContextRequired) is narrower for GET than for the write verbs, twice over, + # and both narrowings are measured -- see its header. Emitting the write wording for a GET + # sent the reader to Set-PfbContext for a call that already works with no context, directly + # contradicting the .EXAMPLE in the same file. + $method = ($EndpointKey -split '\s+', 2)[0].ToUpperInvariant() + $lines = switch ($Scope) { 'fleet' { - @( - "Context requirement ($EndpointKey): this cmdlet targets a fleet-scoped resource" - 'and requires a bare fleet context. Set one with' - 'Set-PfbContext -Context -Kind Fleet, or scope a single call with' - 'Invoke-PfbInContext. Get the fleet name from Get-PfbFleet.' - ) + if ($method -eq 'GET') { + if ($EndpointKey -in $script:PfbNameScopedContextRequiredEndpoints) { + # Measured: the unfiltered list is served from the array's locally + # replicated copy, which is list-only -- so it enumerates without a + # context but cannot resolve a name against it. + @( + "Context requirement ($EndpointKey): this cmdlet targets a" + 'fleet-scoped resource, but reads on it are narrower than the' + 'requirement. An unfiltered list works with NO context, served from' + "the array's locally replicated copy. Filtering by name or id needs a" + 'bare fleet context, because that local copy is list-only: set one' + 'with Set-PfbContext -Context -Kind Fleet, or scope a single' + 'call with Invoke-PfbInContext. Get the fleet name from Get-PfbFleet.' + ) + } + else { + # Not on the measured allowlist: a name-scoped context-free read was + # measured to return 200 here, so the module requires no context at all. + @( + "Context requirement ($EndpointKey): this cmdlet targets a" + 'fleet-scoped resource, but reads on it work with NO context --' + 'unfiltered and filtered by name or id alike. None is required. If you' + 'do supply one it must be a bare fleet context (Set-PfbContext' + '-Context -Kind Fleet, or Invoke-PfbInContext for a single' + 'call); an array context is rejected. Get the fleet name from' + 'Get-PfbFleet.' + ) + } + } + else { + @( + "Context requirement ($EndpointKey): this cmdlet targets a fleet-scoped resource" + 'and requires a bare fleet context. Set one with' + 'Set-PfbContext -Context -Kind Fleet, or scope a single call with' + 'Invoke-PfbInContext. Get the fleet name from Get-PfbFleet.' + ) + } } 'unknown' { @( From f07bd61cf45c3d0c9d597b0a7c2eb2e3497f1dca Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 13:24:07 -0700 Subject: [PATCH 47/53] test(context): assert every Public/ endpoint resolves to a capability-map key I4: Assert-PfbContextCapability treats 'no map entry at all' identically to 'entry without context_names', so with a session context set a cmdlet whose endpoint is absent from the map throws a false 'does not support the context_names parameter' before the wire. This feature is what makes a map gap fatal rather than merely uninformative, so it owes the invariant that catches the next one. AST discovery, no hardcoded files or lines, with a mandatory count guard so a scan that matched nothing cannot pass forever. The expected-unresolvable set is an exact-match tripwire in both directions and is deliberately self-retiring: GET /smtp and PATCH /smtp both go away with issue #80, and the failure message says to delete the set rather than update it. Co-Authored-By: Claude Opus 5 --- ...PfbCapabilityMapEndpointCoverage.Tests.ps1 | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 Tests/PfbCapabilityMapEndpointCoverage.Tests.ps1 diff --git a/Tests/PfbCapabilityMapEndpointCoverage.Tests.ps1 b/Tests/PfbCapabilityMapEndpointCoverage.Tests.ps1 new file mode 100644 index 00000000..14b7e2f7 --- /dev/null +++ b/Tests/PfbCapabilityMapEndpointCoverage.Tests.ps1 @@ -0,0 +1,125 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +# Invariant: every endpoint a shipped cmdlet actually calls must resolve to a key in +# Data/PfbCapabilityMap.json. +# +# WHY THIS TEST BELONGS WITH THE FUSION CONTEXT FEATURE. Before it, a missing map entry was +# merely uninformative: Assert-PfbApiCapability reads a missing entry as +# "if (-not $entry) { return }", so the version gate stays silent. Assert-PfbContextCapability +# does NOT -- it treats "no entry at all" identically to "entry without context_names" (a +# deliberate, documented decision), so with any session context set a cmdlet whose endpoint is +# absent from the map throws BEFORE the wire, claiming the endpoint "does not support the +# context_names parameter". That claim is about context support, for an endpoint the module has +# no entry for at all. This feature is what makes a map gap fatal, so this feature owes the +# invariant that catches the next one. +# +# Discovery is by AST parse, not regex over text, and hardcodes no file and no line: a cmdlet +# added ahead of a map regeneration, a renamed upstream path, or a typo must red this test +# without anyone editing it. + +BeforeAll { + Import-Module "$PSScriptRoot/../PureStorageFlashBladePowerShell.psd1" -Force + + $script:PublicRoot = (Resolve-Path "$PSScriptRoot/../Public").Path + + # Every (Method, Endpoint) literal pair passed to Invoke-PfbApiRequest anywhere under + # Public/. Only STRING LITERAL arguments are collected -- a computed endpoint cannot be + # resolved statically, and silently treating one as absent would be a false failure. + $script:CallSites = @() + + foreach ($file in (Get-ChildItem -LiteralPath $script:PublicRoot -Filter '*.ps1' -Recurse -File)) { + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $file.FullName, [ref]$tokens, [ref]$errors) + if (-not $ast) { continue } + + $calls = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.CommandAst] -and + $n.GetCommandName() -eq 'Invoke-PfbApiRequest' + }, $true) + + foreach ($call in $calls) { + $values = @{} + $elements = @($call.CommandElements) + for ($i = 0; $i -lt $elements.Count - 1; $i++) { + $el = $elements[$i] + if ($el -isnot [System.Management.Automation.Language.CommandParameterAst]) { continue } + if ($el.ParameterName -notin @('Method', 'Endpoint')) { continue } + # A parameter written as -Endpoint:'x' carries its value on the parameter node + # itself rather than as the next element; handle both so neither form is missed. + $arg = if ($null -ne $el.Argument) { $el.Argument } else { $elements[$i + 1] } + if ($arg -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + $values[$el.ParameterName] = $arg.Value + } + } + if (-not ($values.ContainsKey('Method') -and $values.ContainsKey('Endpoint'))) { continue } + + $script:CallSites += [PSCustomObject]@{ + File = $file.FullName.Substring($script:PublicRoot.Length).TrimStart('\', '/') + Line = $call.Extent.StartLineNumber + Method = $values['Method'] + Endpoint = $values['Endpoint'] + } + } + } +} + +Describe 'Public cmdlet endpoints resolve to capability-map keys' { + + It 'finds the Invoke-PfbApiRequest call sites (a silent no-match scan would assert nothing)' { + # Mandatory guard: without it, a scanner that matched nothing would pass forever and + # this whole file would be inert. The module resolved 534 distinct (method, endpoint) + # keys at the time of writing, so the floor is set well below that but far above zero -- + # a wildly smaller number means the discovery broke, not that the codebase shrank. + @($script:CallSites).Count | Should -BeGreaterThan 0 + @($script:CallSites | ForEach-Object { '{0} {1}' -f $_.Method, $_.Endpoint } | Sort-Object -Unique).Count | + Should -BeGreaterThan 400 -Because 'the AST scan must reach the whole Public/ surface, not a corner of it' + } + + It 'has exactly two endpoints absent from the capability map, both owned by issue #80' { + InModuleScope PureStorageFlashBladePowerShell -Parameters @{ Sites = $script:CallSites } { + param($Sites) + + $map = Get-PfbCapabilityMap + $mapKeys = @($map.endpoints.PSObject.Properties.Name) + @($mapKeys).Count | Should -BeGreaterThan 0 -Because 'the shipped map must actually load' + + # Keys are built the ONE sanctioned way. Never re-implement this normalisation: a + # copy differing by a leading slash or by case would miss every entry in the map, + # and the miss is silent -- see Get-PfbEndpointKey's header. + $unresolved = @( + $Sites | + ForEach-Object { Get-PfbEndpointKey -Method $_.Method -Endpoint $_.Endpoint } | + Sort-Object -Unique | + Where-Object { $mapKeys -notcontains $_ } + ) + + # EXACT-MATCH expected set, not a pattern and not a subset assertion, so a third gap + # cannot hide behind it. Both entries are known and both retire TOGETHER with + # issue #80 ("Update-PfbSmtp targets a nonexistent 2.x path; consolidate the SMTP + # cmdlets onto /smtp-servers"): + # + # GET /smtp -- Get-PfbSmtp pins -ApiVersionOverride '1.12', so this is a working + # REST 1.x call. Its absence from the map is correct BY + # CONSTRUCTION: the map is generated from the 2.0-2.28 specs, so no + # 1.x path can ever appear in it. This is the module's entire 1.x + # surface -- one cmdlet, not a family -- and #80 replaces it with an + # alias for Get-PfbSmtpServer. + # PATCH /smtp -- Update-PfbSmtp passes no override, so it resolves against the + # negotiated 2.x, where /smtp does not exist. It has never worked: + # the GET was ported to 1.12 and the PATCH was not. #80 retires it. + # + # Deliberately self-retiring: this assertion is a tripwire in BOTH directions. + $expected = @('GET /smtp', 'PATCH /smtp') + + $detail = if ($unresolved.Count) { $unresolved -join ', ' } else { '(none)' } + $unresolved -join ',' | Should -Be ($expected -join ',') -Because ( + "found: $detail. If a NEW endpoint appears here, either the cmdlet's path is " + + 'wrong or the capability map needs regenerating -- do not widen this list to ' + + 'silence it. If the list is now EMPTY, issue #80 has landed and the SMTP ' + + 'cmdlets are consolidated onto /smtp-servers: DELETE the expected set and this ' + + 'comment rather than updating them.') + } + } +} From 18bc0bc01fecf557b9c330645cdb144be9842c49 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Thu, 6 Aug 2026 13:24:08 -0700 Subject: [PATCH 48/53] test(context): pin contextScope provenance in the committed-map assertions M9: the shipped-artifact test pinned scope and the fleet count but not provenance, so a regeneration that kept 'fleet' and flipped 'declared' -> 'default' would pass. The kind-vs-scope gate's whole design rests on that distinction, so pin GET /presets/workload as fleet/declared and pin the distribution of the three evidence-bearing provenances. Co-Authored-By: Claude Opus 5 --- Tests/Build-PfbCapabilityMap.Tests.ps1 | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Tests/Build-PfbCapabilityMap.Tests.ps1 b/Tests/Build-PfbCapabilityMap.Tests.ps1 index 7bcceff6..56d4fdc5 100644 --- a/Tests/Build-PfbCapabilityMap.Tests.ps1 +++ b/Tests/Build-PfbCapabilityMap.Tests.ps1 @@ -799,6 +799,23 @@ Describe 'Build-PfbCapabilityMap: contextScope' -Skip:($PSVersionTable.PSVersion $committed.endpoints.'GET /presets/workload'.contextScope.scope | Should -Be 'fleet' @($committed.endpoints.PSObject.Properties | Where-Object { $_.Value.contextScope.scope -eq 'fleet' }).Count | Should -Be 8 + + # PROVENANCE IS PINNED TOO, not just scope. The whole kind-vs-scope gate design rests on + # the provenance distinction -- 'unknown' suppresses the gate and nothing else does, + # because 'default' means "no override AND upstream did not flag x-pure-incomplete-gre" + # and is therefore evidence rather than absent metadata (see Get-PfbEndpointContextScope). + # Without this, a regeneration that kept scope 'fleet' but flipped 'declared' -> + # 'default' would pass, silently discarding upstream's own statement. + $committed.endpoints.'GET /presets/workload'.contextScope.provenance | Should -Be 'declared' + + # And the overall distribution, so a regeneration that ERODES the evidence-bearing set + # reds a test instead of passing quietly. 'default' is deliberately not pinned: it is + # the ~600-entry residue and it moves whenever the specs are refreshed. + $byProvenance = @($committed.endpoints.PSObject.Properties) | + Group-Object { $_.Value.contextScope.provenance } -AsHashTable -AsString + @($byProvenance['declared']).Count | Should -Be 5 -Because 'the five override-bearing preset operations' + @($byProvenance['live-tested']).Count | Should -Be 4 -Because 'the curated live-tested readings' + @($byProvenance['unknown']).Count | Should -Be 19 -Because 'upstream-flagged-incomplete with no curated value -- the only provenance that suppresses the kind gate' } It 'applies the curated value for a flagged, curated endpoint' { From 1a95fa18d06a303692a1c0d581a5fe371b2d372b Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 12 Aug 2026 18:55:22 -0700 Subject: [PATCH 49/53] test(context): retire the /smtp allowlist now that issue #80 has landed The endpoint-coverage invariant shipped with a deliberately self-retiring allowlist of GET /smtp and PATCH /smtp, asserted by exact equality so that it would fail in both directions: on a new map gap, and on the SMTP consolidation landing. #80 landed in PR #108 and the tripwire fired as designed. Get-PfbSmtp and Update-PfbSmtp are gone, consolidated onto /smtp-servers, and the module now carries no REST 1.x surface at all -- zero -ApiVersionOverride sites. The expected set is therefore deleted rather than updated, per its own failure message. Tests/RemovedCmdlets.Tests.ps1 (from #108) guards the removal itself. Every endpoint literal under Public/ now resolves to a capability-map key with no exceptions. --- ...PfbCapabilityMapEndpointCoverage.Tests.ps1 | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/Tests/PfbCapabilityMapEndpointCoverage.Tests.ps1 b/Tests/PfbCapabilityMapEndpointCoverage.Tests.ps1 index 14b7e2f7..cb32d822 100644 --- a/Tests/PfbCapabilityMapEndpointCoverage.Tests.ps1 +++ b/Tests/PfbCapabilityMapEndpointCoverage.Tests.ps1 @@ -95,31 +95,24 @@ Describe 'Public cmdlet endpoints resolve to capability-map keys' { Where-Object { $mapKeys -notcontains $_ } ) - # EXACT-MATCH expected set, not a pattern and not a subset assertion, so a third gap - # cannot hide behind it. Both entries are known and both retire TOGETHER with - # issue #80 ("Update-PfbSmtp targets a nonexistent 2.x path; consolidate the SMTP - # cmdlets onto /smtp-servers"): + # EXACT-MATCH expected set, empty: EVERY endpoint literal in Public/ must resolve to + # a capability-map key. No allowlist, so a gap cannot hide behind one. # - # GET /smtp -- Get-PfbSmtp pins -ApiVersionOverride '1.12', so this is a working - # REST 1.x call. Its absence from the map is correct BY - # CONSTRUCTION: the map is generated from the 2.0-2.28 specs, so no - # 1.x path can ever appear in it. This is the module's entire 1.x - # surface -- one cmdlet, not a family -- and #80 replaces it with an - # alias for Get-PfbSmtpServer. - # PATCH /smtp -- Update-PfbSmtp passes no override, so it resolves against the - # negotiated 2.x, where /smtp does not exist. It has never worked: - # the GET was ported to 1.12 and the PATCH was not. #80 retires it. + # This started life carrying two entries, GET /smtp and PATCH /smtp, as a deliberately + # self-retiring tripwire against issue #80. #80 landed in PR #108 and the tripwire + # fired exactly as designed: Get-PfbSmtp and Update-PfbSmtp are gone, consolidated + # onto /smtp-servers, and the module now has no REST 1.x surface at all + # (zero -ApiVersionOverride sites). The set was DELETED rather than updated, per its + # own instruction. Tests/RemovedCmdlets.Tests.ps1 guards the removal itself. # - # Deliberately self-retiring: this assertion is a tripwire in BOTH directions. - $expected = @('GET /smtp', 'PATCH /smtp') + # Do not reintroduce an allowlist to silence a failure here. A new entry means either + # a cmdlet's endpoint path is wrong or the capability map needs regenerating. + $expected = @() $detail = if ($unresolved.Count) { $unresolved -join ', ' } else { '(none)' } $unresolved -join ',' | Should -Be ($expected -join ',') -Because ( - "found: $detail. If a NEW endpoint appears here, either the cmdlet's path is " + - 'wrong or the capability map needs regenerating -- do not widen this list to ' + - 'silence it. If the list is now EMPTY, issue #80 has landed and the SMTP ' + - 'cmdlets are consolidated onto /smtp-servers: DELETE the expected set and this ' + - 'comment rather than updating them.') + "found: $detail. Either the cmdlet's endpoint path is wrong or the capability " + + 'map needs regenerating -- do not widen this list to silence it.') } } } From 5e5d1e743c6676f7c74fec9c232d569a8bb93137 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 12 Aug 2026 19:03:56 -0700 Subject: [PATCH 50/53] chore(reports): regenerate report artifacts after rebase onto main Phase 1 modifies 28 Public/ cmdlets and touches tools/Build-PfbCapabilityMap.ps1 but changes nothing under Reports/, so the committed artifacts went stale the moment the branch rebased onto main. Regenerated with the full chain in order: capability map, field-cmdlet map, response-shape map, drift report. Data/PfbCapabilityMap.json came back byte-identical to the committed copy, confirming the generator change does only what it claims. Data/PfbResponseShapeMap.json is unchanged. Two deltas, both expected and attributable: * Drift report -- Resolve-PfbAdminLocality now appears alongside Get-PfbAdmin for GET /admins, because it probes is_local there; and Update-PfbArray's recorded paramBlockLine moves 29 -> 37, because the generated context help block sits above the param block. * Field-cmdlet map -- "typed but unresolved wire name" rises 40 -> 51. The 11 additions are the context parameters on Connect-PfbArray, Invoke-PfbInContext and Set-PfbContext. These configure session state rather than a request body, so having no wire field is correct rather than a gap. Regenerated, never hand-edited: a prior integration produced a silent auto-merge of Reports/PfbFieldCmdletMap.json matching no generator run on either side. --- Reports/PfbApiDriftReport.json | 83 ++++++++++++++++---------------- Reports/PfbApiDriftReport.md | 4 +- Reports/PfbFieldCmdletMap.json | 44 +++++++++++++++++ Reports/PfbFieldCmdletMapping.md | 13 ++++- 4 files changed, 100 insertions(+), 44 deletions(-) diff --git a/Reports/PfbApiDriftReport.json b/Reports/PfbApiDriftReport.json index c9b11f4b..f87b99a3 100644 --- a/Reports/PfbApiDriftReport.json +++ b/Reports/PfbApiDriftReport.json @@ -2166,7 +2166,8 @@ { "endpoint": "GET /admins", "cmdlets": [ - "Get-PfbAdmin" + "Get-PfbAdmin", + "Resolve-PfbAdminLocality" ], "missingQueryParameters": [ "allow_errors", @@ -5982,7 +5983,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Array/Update-PfbArray.ps1", - "paramBlockLine": 29, + "paramBlockLine": 37, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -5999,7 +6000,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Array/Update-PfbArray.ps1", - "paramBlockLine": 29, + "paramBlockLine": 37, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -6016,7 +6017,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Array/Update-PfbArray.ps1", - "paramBlockLine": 29, + "paramBlockLine": 37, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -6033,7 +6034,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Array/Update-PfbArray.ps1", - "paramBlockLine": 29, + "paramBlockLine": 37, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -6050,7 +6051,7 @@ "enumStatus": "not-found-in-resource", "target": { "file": "Public/Array/Update-PfbArray.ps1", - "paramBlockLine": 29, + "paramBlockLine": 37, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -6067,7 +6068,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Array/Update-PfbArray.ps1", - "paramBlockLine": 29, + "paramBlockLine": 37, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -6084,7 +6085,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Array/Update-PfbArray.ps1", - "paramBlockLine": 29, + "paramBlockLine": 37, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -6101,7 +6102,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Array/Update-PfbArray.ps1", - "paramBlockLine": 29, + "paramBlockLine": 37, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -11271,7 +11272,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Monitoring/New-PfbLogTargetFileSystem.ps1", - "paramBlockLine": 36, + "paramBlockLine": 44, "payloadVariable": "body", "assignmentStyle": "unknown", "hasAttributes": true @@ -11288,7 +11289,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Monitoring/New-PfbLogTargetFileSystem.ps1", - "paramBlockLine": 36, + "paramBlockLine": 44, "payloadVariable": "body", "assignmentStyle": "unknown", "hasAttributes": true @@ -11305,7 +11306,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Monitoring/New-PfbLogTargetFileSystem.ps1", - "paramBlockLine": 36, + "paramBlockLine": 44, "payloadVariable": "body", "assignmentStyle": "unknown", "hasAttributes": true @@ -11322,7 +11323,7 @@ "enumStatus": "not-found-in-resource", "target": { "file": "Public/Monitoring/New-PfbLogTargetFileSystem.ps1", - "paramBlockLine": 36, + "paramBlockLine": 44, "payloadVariable": "body", "assignmentStyle": "unknown", "hasAttributes": true @@ -12456,7 +12457,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12473,7 +12474,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12490,7 +12491,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12507,7 +12508,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12524,7 +12525,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12541,7 +12542,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12562,7 +12563,7 @@ "enumStatus": "matched", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12579,7 +12580,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12596,7 +12597,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12613,7 +12614,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12630,7 +12631,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12647,7 +12648,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -12680,7 +12681,7 @@ "enumStatus": "matched", "target": { "file": "Public/Presets/New-PfbPresetWorkload.ps1", - "paramBlockLine": 44, + "paramBlockLine": 51, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -13245,7 +13246,7 @@ "parameter": "Name", "surface": "AttributesOnly", "file": "Public/Policy/New-PfbSshCaPolicy.ps1", - "line": 30 + "line": 38 } ], "escapeHatchOnly": [ @@ -13886,7 +13887,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -13903,7 +13904,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -13920,7 +13921,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -13937,7 +13938,7 @@ "enumStatus": "not-found-in-resource", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -13954,7 +13955,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -13971,7 +13972,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -13988,7 +13989,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -14009,7 +14010,7 @@ "enumStatus": "matched", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -14026,7 +14027,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -14043,7 +14044,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -14060,7 +14061,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -14077,7 +14078,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -14094,7 +14095,7 @@ "enumStatus": "no-spec-enum-found", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true @@ -14127,7 +14128,7 @@ "enumStatus": "matched", "target": { "file": "Public/Presets/Set-PfbPresetWorkload.ps1", - "paramBlockLine": 35, + "paramBlockLine": 42, "payloadVariable": "Attributes", "assignmentStyle": "attributesOnly", "hasAttributes": true diff --git a/Reports/PfbApiDriftReport.md b/Reports/PfbApiDriftReport.md index c940944c..f81b4538 100644 --- a/Reports/PfbApiDriftReport.md +++ b/Reports/PfbApiDriftReport.md @@ -160,7 +160,7 @@ Endpoints an existing cmdlet already calls, where the capability map knows of a | `DELETE /worm-data-policies` | Remove-PfbWormPolicy | context_names | | `high` | | | `GET /active-directory` | Get-PfbActiveDirectory | ids, limit, sort | | `high` | | | `GET /active-directory/test` | Test-PfbActiveDirectory | allow_errors, context_names, filter, limit, sort | | `high` | | -| `GET /admins` | Get-PfbAdmin | allow_errors, context_names, expose_api_token | | `high` | | +| `GET /admins` | Get-PfbAdmin, Resolve-PfbAdminLocality | allow_errors, context_names, expose_api_token | | `high` | | | `GET /admins/api-tokens` | Get-PfbApiToken | allow_errors, context_names | | `high` | | | `GET /admins/cache` | Get-PfbAdminCache | allow_errors, context_names, refresh | | `high` | | | `GET /admins/management-access-policies` | Get-PfbAdminManagementAccessPolicy | allow_errors, context_names, sort | | `high` | POST/PATCH/DELETE return 403 regardless of account; not an implementation bug | @@ -607,7 +607,7 @@ Per the decision-6 procedure above: open each parameter at its `file:line` and f | `POST /smb-client-policies` | `-Enabled` | AttributesOnly | `Public/Policy/New-PfbSmbClientPolicy.ps1:36` | body reachable only via -Attributes; lists reflect typed-parameter coverage, not wire reachability | | `POST /smb-share-policies` | `-Enabled` | AttributesOnly | `Public/Policy/New-PfbSmbSharePolicy.ps1:36` | body reachable only via -Attributes; lists reflect typed-parameter coverage, not wire reachability | | `POST /snmp-managers` | `-Name` | AttributesOnly | `Public/Monitoring/New-PfbSnmpManager.ps1:33` | body reachable only via -Attributes; lists reflect typed-parameter coverage, not wire reachability | -| `POST /ssh-certificate-authority-policies` | `-Name` | AttributesOnly | `Public/Policy/New-PfbSshCaPolicy.ps1:30` | body reachable only via -Attributes; lists reflect typed-parameter coverage, not wire reachability | +| `POST /ssh-certificate-authority-policies` | `-Name` | AttributesOnly | `Public/Policy/New-PfbSshCaPolicy.ps1:38` | body reachable only via -Attributes; lists reflect typed-parameter coverage, not wire reachability | | `POST /storage-class-tiering-policies` | `-Name` | AttributesOnly | `Public/Policy/New-PfbStorageClassTieringPolicy.ps1:30` | body reachable only via -Attributes; lists reflect typed-parameter coverage, not wire reachability | | `POST /syslog-servers` | `-Name` | AttributesOnly | `Public/Monitoring/New-PfbSyslogServer.ps1:32` | body reachable only via -Attributes; lists reflect typed-parameter coverage, not wire reachability | | `POST /targets` | `-Name` | AttributesOnly | `Public/Replication/New-PfbTarget.ps1:31` | body reachable only via -Attributes; lists reflect typed-parameter coverage, not wire reachability | diff --git a/Reports/PfbFieldCmdletMap.json b/Reports/PfbFieldCmdletMap.json index f3df4555..2590f2db 100644 --- a/Reports/PfbFieldCmdletMap.json +++ b/Reports/PfbFieldCmdletMap.json @@ -20481,6 +20481,10 @@ } ], "typedUnresolved": [ + { + "cmdlet": "Connect-PfbArray", + "parameter": "AllArrays" + }, { "cmdlet": "Connect-PfbArray", "parameter": "ApiToken" @@ -20493,6 +20497,10 @@ "cmdlet": "Connect-PfbArray", "parameter": "ClientId" }, + { + "cmdlet": "Connect-PfbArray", + "parameter": "Context" + }, { "cmdlet": "Connect-PfbArray", "parameter": "Credential" @@ -20517,6 +20525,10 @@ "cmdlet": "Connect-PfbArray", "parameter": "KeyId" }, + { + "cmdlet": "Connect-PfbArray", + "parameter": "Kind" + }, { "cmdlet": "Connect-PfbArray", "parameter": "Password" @@ -20561,6 +20573,22 @@ "cmdlet": "Get-PfbUserGroupQuotaPolicy", "parameter": "Name" }, + { + "cmdlet": "Invoke-PfbInContext", + "parameter": "AllArrays" + }, + { + "cmdlet": "Invoke-PfbInContext", + "parameter": "Context" + }, + { + "cmdlet": "Invoke-PfbInContext", + "parameter": "Kind" + }, + { + "cmdlet": "Invoke-PfbInContext", + "parameter": "ScriptBlock" + }, { "cmdlet": "New-PfbDataEvictionPolicy", "parameter": "Disabled" @@ -20621,6 +20649,22 @@ "cmdlet": "Remove-PfbServer", "parameter": "Eradicate" }, + { + "cmdlet": "Set-PfbContext", + "parameter": "AllArrays" + }, + { + "cmdlet": "Set-PfbContext", + "parameter": "AllowErrors" + }, + { + "cmdlet": "Set-PfbContext", + "parameter": "Context" + }, + { + "cmdlet": "Set-PfbContext", + "parameter": "Kind" + }, { "cmdlet": "Set-PfbCredential", "parameter": "Credential" diff --git a/Reports/PfbFieldCmdletMapping.md b/Reports/PfbFieldCmdletMapping.md index cab4f222..52f480b3 100644 --- a/Reports/PfbFieldCmdletMapping.md +++ b/Reports/PfbFieldCmdletMapping.md @@ -121,17 +121,20 @@ Reporting only -- no `Public/` cmdlet is edited by this script. Every `matched` - `Update-PfbSmbSharePolicy -Enabled` - `Update-PfbUserGroupQuotaPolicy -Enabled` -## Typed but unresolved wire name (needs manual inspection): 40 +## Typed but unresolved wire name (needs manual inspection): 51 +- `Connect-PfbArray -AllArrays` - `Connect-PfbArray -ApiToken` - `Connect-PfbArray -ApiVersion` - `Connect-PfbArray -ClientId` +- `Connect-PfbArray -Context` - `Connect-PfbArray -Credential` - `Connect-PfbArray -Endpoint` - `Connect-PfbArray -HttpTimeout` - `Connect-PfbArray -IgnoreCertificateError` - `Connect-PfbArray -Issuer` - `Connect-PfbArray -KeyId` +- `Connect-PfbArray -Kind` - `Connect-PfbArray -Password` - `Connect-PfbArray -PrivateKeyFile` - `Connect-PfbArray -PrivateKeyPassword` @@ -143,6 +146,10 @@ Reporting only -- no `Public/` cmdlet is edited by this script. Every `matched` - `Get-PfbHardwareTemperature -Limit` - `Get-PfbUserGroupQuotaPolicy -Id` - `Get-PfbUserGroupQuotaPolicy -Name` +- `Invoke-PfbInContext -AllArrays` +- `Invoke-PfbInContext -Context` +- `Invoke-PfbInContext -Kind` +- `Invoke-PfbInContext -ScriptBlock` - `New-PfbDataEvictionPolicy -Disabled` - `New-PfbFileSystemSnapshot -SourceName` - `New-PfbLocalGroupMember -Member` @@ -158,6 +165,10 @@ Reporting only -- no `Public/` cmdlet is edited by this script. Every `matched` - `Remove-PfbQuotaUser -UserName` - `Remove-PfbRealm -Eradicate` - `Remove-PfbServer -Eradicate` +- `Set-PfbContext -AllArrays` +- `Set-PfbContext -AllowErrors` +- `Set-PfbContext -Context` +- `Set-PfbContext -Kind` - `Set-PfbCredential -Credential` - `Set-PfbWorkloadTag -Tags` - `Test-PfbConnection -Endpoint` From dfaad902762cb1f1b0b132fcb4ae61922dd3e06c Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Wed, 12 Aug 2026 20:56:53 -0700 Subject: [PATCH 51/53] fix(tools): adopt the target file's line ending when splicing the help block The generated context help block was assembled with hardcoded CRLF and spliced into whatever the cmdlet file already contained. The repo commits LF blobs, so a Windows checkout has CRLF and matches, while a Linux/macOS checkout has LF and mismatches on every file. The generator therefore reported all 24 files as changed and rewrote them, and the idempotency test failed with "Expected 0, but got 24" on ubuntu-latest and macos-latest while both windows-latest legs passed. Set-PfbContextHelpBlock now detects the newline from the content it is splicing into and normalises the block to match, rather than trusting the platform: the checked-out file decides this, not [Environment]::NewLine. The two separators the function inserts itself follow the same value. The strip pattern already handled both, so it is unchanged. Behaviour-neutral on Windows -- a -WhatIf run against the committed tree still reports zero changes -- so no Public/ file needed regenerating. Adds two tests that build LF and CRLF fixtures explicitly via WriteAllText and run the generator against each. The pre-existing idempotency test could never catch this on a single runner, because it only ever sees the local platform's convention; these fail on every platform when the splice stops adopting the target's line ending. Verified by mutation: pinning the newline back to CRLF reds the LF case and leaves the CRLF case green, which is exactly the asymmetry that kept Windows CI green while Linux and macOS failed. --- Tests/Update-PfbContextHelp.Tests.ps1 | 70 +++++++++++++++++++++++++++ tools/Update-PfbContextHelp.ps1 | 21 ++++++-- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/Tests/Update-PfbContextHelp.Tests.ps1 b/Tests/Update-PfbContextHelp.Tests.ps1 index cb737020..134d1f01 100644 --- a/Tests/Update-PfbContextHelp.Tests.ps1 +++ b/Tests/Update-PfbContextHelp.Tests.ps1 @@ -178,6 +178,76 @@ Describe 'Update-PfbContextHelp' { (Get-Content $arrayScoped -Raw).Contains('