Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 277 additions & 0 deletions Tests/Build-PfbCapabilityMap.ContextScopeDrift.Tests.ps1
Original file line number Diff line number Diff line change
@@ -1,6 +1,81 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' }

BeforeAll {
# Defined in the top-level BeforeAll, NOT at file scope, so both the real-spec Describe and
# the synthetic-input Describe below can call it. A function defined at file scope is created
# during Pester's DISCOVERY pass, in a scope the run pass cannot see -- every It then fails
# with "The term ... is not recognized", which is what happened when this was written that
# way. Pester also permits only ONE BeforeAll per block, so this cannot be split out into its
# own adjacent block either.
#
# It takes the collected declarations rather than reading specs itself, deliberately: a
# comparison that owns its own I/O can only ever be tested against whatever is on disk, and
# nothing on disk exhibits any of these conditions today (issue #112).
function Get-PfbContextScopeVersionFinding {
[CmdletBinding()]
param(
# endpoint -> @{ <version> = '<TOKEN>|<TOKEN>' }, tokens upper-cased and sorted, and
# ONLY for versions that actually declare an override.
[Parameter(Mandatory)] [hashtable]$Declared,
# endpoint -> the versions whose spec contains the operation at all. Required to tell
# a WITHDRAWN override from an endpoint that simply left the API.
[Parameter(Mandatory)] [hashtable]$Seen,
# Every scanned version, ascending. Rank comes from position here, never from a string
# or numeric comparison of the version itself: '2.10' sorts before '2.2' as a string,
# and 2.20 truncates to 2.2 as a number.
[Parameter(Mandatory)] [string[]]$VersionOrder
)

$rank = @{}
for ($i = 0; $i -lt $VersionOrder.Count; $i++) { $rank[$VersionOrder[$i]] = $i }

foreach ($endpoint in ($Declared.Keys | Sort-Object)) {
$byVersion = $Declared[$endpoint]
$declaringVersions = @($byVersion.Keys |
Where-Object { $rank.ContainsKey($_) } | Sort-Object { $rank[$_] })
if ($declaringVersions.Count -eq 0) { continue }

$previousVersion = $null
foreach ($version in $declaringVersions) {
$current = $byVersion[$version]
if ($null -ne $previousVersion) {
$previous = $byVersion[$previousVersion]
if ($current -ne $previous) {
# A GAIN (the earlier tokens all survive) and a SWAP need different
# responses, so they are different findings rather than one "it changed".
# A gain means the map's single scalar scope cannot hold what the endpoint
# now declares; a swap means the scope itself is version-dependent.
$previousTokens = @($previous -split '\|')
$currentTokens = @($current -split '\|')
$lost = @($previousTokens | Where-Object { $currentTokens -notcontains $_ })
[pscustomobject]@{
Endpoint = $endpoint
Finding = if ($lost.Count -eq 0) { 'KindGained' } else { 'ValueChange' }
Detail = "declared '$previous' at $previousVersion, '$current' at $version"
}
}
}
$previousVersion = $version
}

# Withdrawal: the operation is still in a LATER spec but no longer carries an
# override. Bounded to versions after the first declaration -- before it, an absent
# override is simply an endpoint upstream had not annotated yet, the normal state.
$firstDeclared = $declaringVersions[0]
foreach ($version in @($Seen[$endpoint])) {
if (-not $rank.ContainsKey($version)) { continue }
if ($rank[$version] -le $rank[$firstDeclared]) { continue }
if ($byVersion.ContainsKey($version)) { continue }
[pscustomobject]@{
Endpoint = $endpoint
Finding = 'Withdrawn'
Detail = ("declared '{0}' at {1}, but {2} carries no override while the " +
'operation still exists') -f $byVersion[$firstDeclared], $firstDeclared, $version
}
}
}
}

$script:repoRoot = Split-Path -Parent $PSScriptRoot
. (Join-Path $script:repoRoot 'tools/lib/PfbSpecTools.ps1')

Expand Down Expand Up @@ -146,3 +221,205 @@ Describe 'contextScope drift against the real specs and the committed map' -Skip
}
}
}

Describe 'Get-PfbContextScopeVersionFinding, against synthetic declarations' {
# No spec I/O and no ConvertFrom-Json -Depth, so this runs on BOTH editions and needs no
# spec cache. It exists because the real-spec Describe below is VACUOUSLY green: every
# endpoint that declares an override declares it in exactly one version (fb2.28), so
# nothing on disk can demonstrate that these findings ever fire. A tripwire nobody has
# seen trip is indistinguishable from a tripwire that cannot.

BeforeAll {
$script:order = @('2.26', '2.27', '2.28')
}

It 'reports nothing when an endpoint declares the same scope in every version' {
$findings = @(Get-PfbContextScopeVersionFinding `
-Declared @{ 'GET /x' = @{ '2.26' = 'FLEET'; '2.27' = 'FLEET'; '2.28' = 'FLEET' } } `
-Seen @{ 'GET /x' = @('2.26', '2.27', '2.28') } `
-VersionOrder $script:order)
$findings.Count | Should -Be 0
}

It 'reports ValueChange when a declared scope is SWAPPED for a different one' {
$findings = @(Get-PfbContextScopeVersionFinding `
-Declared @{ 'GET /x' = @{ '2.26' = 'FLEET'; '2.28' = 'ARRAY' } } `
-Seen @{ 'GET /x' = @('2.26', '2.27', '2.28') } `
-VersionOrder $script:order)
@($findings | Where-Object Finding -EQ 'ValueChange').Count | Should -Be 1
($findings | Where-Object Finding -EQ 'ValueChange').Detail | Should -Match "'FLEET' at 2.26"
}

It 'reports KindGained when an endpoint gains an ADDITIONAL context kind' {
# The case issue #112 has to survive: a future REALM or TOPOLOGY_GROUP domain appearing
# alongside FLEET on an endpoint that already declared FLEET. The map holds one scalar
# scope per endpoint, so it cannot record both, and the generator's FLEET-wins rule
# would silently pick one.
$findings = @(Get-PfbContextScopeVersionFinding `
-Declared @{ 'GET /x' = @{ '2.27' = 'FLEET'; '2.28' = 'FLEET|TOPOLOGY_GROUP' } } `
-Seen @{ 'GET /x' = @('2.27', '2.28') } `
-VersionOrder $script:order)
@($findings | Where-Object Finding -EQ 'KindGained').Count | Should -Be 1
@($findings | Where-Object Finding -EQ 'ValueChange').Count | Should -Be 0
}

It 'reports ValueChange, not KindGained, when a kind is gained AND another is lost' {
$findings = @(Get-PfbContextScopeVersionFinding `
-Declared @{ 'GET /x' = @{ '2.27' = 'ARRAY|FLEET'; '2.28' = 'FLEET|REALM' } } `
-Seen @{ 'GET /x' = @('2.27', '2.28') } `
-VersionOrder $script:order)
@($findings | Where-Object Finding -EQ 'ValueChange').Count | Should -Be 1
@($findings | Where-Object Finding -EQ 'KindGained').Count | Should -Be 0
}

It 'reports Withdrawn when a later spec still has the operation but drops its override' {
$findings = @(Get-PfbContextScopeVersionFinding `
-Declared @{ 'GET /x' = @{ '2.26' = 'FLEET' } } `
-Seen @{ 'GET /x' = @('2.26', '2.27', '2.28') } `
-VersionOrder $script:order)
@($findings | Where-Object Finding -EQ 'Withdrawn').Count | Should -Be 2
}

It 'reports NOTHING when the operation itself leaves the API' {
# An endpoint that is gone carries no override because it carries nothing. Treating that
# as a withdrawal would make every removed operation a permanent false finding.
$findings = @(Get-PfbContextScopeVersionFinding `
-Declared @{ 'GET /x' = @{ '2.26' = 'FLEET' } } `
-Seen @{ 'GET /x' = @('2.26') } `
-VersionOrder $script:order)
$findings.Count | Should -Be 0
}

It 'ignores an override declared before the first version in scope rather than mis-ranking it' {
# A version absent from -VersionOrder has no rank. Comparing it would silently order it
# first, which is how a stale or hand-built table produces a confident wrong answer.
$findings = @(Get-PfbContextScopeVersionFinding `
-Declared @{ 'GET /x' = @{ '2.20' = 'ARRAY'; '2.28' = 'FLEET' } } `
-Seen @{ 'GET /x' = @('2.28') } `
-VersionOrder $script:order)
$findings.Count | Should -Be 0
}
}

Describe 'contextScope stability across every cached REST version (issue #112)' -Skip:($PSVersionTable.PSVersion.Major -lt 7) {
# Issue #112 records a deliberate omission: contextScope is ONE value per endpoint,
# computed last-seen-wins across the whole spec set, with no version dimension. The
# justification is that a resource does not migrate between the fleet database and an
# individual array, so scope is a property of the resource rather than of the API version.
#
# This Describe is the tripwire on that justification. If it ever fails, the premise has
# stopped holding: the map cannot represent what upstream is now declaring, the runtime
# gates in Private/Assert-PfbContextSupported.ps1 would apply the newest reading to an array
# running an older REST version, and NO other test can see it -- the drift suite compares
# the shipped artifact against generator output, and both sides would agree.
#
# Reopen #112 when it fires. Do not silence it by widening the expectation.

BeforeAll {
$script:specsDirectory = Join-Path $script:repoRoot 'tools/specs'
$script:hasSpecs = Test-Path (Join-Path $script:specsDirectory 'fb2.28.json')

if ($script:hasSpecs) {
# Ordered by (major, minor) parsed from the FILENAME. Never `foreach ($v in 2.20)`:
# that literal is the number 2.2, which silently opens fb2.2.json -- a real file --
# so a cross-version claim ends up covering a version it never read.
$ordered = @(Get-ChildItem -Path $script:specsDirectory -Filter 'fb*.json' -File |
Sort-Object {
$parts = ($_.BaseName -replace '^fb', '') -split '\.'
([int]$parts[0] * 1000) + [int]$parts[1]
})

$script:versionOrder = @($ordered | ForEach-Object { $_.BaseName -replace '^fb', '' })
$script:declaredByEndpoint = @{}
$script:seenByEndpoint = @{}
$script:domainTokens = [System.Collections.Generic.HashSet[string]]::new()

foreach ($file in $ordered) {
$version = $file.BaseName -replace '^fb', ''
$spec = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json -Depth 64
foreach ($record in (Get-PfbSpecContextScope -Spec $spec)) {
if (-not $script:seenByEndpoint.ContainsKey($record.Endpoint)) {
$script:seenByEndpoint[$record.Endpoint] = [System.Collections.Generic.List[string]]::new()
}
$script:seenByEndpoint[$record.Endpoint].Add($version)

$tokens = @($record.DomainsOverride | ForEach-Object { "$_".ToUpperInvariant() } | Sort-Object)
if ($tokens.Count -eq 0) { continue }
foreach ($token in $tokens) { [void]$script:domainTokens.Add($token) }
if (-not $script:declaredByEndpoint.ContainsKey($record.Endpoint)) {
$script:declaredByEndpoint[$record.Endpoint] = @{}
}
$script:declaredByEndpoint[$record.Endpoint][$version] = ($tokens -join '|')
}
}

$script:versionFindings = @(Get-PfbContextScopeVersionFinding `
-Declared $script:declaredByEndpoint `
-Seen $script:seenByEndpoint `
-VersionOrder $script:versionOrder)
}
}

It 'POSITIVE CONTROL: the walk actually saw the API surface and found declarations' {
# Without this, a broken walk -- one that stops resolving $ref or allOf, as the 2.17
# restructuring made easy -- returns nothing, and every assertion below passes as
# "no changes found" having examined nothing. An empty result here is INCONCLUSIVE,
# never negative.
#
# Bounds, not exact counts: a new spec version must not red this file.
if (-not $script:hasSpecs) { Set-ItResult -Skipped -Because 'tools/specs cache absent'; return }
$script:versionOrder.Count | Should -BeGreaterThan 20 -Because 'the cache should hold the whole 2.x series'
$script:seenByEndpoint.Count | Should -BeGreaterThan 500 -Because 'the walk must see the API surface, not a fragment of it'
$script:declaredByEndpoint.Count | Should -BeGreaterThan 0 -Because 'at least one endpoint declares a domains override; zero means the walk broke'
}

It 'no endpoint declares a DIFFERENT context scope in a later version' {
# The literal question #112 asks. A swap means scope is version-dependent after all, so
# the map needs a version dimension on contextScope and #112 should be reopened.
if (-not $script:hasSpecs) { Set-ItResult -Skipped -Because 'tools/specs cache absent'; return }
$swaps = @($script:versionFindings | Where-Object Finding -EQ 'ValueChange')
$swaps.Count | Should -Be 0 -Because (
"contextScope has no version dimension (issue #112), so a scope that changes between " +
"REST versions cannot be represented and the newest reading would be applied to older " +
"arrays: $(($swaps | ForEach-Object { "$($_.Endpoint) $($_.Detail)" }) -join '; ')")
}

It 'no endpoint GAINS an additional context kind in a later version' {
# e.g. an endpoint declaring FLEET today also declaring TOPOLOGY_GROUP or REALM
# tomorrow. The map holds one scalar scope per endpoint, and the generator's
# FLEET-wins rule would quietly pick one of the two rather than record both --
# so the kind-vs-scope gate would reject a context the array accepts.
if (-not $script:hasSpecs) { Set-ItResult -Skipped -Because 'tools/specs cache absent'; return }
$gains = @($script:versionFindings | Where-Object Finding -EQ 'KindGained')
$gains.Count | Should -Be 0 -Because (
"contextScope records ONE scope per endpoint, so an endpoint addressable by more than " +
"one kind cannot be represented (issue #112): " +
"$(($gains | ForEach-Object { "$($_.Endpoint) $($_.Detail)" }) -join '; ')")
}

It 'no endpoint WITHDRAWS an override it declared in an earlier version' {
# Last-seen-wins would revert the endpoint to the 'array' default (or to 'unknown' if it
# is flagged incomplete-gre), silently changing what the runtime gates enforce, with the
# committed map and the generator still in perfect agreement.
if (-not $script:hasSpecs) { Set-ItResult -Skipped -Because 'tools/specs cache absent'; return }
$withdrawn = @($script:versionFindings | Where-Object Finding -EQ 'Withdrawn')
$withdrawn.Count | Should -Be 0 -Because (
"last-seen-wins would revert these endpoints to the generator's default scope: " +
"$(($withdrawn | ForEach-Object { "$($_.Endpoint) $($_.Detail)" }) -join '; ')")
}

It 'the declared domain vocabulary is still exactly ARRAY and FLEET' {
# The NEW-KIND tripwire, and the one most likely to fire first: it needs only a single
# version to declare an unfamiliar token, not two versions to disagree.
#
# Build-PfbCapabilityMap.ps1 interprets FLEET and ARRAY and sends anything else to
# scope/provenance 'unknown', which SUPPRESSES the kind-vs-scope gate entirely
# (Private/Assert-PfbContextSupported.ps1) -- so a new domain would not fail loudly, it
# would quietly stop validating. Set-PfbContext -Kind already accepts TopologyGroup with
# no spec vocabulary behind it, so the client half of that gap exists today.
if (-not $script:hasSpecs) { Set-ItResult -Skipped -Because 'tools/specs cache absent'; return }
@($script:domainTokens | Sort-Object) | Should -Be @('ARRAY', 'FLEET') -Because (
'a new domain token means the generator gained an uninterpreted scope and the ' +
'kind-vs-scope gate silently stopped validating those endpoints (issue #112)')
}
}
26 changes: 25 additions & 1 deletion Tests/coverage-baseline.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@
# to catch.
'Empty-pipeline guard coverage'
'Update-PfbEmptyPipelineGuards - real tree'
# Issue #112 contextScope version tripwire. PS7-gated (it parses every cached spec
# with ConvertFrom-Json -Depth), so it belongs to this block alone. It is exactly
# what this list exists for: it is VACUOUSLY green today -- every endpoint declaring
# a domains override declares it in one version only -- so a skip ceiling could never
# tell it from a block that stopped running. Its synthetic sibling is listed under
# both editions.
'contextScope stability across every cached REST version (issue #112)'
'Get-PfbContextScopeVersionFinding, against synthetic declarations'
)
}
winps51 = @{
Expand Down Expand Up @@ -111,7 +119,18 @@
# spec cache and so runs on both editions -- it adds no skips.
#
# 268 keeps the same +16 headroom over measured.
MaxSkipped = 268
#
# RAISED 268 -> 273 for the issue #112 contextScope version tripwire. It adds one
# PS7-gated Describe of 5 It blocks to
# Build-PfbCapabilityMap.ContextScopeDrift.Tests.ps1, taking that file's 5.1 skips from
# 8 to 13 (measured locally under both editions: pwsh 7 20 passed / 0 skipped, 5.1
# 7 passed / 13 skipped, container ok on both). They RUN on 7, so this is the PS7 gate
# working, not lost coverage. The file's other new Describe -- the synthetic-input tests
# for Get-PfbContextScopeVersionFinding -- is deliberately UNGATED: it needs neither the
# spec cache nor ConvertFrom-Json -Depth, so it executes on both legs and adds no skips.
#
# 273 keeps the same +16 headroom over an expected measured 257.
MaxSkipped = 273
# Only the ungated blocks are required here. The six spec-cache blocks above are
# PS7-gated by design, so requiring them on 5.1 would be a permanent false red.
RequiredDescribes = @(
Expand Down Expand Up @@ -140,6 +159,11 @@
# red. Measured 7 passed / 15 passed on 5.1 for the two files.
'Empty-pipeline guard coverage'
'Update-PfbEmptyPipelineGuards - real tree'
# Issue #112, synthetic half only. It reads no spec and uses no PS7-only syntax, so
# it runs on this leg (measured 7 passed on 5.1) and is the ONLY thing proving the
# comparison can produce a finding at all -- the real-spec half it guards is
# vacuously green and is PS7-gated, so it appears under pwsh7 alone.
'Get-PfbContextScopeVersionFinding, against synthetic declarations'
)
}
}
Loading