Files
jiapuapp/tests/join-application-openapi-contract.ps1
T
2026-07-23 17:21:33 +08:00

680 lines
49 KiB
PowerShell

$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$issues = New-Object System.Collections.Generic.List[string]
$operations = @(
[pscustomobject]@{ Path = '/genealogy/app/genealogies/public'; Method = 'get'; Id = 'appSearchPublicGenealogies'; Responses = @('200','400','401','429','500'); SuccessRef = '#/components/schemas/RGenealogySearchCursorPage'; Parameters = @('header:clientid','query:cursor','query:keyword','query:limit') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/{genealogyId}/join-applies'; Method = 'post'; Id = 'appCreateGenealogyJoinApplication'; Responses = @('200','400','401','403','404','409','422','429','500'); SuccessRef = '#/components/schemas/RGenealogyJoinApplicationReceipt'; Parameters = @('header:Idempotency-Key','header:clientid','path:genealogyId') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/join-apply-requests/{requestKey}'; Method = 'get'; Id = 'appGetGenealogyJoinApplicationRequest'; Responses = @('200','400','401','404','429','500'); SuccessRef = '#/components/schemas/RGenealogyJoinApplicationRequestStatus'; Parameters = @('header:clientid','path:requestKey') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/join-applies/mine'; Method = 'get'; Id = 'appListMyGenealogyJoinApplications'; Responses = @('200','400','401','429','500'); SuccessRef = '#/components/schemas/RMyGenealogyJoinApplicationCursorPage'; Parameters = @('header:clientid','query:cursor','query:limit') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/join-applies/{applyId}'; Method = 'delete'; Id = 'appWithdrawGenealogyJoinApplication'; Responses = @('200','400','401','404','409','429','500'); SuccessRef = '#/components/schemas/RWithdrawnGenealogyJoinApplicationReceipt'; Parameters = @('header:clientid','path:applyId') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/{genealogyId}/join-applies/pending'; Method = 'get'; Id = 'appListPendingGenealogyJoinApplications'; Responses = @('200','400','401','403','404','429','500'); SuccessRef = '#/components/schemas/RPendingGenealogyJoinApplicationCursorPage'; Parameters = @('header:clientid','path:genealogyId','query:cursor','query:limit') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit'; Method = 'put'; Id = 'appReviewGenealogyJoinApplication'; Responses = @('200','400','401','403','404','409','422','429','500'); SuccessRef = '#/components/schemas/RReviewedGenealogyJoinApplicationReceipt'; Parameters = @('header:clientid','path:applyId','path:genealogyId') }
)
function Add-Issue([string]$Message) { $script:issues.Add($Message) }
function Test-JsonBoolean([object]$Value, [bool]$Expected) {
return $Value -is [System.Boolean] -and $Value -eq $Expected
}
function Test-IsJsonArray([object]$Value) {
return $null -ne $Value -and $Value.GetType().IsArray
}
function Get-LocalComponentName([string]$Ref, [string]$Section, [string]$Label) {
$pattern = '^#/components/' + [regex]::Escape($Section) + '/(?<name>[^/]+)$'
$match = [regex]::Match($Ref, $pattern)
if (-not $match.Success) {
Add-Issue "$Label must use an exact local #/components/$Section/... ref; actual: $Ref"
return ''
}
return $match.Groups['name'].Value
}
function Test-IsNonNullable([object]$Schema) {
if (-not $Schema) { return $false }
$nullable = $Schema.PSObject.Properties['nullable']
return -not $nullable -or (Test-JsonBoolean $nullable.Value $false)
}
function Assert-NoConflictingSchemaKeywords([object]$Schema, [string]$Label, [string[]]$Allowed = @()) {
if (-not $Schema) { return }
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) {
if ($keyword -notin $Allowed -and $Schema.PSObject.Properties[$keyword]) {
Add-Issue "$Label must not define conflicting schema keyword: $keyword"
}
}
}
function Assert-AllowedSchemaKeywords([object]$Schema, [string]$Label, [string[]]$Allowed) {
if (-not $Schema) { return }
$annotations = @('title', 'description', 'example', 'examples', 'deprecated')
foreach ($property in @($Schema.PSObject.Properties)) {
if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { continue }
Add-Issue "$Label contains an unowned schema keyword: $($property.Name)"
}
}
function Test-IsPureComponentRef([object]$Value, [string]$ExpectedRef, [string]$Section, [string]$Label) {
if (-not $Value) { return $false }
$properties = @($Value.PSObject.Properties.Name)
$actualRef = [string]$Value.'$ref'
if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
Add-Issue "$Label must be the sole exact local ref $ExpectedRef; actual: $actualRef"
return $false
}
[void](Get-LocalComponentName $actualRef $Section $Label)
return $true
}
function Test-IsExactReferenceObjectRef([object]$Value, [string]$ExpectedRef, [string]$Section, [string]$Label) {
if (-not $Value) { return $false }
$actualRef = [string]$Value.'$ref'
$semanticSiblings = @($Value.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($actualRef -cne $ExpectedRef -or $semanticSiblings.Count -gt 0) {
Add-Issue "$Label must use exact local ref $ExpectedRef with only harmless summary/description siblings; actual: $actualRef; semantic siblings: $($semanticSiblings -join ',')"
return $false
}
[void](Get-LocalComponentName $actualRef $Section $Label)
return $true
}
function Test-IsPureSchemaRef([object]$Schema, [string]$ExpectedRef, [string]$Label) {
return Test-IsPureComponentRef $Schema $ExpectedRef 'schemas' $Label
}
function Get-Schema([string]$Name) {
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) { Add-Issue "missing schema owner: $Name"; return $null }
return $property.Value
}
function Get-Operation([string]$Path, [string]$Method) {
$pathProperty = $document.paths.PSObject.Properties[$Path]
$operation = if ($pathProperty) { $pathProperty.Value.PSObject.Properties[$Method] } else { $null }
if (-not $operation) { Add-Issue "missing $($Method.ToUpperInvariant()) $Path"; return $null }
return $operation.Value
}
function Get-Parameters([string]$Path, [object]$Operation) {
if (-not $Operation) { return @() }
$pathItem = $document.paths.PSObject.Properties[$Path].Value
$byIdentity = [ordered]@{}
foreach ($scope in @(@($pathItem.parameters), @($Operation.parameters))) {
$scopeIdentities = @{}
foreach ($parameter in $scope) {
if (-not $parameter) { continue }
$resolved = $parameter
if ($parameter.'$ref') {
$name = Get-LocalComponentName ([string]$parameter.'$ref') 'parameters' "$Path parameter"
if (-not $name) { continue }
$parameterRefSiblings = @($parameter.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($parameterRefSiblings.Count -gt 0) {
Add-Issue "$Path parameter ref contains semantic sibling keywords: $($parameterRefSiblings -join ',')"
}
$owner = $document.components.parameters.PSObject.Properties[$name]
if (-not $owner) { Add-Issue "missing parameter owner: $name"; continue }
$resolved = $owner.Value
}
$identity = "$($resolved.in):$($resolved.name)".ToLowerInvariant()
if ($scopeIdentities.ContainsKey($identity)) { Add-Issue "$Path contains duplicate parameter in one scope: $identity"; continue }
$scopeIdentities[$identity] = $true
$byIdentity[$identity] = $resolved
}
}
return @($byIdentity.Values)
}
function Get-Parameter([string]$Path, [object]$Operation, [string]$In, [string]$Name) {
$matches = @(Get-Parameters $Path $Operation | Where-Object { $_.in -eq $In -and $_.name -eq $Name })
if ($matches.Count -ne 1) { Add-Issue "$($Operation.operationId) must define exactly one ${In}:${Name}"; return $null }
return $matches[0]
}
function Assert-ClosedObject([object]$Schema, [string]$Name, [string[]]$Fields, [string[]]$Required) {
if (-not $Schema) { return }
$actualFields = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
$actualRequired = @($Schema.required | Sort-Object)
if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or
-not (Test-JsonBoolean $Schema.additionalProperties $false) -or
($actualFields -join ',') -ne ((@($Fields | Sort-Object)) -join ',') -or
($actualRequired -join ',') -ne ((@($Required | Sort-Object)) -join ',')) {
Add-Issue "$Name must be closed; fields=$($Fields -join ','); required=$($Required -join ',')"
}
Assert-NoConflictingSchemaKeywords $Schema $Name
Assert-AllowedSchemaKeywords $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable')
}
function Assert-StringOwner([string]$Name, [int]$Min, [int]$Max, [string]$Pattern = '') {
$schema = Get-Schema $Name
if (-not $schema) { return }
if ($schema.type -ne 'string' -or -not (Test-IsNonNullable $schema) -or [int]$schema.minLength -ne $Min -or [int]$schema.maxLength -ne $Max) {
Add-Issue "$Name must be a non-null string length $Min..$Max"
}
if ($Pattern -and [string]$schema.pattern -ne $Pattern) { Add-Issue "$Name pattern drifted" }
Assert-NoConflictingSchemaKeywords $schema $Name
Assert-AllowedSchemaKeywords $schema $Name @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
}
function Assert-PropertyRef([object]$Schema, [string]$Name, [string]$Field, [string]$Ref) {
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property) { Add-Issue "$Name.$Field must use $Ref"; return }
[void](Test-IsPureSchemaRef $property.Value $Ref "$Name.$Field")
}
function Assert-Union([object]$Schema, [string]$Name, [string]$Discriminator, [hashtable]$Mapping) {
if (-not $Schema) { return }
$expectedRefs = @($Mapping.Values | Sort-Object -Unique)
$actualRefs = @($Schema.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object)
$actualKeys = @($Schema.discriminator.mapping.PSObject.Properties.Name | Sort-Object)
$expectedKeys = @($Mapping.Keys | Sort-Object)
if ($Schema.discriminator.propertyName -ne $Discriminator) { Add-Issue "$Name discriminator property must be $Discriminator" }
if (($actualRefs -join ',') -ne ($expectedRefs -join ',') -or ($actualKeys -join ',') -ne ($expectedKeys -join ',')) { Add-Issue "$Name oneOf/mapping branches drifted" }
foreach ($key in $expectedKeys) {
if ([string]$Schema.discriminator.mapping.$key -ne [string]$Mapping[$key]) { Add-Issue "$Name mapping $key drifted" }
}
foreach ($branch in @($Schema.oneOf)) {
$branchRef = [string]$branch.'$ref'
if ($branchRef -notin $expectedRefs) {
Add-Issue "$Name contains an unexpected oneOf branch: $branchRef"
} else {
[void](Test-IsPureSchemaRef $branch $branchRef "$Name oneOf branch")
}
}
Assert-AllowedSchemaKeywords $Schema $Name @('oneOf', 'discriminator')
if ($Schema.discriminator -and
(@($Schema.discriminator.PSObject.Properties.Name | Sort-Object) -join ',') -cne 'mapping,propertyName') {
Add-Issue "$Name discriminator must contain only mapping and propertyName"
}
}
function Assert-SingleEnum([object]$Schema, [string]$Name, [string]$Field, [string]$Value) {
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property -or $property.Value.type -ne 'string' -or -not (Test-IsNonNullable $property.Value) -or -not (Test-IsJsonArray $property.Value.enum) -or @($property.Value.enum).Count -ne 1 -or $property.Value.enum[0] -ne $Value) { Add-Issue "$Name.$Field must be the single non-null value $Value" }
if ($property) { Assert-NoConflictingSchemaKeywords $property.Value "$Name.$Field" @('enum') }
if ($property) { Assert-AllowedSchemaKeywords $property.Value "$Name.$Field" @('type', 'enum', 'nullable') }
}
function Assert-RequestBody([object]$Operation, [string]$Label, [string]$Ref) {
if (-not $Operation) { return }
$content = $Operation.requestBody.content
$media = if ($content) { $content.PSObject.Properties['application/json'] } else { $null }
if (-not (Test-JsonBoolean $Operation.requestBody.required $true) -or -not $media -or $content.PSObject.Properties.Count -ne 1) {
Add-Issue "$Label must require only application/json with $Ref"
} elseif ($media) {
[void](Test-IsPureSchemaRef $media.Value.schema $Ref "$Label request schema")
}
}
function Assert-PathId([string]$Path, [object]$Operation, [string]$Name, [string]$Ref) {
if (-not $Operation) { return }
$parameter = Get-Parameter $Path $Operation 'path' $Name
if ($parameter) {
if (-not (Test-JsonBoolean $parameter.required $true)) { Add-Issue "$($Operation.operationId) $Name must be required" }
[void](Test-IsPureSchemaRef $parameter.schema $Ref "$($Operation.operationId) $Name")
}
}
function Assert-Envelope([string]$Name, [string]$DataRef) {
$schema = Get-Schema $Name
Assert-ClosedObject $schema $Name @('code','data') @('code','data')
if (-not $schema) { return }
if ($schema.properties.code.type -ne 'integer' -or -not (Test-IsNonNullable $schema.properties.code) -or -not (Test-IsJsonArray $schema.properties.code.enum) -or @($schema.properties.code.enum).Count -ne 1 -or $schema.properties.code.enum[0] -ne 200) { Add-Issue "$Name.code must be non-null integer enum [200]" }
Assert-NoConflictingSchemaKeywords $schema.properties.code "$Name.code" @('enum')
Assert-AllowedSchemaKeywords $schema.properties.code "$Name.code" @('type', 'enum', 'nullable')
Assert-PropertyRef $schema $Name 'data' $DataRef
}
function Assert-ErrorEnvelope([string]$Name, [int]$Status, [string[]]$BusinessCodes, [bool]$HasCurrent = $false) {
$schema = Get-Schema $Name
$fields = if ($HasCurrent) { @('code','businessCode','current') } else { @('code','businessCode') }
Assert-ClosedObject $schema $Name $fields $fields
if (-not $schema) { return }
if ($schema.properties.code.type -ne 'integer' -or -not (Test-IsNonNullable $schema.properties.code) -or -not (Test-IsJsonArray $schema.properties.code.enum) -or @($schema.properties.code.enum).Count -ne 1 -or $schema.properties.code.enum[0] -ne $Status) { Add-Issue "$Name.code must be non-null integer enum [$Status]" }
$actualCodes = @($schema.properties.businessCode.enum | Sort-Object)
if ($schema.properties.businessCode.type -ne 'string' -or -not (Test-IsNonNullable $schema.properties.businessCode) -or -not (Test-IsJsonArray $schema.properties.businessCode.enum) -or ($actualCodes -join ',') -ne ((@($BusinessCodes | Sort-Object)) -join ',')) { Add-Issue "$Name.businessCode enum drifted" }
Assert-NoConflictingSchemaKeywords $schema.properties.code "$Name.code" @('enum')
Assert-NoConflictingSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('enum')
Assert-AllowedSchemaKeywords $schema.properties.code "$Name.code" @('type', 'enum', 'nullable')
Assert-AllowedSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('type', 'enum', 'nullable')
if ($HasCurrent) { Assert-PropertyRef $schema $Name 'current' '#/components/schemas/GenealogyJoinApplicationCurrentState' }
}
function Resolve-Response([object]$Response, [string]$Label = 'response') {
if (-not $Response) { return $null }
if ($Response.'$ref') {
$name = Get-LocalComponentName ([string]$Response.'$ref') 'responses' $Label
if (-not $name) { return $null }
$responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($responseRefSiblings.Count -gt 0) {
Add-Issue "$Label ref contains semantic sibling keywords: $($responseRefSiblings -join ',')"
}
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "$Label references missing response owner: $name"
return $null
}
return $owner.Value
}
return $Response
}
function Get-ExpectedResponseSchemaRef([string]$OperationId, [string]$Status, [string]$SuccessRef) {
if ($Status -eq '200') { return $SuccessRef }
if ($OperationId -eq 'appGetGenealogyJoinApplicationRequest' -and $Status -eq '404') { return '#/components/schemas/RGenealogyJoinApplicationRequestNotAvailable' }
if ($Status -eq '409') {
if ($OperationId -eq 'appCreateGenealogyJoinApplication') { return '#/components/schemas/RJoinApplicationKeyConflict' }
if ($OperationId -in @('appWithdrawGenealogyJoinApplication','appReviewGenealogyJoinApplication')) { return '#/components/schemas/RJoinApplicationStateConflict' }
}
return @{
'400' = '#/components/schemas/RJoinApplicationBadRequest'
'401' = '#/components/schemas/RJoinApplicationUnauthorized'
'403' = '#/components/schemas/RJoinApplicationForbidden'
'404' = '#/components/schemas/RJoinApplicationNotFound'
'422' = '#/components/schemas/RJoinApplicationUnprocessable'
'429' = '#/components/schemas/RJoinApplicationRateLimited'
'500' = '#/components/schemas/RJoinApplicationServerError'
}[$Status]
}
function Assert-ResponseContract([object]$Operation, [string]$Label, [string[]]$Statuses, [string]$SuccessRef) {
if (-not $Operation) { return }
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
if (($actual -join ',') -ne ((@($Statuses | Sort-Object)) -join ',')) { Add-Issue "$Label response set drifted: $($actual -join ',')" }
foreach ($status in $actual) {
if ($status -eq 'default' -or $status -match '^3') { Add-Issue "$Label must not use default or 3xx responses" }
$response = Resolve-Response $Operation.responses.PSObject.Properties[$status].Value "$Label $status response"
if (-not $response) { continue }
$media = if ($response.content) { $response.content.PSObject.Properties['application/json'] } else { $null }
if (-not $media -or $response.content.PSObject.Properties.Count -ne 1) { Add-Issue "$Label $status must use only application/json" }
$expectedRef = Get-ExpectedResponseSchemaRef $Operation.operationId $status $SuccessRef
$actualRef = if ($media) { [string]$media.Value.schema.'$ref' } else { '' }
if ($expectedRef -and $media) { [void](Test-IsPureSchemaRef $media.Value.schema $expectedRef "$Label $status response schema") }
$cache = if ($response.headers) { $response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $cache) {
Add-Issue "$Label $status must use the shared PrivateNoStore header owner"
} else {
[void](Test-IsExactReferenceObjectRef $cache.Value '#/components/headers/PrivateNoStore' 'headers' "$Label $status Cache-Control")
}
if ($status -eq '429') {
$retry = if ($response.headers) { $response.headers.PSObject.Properties['Retry-After'] } else { $null }
if (-not $retry) {
Add-Issue "$Label 429 must use the shared RetryAfter header owner"
} else {
[void](Test-IsExactReferenceObjectRef $retry.Value '#/components/headers/RetryAfter' 'headers' "$Label 429 Retry-After")
}
}
}
}
function Assert-SecurityAndClient([string]$Path, [object]$Operation, [string]$Label) {
if (-not $Operation) { return }
$security = @($Operation.security)
if ($security.Count -ne 1 -or $security[0].PSObject.Properties.Count -ne 1 -or $security[0].PSObject.Properties.Name -notcontains 'SaToken') {
Add-Issue "$Label must require SaToken without an anonymous alternative"
}
$client = Get-Parameter $Path $Operation 'header' 'clientid'
if ($client -and (-not (Test-JsonBoolean $client.required $true) -or $client.schema.type -ne 'string' -or -not (Test-IsNonNullable $client.schema) -or [int]$client.schema.minLength -ne 1 -or [int]$client.schema.maxLength -ne 128)) {
Add-Issue "$Label clientid must be a required non-null string bounded to 1..128"
}
if ($client) { Assert-NoConflictingSchemaKeywords $client.schema "$Label clientid" }
if ($client) { Assert-AllowedSchemaKeywords $client.schema "$Label clientid" @('type', 'minLength', 'maxLength', 'nullable') }
}
$parityOutput = @(& node (Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js') 2>&1)
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
Add-Issue "protected JSON/YAML parity failed: $($parityOutput -join ' | ')"
}
$resolvedOperations = @{}
foreach ($entry in $operations) {
$operation = Get-Operation $entry.Path $entry.Method
$resolvedOperations[$entry.Id] = $operation
if ($operation -and [string]$operation.operationId -ne $entry.Id) { Add-Issue "$($entry.Method.ToUpperInvariant()) $($entry.Path) operationId must be $($entry.Id)" }
Assert-SecurityAndClient $entry.Path $operation "$($entry.Method.ToUpperInvariant()) $($entry.Path)"
Assert-ResponseContract $operation "$($entry.Method.ToUpperInvariant()) $($entry.Path)" $entry.Responses $entry.SuccessRef
if ($operation) {
$actualParameters = @(Get-Parameters $entry.Path $operation | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
$expectedParameters = @($entry.Parameters | Sort-Object)
if (($actualParameters -join ',') -ne ($expectedParameters -join ',')) { Add-Issue "$($entry.Id) parameters must be exactly $($expectedParameters -join ','); actual=$($actualParameters -join ',')" }
}
}
foreach ($entry in $operations) {
$duplicates = @($document.paths.PSObject.Properties | ForEach-Object { $_.Value.PSObject.Properties | Where-Object { $_.Name -in @('get','post','put','delete','patch') -and $_.Value.operationId -eq $entry.Id } })
if ($duplicates.Count -ne 1) { Add-Issue "operationId must be globally unique: $($entry.Id)" }
}
$headerOwners = if ($document.components.PSObject.Properties['headers']) { $document.components.headers } else { $null }
$privateHeaderProperty = if ($headerOwners) { $headerOwners.PSObject.Properties['PrivateNoStore'] } else { $null }
$privateHeader = if ($privateHeaderProperty) { $privateHeaderProperty.Value } else { $null }
if (-not $privateHeader -or $privateHeader.schema.type -ne 'string' -or -not (Test-IsNonNullable $privateHeader.schema) -or -not (Test-IsJsonArray $privateHeader.schema.enum) -or @($privateHeader.schema.enum).Count -ne 1 -or $privateHeader.schema.enum[0] -ne 'private, no-store') { Add-Issue 'PrivateNoStore header must be a fixed non-null string enum [private, no-store]' }
if ($privateHeader) { Assert-NoConflictingSchemaKeywords $privateHeader.schema 'PrivateNoStore header schema' @('enum') }
if ($privateHeader) { Assert-AllowedSchemaKeywords $privateHeader.schema 'PrivateNoStore header schema' @('type', 'enum', 'nullable') }
$retryHeaderProperty = if ($headerOwners) { $headerOwners.PSObject.Properties['RetryAfter'] } else { $null }
$retryHeader = if ($retryHeaderProperty) { $retryHeaderProperty.Value } else { $null }
if (-not $retryHeader -or $retryHeader.schema.type -ne 'integer' -or -not (Test-IsNonNullable $retryHeader.schema) -or [int]$retryHeader.schema.minimum -ne 1 -or [int]$retryHeader.schema.maximum -ne 120) { Add-Issue 'RetryAfter header must be a non-null integer 1..120 seconds' }
if ($retryHeader) { Assert-NoConflictingSchemaKeywords $retryHeader.schema 'RetryAfter header schema' }
if ($retryHeader) { Assert-AllowedSchemaKeywords $retryHeader.schema 'RetryAfter header schema' @('type', 'minimum', 'maximum', 'nullable') }
Assert-StringOwner 'GenealogyId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
Assert-StringOwner 'JoinApplicationId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
Assert-StringOwner 'JoinApplicationCursor' 1 512 '^[A-Za-z0-9_-]+$'
Assert-StringOwner 'GenealogyJoinApplicationRequestKey' 40 61 '^gja\.[0-9]{13}\.[A-Za-z0-9_-]{22,43}$'
$requestKeyOwner = Get-Schema 'GenealogyJoinApplicationRequestKey'
if ($requestKeyOwner) {
foreach ($extension in @(
@{ Name = 'x-issued-at-source'; Value = 'KEY_EPOCH_MILLISECONDS' },
@{ Name = 'x-accept-window-seconds'; Value = 600 },
@{ Name = 'x-max-future-skew-seconds'; Value = 300 },
@{ Name = 'x-resolve-sla-seconds'; Value = 120 },
@{ Name = 'x-random-min-bits'; Value = 128 }
)) {
if ($requestKeyOwner.PSObject.Properties[$extension.Name].Value -ne $extension.Value) { Add-Issue "GenealogyJoinApplicationRequestKey $($extension.Name) must be $($extension.Value)" }
}
}
$applyBody = Get-Schema 'AppGenealogyJoinApplicationBody'
Assert-ClosedObject $applyBody 'AppGenealogyJoinApplicationBody' @('applicantName','relationDesc','applyReason') @('applicantName','relationDesc')
foreach ($field in @(
@{ Name = 'applicantName'; Max = 50 },
@{ Name = 'relationDesc'; Max = 100 },
@{ Name = 'applyReason'; Max = 500 }
)) {
$property = if ($applyBody) { $applyBody.properties.PSObject.Properties[$field.Name].Value } else { $null }
if ($property -and ($property.type -ne 'string' -or [int]$property.minLength -ne 1 -or [int]$property.maxLength -ne $field.Max -or $property.'x-text-normalizer' -ne 'JOIN_APPLICATION_TEXT_V1')) {
Add-Issue "AppGenealogyJoinApplicationBody.$($field.Name) must use JOIN_APPLICATION_TEXT_V1 and length 1..$($field.Max)"
}
}
$reviewBody = Get-Schema 'AppGenealogyJoinReviewBody'
if ($reviewBody) {
Assert-Union $reviewBody 'AppGenealogyJoinReviewBody' 'decision' @{
APPROVE = '#/components/schemas/AppGenealogyJoinApproveBody'
REJECT = '#/components/schemas/AppGenealogyJoinRejectBody'
}
if ($reviewBody.PSObject.Properties['additionalProperties']) { Add-Issue 'review union wrapper must leave closure to its two concrete branches under OpenAPI 3.0.1' }
}
$approveBody = Get-Schema 'AppGenealogyJoinApproveBody'
$rejectBody = Get-Schema 'AppGenealogyJoinRejectBody'
Assert-ClosedObject $approveBody 'AppGenealogyJoinApproveBody' @('decision') @('decision')
Assert-ClosedObject $rejectBody 'AppGenealogyJoinRejectBody' @('decision','rejectionReason') @('decision','rejectionReason')
Assert-SingleEnum $approveBody 'AppGenealogyJoinApproveBody' 'decision' 'APPROVE'
Assert-SingleEnum $rejectBody 'AppGenealogyJoinRejectBody' 'decision' 'REJECT'
if ($rejectBody -and ($rejectBody.properties.rejectionReason.type -ne 'string' -or [int]$rejectBody.properties.rejectionReason.minLength -ne 1 -or [int]$rejectBody.properties.rejectionReason.maxLength -ne 500 -or $rejectBody.properties.rejectionReason.'x-text-normalizer' -ne 'JOIN_APPLICATION_TEXT_V1')) {
Add-Issue 'rejectionReason must use JOIN_APPLICATION_TEXT_V1 and length 1..500'
}
$searchItem = Get-Schema 'AppGenealogySearchItem'
Assert-ClosedObject $searchItem 'AppGenealogySearchItem' @('genealogyId','genealogyName','surname','regionName','ancestralHall','parentGenealogyName','branchName','certificationLabel','memberCount','updatedAt','viewerState') @('genealogyId','genealogyName','surname','regionName','viewerState')
Assert-PropertyRef $searchItem 'AppGenealogySearchItem' 'genealogyId' '#/components/schemas/GenealogyId'
if ($searchItem) {
$expectedViewerStates = @('NOT_JOINED','MEMBER','PENDING','REJECTED','FORMER_MEMBER','OWNER')
if ((@($searchItem.properties.viewerState.enum | Sort-Object) -join ',') -ne ((@($expectedViewerStates | Sort-Object)) -join ',')) { Add-Issue 'AppGenealogySearchItem.viewerState enum drifted' }
foreach ($forbidden in @('phone','managerName','managerPhone','userId','inviterUserId','auditUserId','canApply')) {
if ($searchItem.properties.PSObject.Properties[$forbidden]) { Add-Issue "search projection leaks or duplicates state: $forbidden" }
}
}
$pendingItem = Get-Schema 'PendingGenealogyJoinApplicationItem'
Assert-ClosedObject $pendingItem 'PendingGenealogyJoinApplicationItem' @('applyId','applicantName','relationDesc','applyReason','submittedAt') @('applyId','applicantName','relationDesc','submittedAt')
Assert-PropertyRef $pendingItem 'PendingGenealogyJoinApplicationItem' 'applyId' '#/components/schemas/JoinApplicationId'
if ($pendingItem -and $pendingItem.properties.submittedAt.format -ne 'date-time') { Add-Issue 'pending submittedAt must be RFC3339 date-time' }
$mineUnion = Get-Schema 'MyGenealogyJoinApplicationItem'
$mineMapping = @{
PENDING = '#/components/schemas/MyPendingGenealogyJoinApplication'
APPROVED = '#/components/schemas/MyApprovedGenealogyJoinApplication'
REJECTED = '#/components/schemas/MyRejectedGenealogyJoinApplication'
WITHDRAWN = '#/components/schemas/MyWithdrawnGenealogyJoinApplication'
}
Assert-Union $mineUnion 'MyGenealogyJoinApplicationItem' 'status' $mineMapping
$mineBranches = @(
@{ Name = 'MyPendingGenealogyJoinApplication'; Status = 'PENDING'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt') },
@{ Name = 'MyApprovedGenealogyJoinApplication'; Status = 'APPROVED'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt','resolvedAt'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt','resolvedAt') },
@{ Name = 'MyRejectedGenealogyJoinApplication'; Status = 'REJECTED'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt','resolvedAt','rejectionReason'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt','resolvedAt','rejectionReason') },
@{ Name = 'MyWithdrawnGenealogyJoinApplication'; Status = 'WITHDRAWN'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt','resolvedAt'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt','resolvedAt') }
)
foreach ($branch in $mineBranches) {
$name = $branch.Name
$schema = Get-Schema $name
if ($schema) {
Assert-ClosedObject $schema $name $branch.Fields $branch.Required
Assert-SingleEnum $schema $name 'status' $branch.Status
foreach ($field in @('applyId','genealogyId')) { Assert-PropertyRef $schema $name $field $(if ($field -eq 'applyId') { '#/components/schemas/JoinApplicationId' } else { '#/components/schemas/GenealogyId' }) }
if ($schema.properties.submittedAt.format -ne 'date-time') { Add-Issue "$name.submittedAt must be date-time" }
if ($schema.properties.PSObject.Properties['resolvedAt'] -and $schema.properties.resolvedAt.format -ne 'date-time') { Add-Issue "$name.resolvedAt must be date-time" }
}
}
$rejectedMine = Get-Schema 'MyRejectedGenealogyJoinApplication'
if ($rejectedMine -and @($rejectedMine.required) -notcontains 'rejectionReason') { Add-Issue 'REJECTED mine branch must require applicant-visible rejectionReason' }
foreach ($pageName in @('GenealogySearchCursorPage','MyGenealogyJoinApplicationCursorPage','PendingGenealogyJoinApplicationCursorPage')) {
$page = Get-Schema $pageName
Assert-ClosedObject $page $pageName @('items','nextCursor') @('items')
if ($page -and ($page.properties.PSObject.Properties['total'] -or $page.properties.PSObject.Properties['pageNum'])) { Add-Issue "$pageName must not expose total/pageNum" }
}
$pageItemRefs = @{
GenealogySearchCursorPage = '#/components/schemas/AppGenealogySearchItem'
MyGenealogyJoinApplicationCursorPage = '#/components/schemas/MyGenealogyJoinApplicationItem'
PendingGenealogyJoinApplicationCursorPage = '#/components/schemas/PendingGenealogyJoinApplicationItem'
}
foreach ($pageName in $pageItemRefs.Keys) {
$page = $document.components.schemas.PSObject.Properties[$pageName].Value
if (-not $page) { continue }
if ($page.properties.items.type -ne 'array' -or [string]$page.properties.items.items.'$ref' -ne $pageItemRefs[$pageName] -or [int]$page.properties.items.maxItems -ne 50) { Add-Issue "$pageName.items must be a max-50 array of $($pageItemRefs[$pageName])" }
Assert-PropertyRef $page $pageName 'nextCursor' '#/components/schemas/JoinApplicationCursor'
}
$receipt = Get-Schema 'GenealogyJoinApplicationReceipt'
Assert-ClosedObject $receipt 'GenealogyJoinApplicationReceipt' @('applyId','genealogyId','status','submittedAt') @('applyId','genealogyId','status','submittedAt')
Assert-PropertyRef $receipt 'GenealogyJoinApplicationReceipt' 'applyId' '#/components/schemas/JoinApplicationId'
Assert-PropertyRef $receipt 'GenealogyJoinApplicationReceipt' 'genealogyId' '#/components/schemas/GenealogyId'
Assert-SingleEnum $receipt 'GenealogyJoinApplicationReceipt' 'status' 'PENDING'
if ($receipt -and $receipt.properties.submittedAt.format -ne 'date-time') { Add-Issue 'GenealogyJoinApplicationReceipt.submittedAt must be date-time' }
Assert-Envelope 'RGenealogySearchCursorPage' '#/components/schemas/GenealogySearchCursorPage'
Assert-Envelope 'RGenealogyJoinApplicationReceipt' '#/components/schemas/GenealogyJoinApplicationReceipt'
Assert-Envelope 'RGenealogyJoinApplicationRequestStatus' '#/components/schemas/GenealogyJoinApplicationRequestStatus'
Assert-Envelope 'RMyGenealogyJoinApplicationCursorPage' '#/components/schemas/MyGenealogyJoinApplicationCursorPage'
Assert-Envelope 'RWithdrawnGenealogyJoinApplicationReceipt' '#/components/schemas/WithdrawnGenealogyJoinApplicationReceipt'
Assert-Envelope 'RReviewedGenealogyJoinApplicationReceipt' '#/components/schemas/ReviewedGenealogyJoinApplicationReceipt'
Assert-Envelope 'RPendingGenealogyJoinApplicationCursorPage' '#/components/schemas/PendingGenealogyJoinApplicationCursorPage'
$reviewedUnion = Get-Schema 'ReviewedGenealogyJoinApplicationReceipt'
Assert-Union $reviewedUnion 'ReviewedGenealogyJoinApplicationReceipt' 'status' @{
APPROVED = '#/components/schemas/ApprovedGenealogyJoinApplicationReceipt'
REJECTED = '#/components/schemas/RejectedGenealogyJoinApplicationReceipt'
}
foreach ($branch in @(
@{ Name='ApprovedGenealogyJoinApplicationReceipt'; Status='APPROVED'; Fields=@('applyId','status','resolvedAt'); Required=@('applyId','status','resolvedAt') },
@{ Name='RejectedGenealogyJoinApplicationReceipt'; Status='REJECTED'; Fields=@('applyId','status','resolvedAt','rejectionReason'); Required=@('applyId','status','resolvedAt','rejectionReason') },
@{ Name='WithdrawnGenealogyJoinApplicationReceipt'; Status='WITHDRAWN'; Fields=@('applyId','status','resolvedAt'); Required=@('applyId','status','resolvedAt') }
)) {
$schema = Get-Schema $branch.Name
Assert-ClosedObject $schema $branch.Name $branch.Fields $branch.Required
Assert-SingleEnum $schema $branch.Name 'status' $branch.Status
Assert-PropertyRef $schema $branch.Name 'applyId' '#/components/schemas/JoinApplicationId'
if ($schema -and $schema.properties.resolvedAt.format -ne 'date-time') { Add-Issue "$($branch.Name).resolvedAt must be date-time" }
}
$currentState = Get-Schema 'GenealogyJoinApplicationCurrentState'
Assert-ClosedObject $currentState 'GenealogyJoinApplicationCurrentState' @('applyId','status','resolvedAt') @('applyId','status')
Assert-PropertyRef $currentState 'GenealogyJoinApplicationCurrentState' 'applyId' '#/components/schemas/JoinApplicationId'
if ($currentState) {
$states = @('PENDING','APPROVED','REJECTED','WITHDRAWN')
if ((@($currentState.properties.status.enum | Sort-Object) -join ',') -ne ((@($states | Sort-Object)) -join ',')) { Add-Issue 'current-state status enum drifted' }
}
Assert-ErrorEnvelope 'RJoinApplicationBadRequest' 400 @('CURSOR_INVALID','INVALID_REQUEST','OPERATION_KEY_INVALID')
Assert-ErrorEnvelope 'RJoinApplicationUnauthorized' 401 @('AUTHENTICATION_REQUIRED')
Assert-ErrorEnvelope 'RJoinApplicationForbidden' 403 @('JOIN_APPLICATION_FORBIDDEN')
Assert-ErrorEnvelope 'RJoinApplicationNotFound' 404 @('GENEALOGY_NOT_FOUND','JOIN_APPLICATION_NOT_FOUND')
Assert-ErrorEnvelope 'RJoinApplicationKeyConflict' 409 @('ACTIVE_PENDING_EXISTS','IDEMPOTENCY_KEY_REUSED','OPERATION_KEY_EXPIRED')
Assert-ErrorEnvelope 'RJoinApplicationStateConflict' 409 @('JOIN_APPLICATION_DECISION_CONFLICT','JOIN_APPLICATION_STATE_CHANGED') $true
if ($document.components.schemas.PSObject.Properties['RJoinApplicationConflict']) { Add-Issue 'remove broad RJoinApplicationConflict; each mutation operation must reference its precise 409 owner' }
Assert-ErrorEnvelope 'RJoinApplicationUnprocessable' 422 @('GENEALOGY_NOT_PUBLIC_APPLY','JOIN_APPLICATION_NOT_ALLOWED','REJECTION_REASON_INVALID')
Assert-ErrorEnvelope 'RJoinApplicationRateLimited' 429 @('RATE_LIMITED')
Assert-ErrorEnvelope 'RJoinApplicationServerError' 500 @('INTERNAL_ERROR')
$notAvailable = Get-Schema 'RGenealogyJoinApplicationRequestNotAvailable'
Assert-ClosedObject $notAvailable 'RGenealogyJoinApplicationRequestNotAvailable' @('code','businessCode','acceptUntil') @('code','businessCode','acceptUntil')
if ($notAvailable -and ($notAvailable.properties.code.enum[0] -ne 404 -or $notAvailable.properties.businessCode.enum[0] -ne 'JOIN_APPLICATION_REQUEST_NOT_AVAILABLE' -or $notAvailable.properties.acceptUntil.format -ne 'date-time')) { Add-Issue 'status 404 contract drifted' }
$post = $resolvedOperations['appCreateGenealogyJoinApplication']
if ($post) {
Assert-RequestBody $post 'join POST' '#/components/schemas/AppGenealogyJoinApplicationBody'
Assert-PathId $operations[1].Path $post 'genealogyId' '#/components/schemas/GenealogyId'
$key = Get-Parameter $operations[1].Path $post 'header' 'Idempotency-Key'
if ($key) {
if (-not (Test-JsonBoolean $key.required $true)) { Add-Issue 'join POST Idempotency-Key must be required' }
[void](Test-IsPureSchemaRef $key.schema '#/components/schemas/GenealogyJoinApplicationRequestKey' 'join POST Idempotency-Key')
}
$description = [string]$post.description
foreach ($pattern in @('canonical.*method.*path.*genealogyId.*tenant.*account.*client.*body','unique.*account.*tenant.*genealogy.*PENDING','same key.*same canonical.*same receipt','same key.*different.*409','domain transaction.*application.*SUCCEEDED','FAILED_NO_COMMIT.*no domain effects','same transaction.*READY.*PUBLIC_APPLY.*application eligibility','single winner.*PUBLIC_APPLY.*MEMBER_ONLY')) {
if ($description -notmatch "(?i)$pattern") { Add-Issue "join POST description misses: $pattern" }
}
if (-not (Test-IsJsonArray $post.'x-idempotency-scope') -or
(@($post.'x-idempotency-scope') -join ',') -ne 'method,path,genealogyId,tenant,account,client,canonicalBody' -or
-not (Test-IsJsonArray $post.'x-active-pending-unique-scope') -or
(@($post.'x-active-pending-unique-scope') -join ',') -ne 'tenant,account,genealogyId' -or
-not (Test-IsJsonArray $post.'x-domain-transaction-effects') -or
(@($post.'x-domain-transaction-effects') -join ',') -ne 'JOIN_APPLICATION,SUCCEEDED_RECEIPT' -or
-not (Test-IsJsonArray $post.'x-revalidates') -or
(@($post.'x-revalidates') -join ',') -ne 'genealogyState,accessPreset,applicationEligibility' -or
[string]$post.'x-public-apply-coordination' -ne 'ATOMIC_SINGLE_WINNER' -or
-not (Test-JsonBoolean $post.'x-same-request-replays-receipt' $true) -or
$post.'x-different-digest-error' -ne 'IDEMPOTENCY_KEY_REUSED') { Add-Issue 'join POST machine-readable idempotency/replay/transaction extensions drifted' }
}
$status = $resolvedOperations['appGetGenealogyJoinApplicationRequest']
if ($status) {
if ($status.PSObject.Properties['requestBody']) { Add-Issue 'status GET must not define a request body' }
$requestKey = Get-Parameter $operations[2].Path $status 'path' 'requestKey'
if ($requestKey) {
if (-not (Test-JsonBoolean $requestKey.required $true)) { Add-Issue 'status requestKey must be required' }
[void](Test-IsPureSchemaRef $requestKey.schema '#/components/schemas/GenealogyJoinApplicationRequestKey' 'status requestKey')
}
foreach ($pattern in @('read-only.*no side effect','ABSENT.*PENDING.*SUCCEEDED.*FAILED_NO_COMMIT','terminal.*immutable','cross-account.*tenant.*client.*404','before.*acceptUntil.*404','after.*acceptUntil.*computed.*FAILED_NO_COMMIT.*no write','PENDING.*resolveBy','domain effect.*SUCCEEDED.*same transaction')) {
if ([string]$status.description -notmatch "(?i)$pattern") { Add-Issue "status GET description misses: $pattern" }
}
if (-not (Test-JsonBoolean $status.'x-read-only' $true) -or -not (Test-JsonBoolean $status.'x-expired-absent-zero-write' $true)) { Add-Issue 'status GET read-only/zero-write extensions drifted' }
$status404 = Resolve-Response $status.responses.PSObject.Properties['404'].Value 'status GET 404 response'
$status404Retry = if ($status404.headers) { $status404.headers.PSObject.Properties['Retry-After'] } else { $null }
if (-not $status404Retry) {
Add-Issue 'status GET 404 must use RetryAfter before acceptUntil'
} else {
[void](Test-IsExactReferenceObjectRef $status404Retry.Value '#/components/headers/RetryAfter' 'headers' 'status GET 404 Retry-After')
}
}
$statusUnion = Get-Schema 'GenealogyJoinApplicationRequestStatus'
Assert-Union $statusUnion 'GenealogyJoinApplicationRequestStatus' 'status' @{
PENDING = '#/components/schemas/PendingGenealogyJoinApplicationRequest'
SUCCEEDED = '#/components/schemas/SucceededGenealogyJoinApplicationRequest'
FAILED_NO_COMMIT = '#/components/schemas/FailedGenealogyJoinApplicationRequest'
}
if ($statusUnion -and (-not (Test-JsonBoolean $statusUnion.'x-terminal-immutable' $true) -or -not (Test-IsJsonArray $statusUnion.'x-state-transitions') -or (@($statusUnion.'x-state-transitions') -join ',') -ne 'ABSENT->PENDING,PENDING->SUCCEEDED,PENDING->FAILED_NO_COMMIT')) { Add-Issue 'operation status transitions or terminal immutability drifted' }
$pendingStatus = Get-Schema 'PendingGenealogyJoinApplicationRequest'
$succeededStatus = Get-Schema 'SucceededGenealogyJoinApplicationRequest'
$failedStatus = Get-Schema 'FailedGenealogyJoinApplicationRequest'
Assert-ClosedObject $pendingStatus 'PendingGenealogyJoinApplicationRequest' @('status','resolveBy','retryAfterSeconds') @('status','resolveBy','retryAfterSeconds')
Assert-SingleEnum $pendingStatus 'PendingGenealogyJoinApplicationRequest' 'status' 'PENDING'
if ($pendingStatus -and ($pendingStatus.properties.resolveBy.format -ne 'date-time' -or $pendingStatus.properties.retryAfterSeconds.type -ne 'integer' -or [int]$pendingStatus.properties.retryAfterSeconds.minimum -ne 1 -or [int]$pendingStatus.properties.retryAfterSeconds.maximum -ne 30)) { Add-Issue 'PENDING operation status timing fields drifted' }
Assert-ClosedObject $succeededStatus 'SucceededGenealogyJoinApplicationRequest' @('status','result') @('status','result')
Assert-SingleEnum $succeededStatus 'SucceededGenealogyJoinApplicationRequest' 'status' 'SUCCEEDED'
Assert-PropertyRef $succeededStatus 'SucceededGenealogyJoinApplicationRequest' 'result' '#/components/schemas/GenealogyJoinApplicationReceipt'
Assert-ClosedObject $failedStatus 'FailedGenealogyJoinApplicationRequest' @('status') @('status')
Assert-SingleEnum $failedStatus 'FailedGenealogyJoinApplicationRequest' 'status' 'FAILED_NO_COMMIT'
if ($failedStatus -and ($failedStatus.'x-domain-effects' -ne 'NONE' -or -not (Test-JsonBoolean $failedStatus.'x-active-application-created' $false))) { Add-Issue 'FAILED_NO_COMMIT must machine-lock zero application effects' }
foreach ($listId in @('appSearchPublicGenealogies','appListMyGenealogyJoinApplications','appListPendingGenealogyJoinApplications')) {
$operation = $resolvedOperations[$listId]
if (-not $operation) { continue }
$limit = Get-Parameter ($operations | Where-Object Id -eq $listId).Path $operation 'query' 'limit'
$cursor = Get-Parameter ($operations | Where-Object Id -eq $listId).Path $operation 'query' 'cursor'
if ($limit -and ($limit.schema.type -ne 'integer' -or -not (Test-IsNonNullable $limit.schema) -or [int]$limit.schema.minimum -ne 1 -or [int]$limit.schema.maximum -ne 50)) { Add-Issue "$listId limit must be a non-null integer 1..50" }
if ($limit) {
Assert-NoConflictingSchemaKeywords $limit.schema "$listId limit"
Assert-AllowedSchemaKeywords $limit.schema "$listId limit" @('type', 'minimum', 'maximum', 'nullable')
}
if ($cursor) { [void](Test-IsPureSchemaRef $cursor.schema '#/components/schemas/JoinApplicationCursor' "$listId cursor") }
foreach ($pattern in @('stable.*cursor','no total','tenant.*account.*client','filter.*cursor','tie-breaker')) {
if ([string]$operation.description -notmatch "(?i)$pattern") { Add-Issue "$listId pagination description misses: $pattern" }
}
$expectedOrder = if ($listId -eq 'appSearchPublicGenealogies') { 'updatedAt:desc,genealogyId:desc' } else { 'submittedAt:desc,applyId:desc' }
if (-not (Test-IsJsonArray $operation.'x-cursor-scope') -or (@($operation.'x-cursor-scope') -join ',') -ne 'tenant,account,client,filters' -or -not (Test-IsJsonArray $operation.'x-cursor-order') -or (@($operation.'x-cursor-order') -join ',') -ne $expectedOrder -or -not (Test-JsonBoolean $operation.'x-cursor-no-total' $true)) { Add-Issue "$listId cursor extensions drifted" }
}
$search = $resolvedOperations['appSearchPublicGenealogies']
if ($search) {
$keyword = Get-Parameter $operations[0].Path $search 'query' 'keyword'
if ($keyword -and (-not (Test-JsonBoolean $keyword.required $true) -or $keyword.schema.type -ne 'string' -or -not (Test-IsNonNullable $keyword.schema) -or [int]$keyword.schema.minLength -ne 1 -or [int]$keyword.schema.maxLength -ne 50)) { Add-Issue 'search keyword must be required non-null string length 1..50' }
if ($keyword) {
Assert-NoConflictingSchemaKeywords $keyword.schema 'search keyword'
Assert-AllowedSchemaKeywords $keyword.schema 'search keyword' @('type', 'minLength', 'maxLength', 'nullable')
}
}
$withdraw = $resolvedOperations['appWithdrawGenealogyJoinApplication']
if ($withdraw) {
if ($withdraw.PSObject.Properties['requestBody']) { Add-Issue 'withdraw DELETE must not define a request body' }
Assert-PathId $operations[4].Path $withdraw 'applyId' '#/components/schemas/JoinApplicationId'
foreach ($pattern in @('WHERE status=PENDING','same withdraw.*same 200','audit.*race.*409','current.*state','terminal.*immutable','cross-account.*tenant.*404')) {
if ([string]$withdraw.description -notmatch "(?i)$pattern") { Add-Issue "withdraw description misses: $pattern" }
}
if ($withdraw.'x-cas-where' -ne 'status=PENDING' -or -not (Test-JsonBoolean $withdraw.'x-same-action-replay' $true)) { Add-Issue 'withdraw CAS extensions drifted' }
}
$audit = $resolvedOperations['appReviewGenealogyJoinApplication']
if ($audit) {
Assert-RequestBody $audit 'audit PUT' '#/components/schemas/AppGenealogyJoinReviewBody'
Assert-PathId $operations[6].Path $audit 'genealogyId' '#/components/schemas/GenealogyId'
Assert-PathId $operations[6].Path $audit 'applyId' '#/components/schemas/JoinApplicationId'
foreach ($pattern in @('WHERE status=PENDING','same decision.*same 200','different rejection.*409','opposite decision.*409','permission.*READY.*PUBLIC_APPLY','same transaction.*member.*application','unique member')) {
if ([string]$audit.description -notmatch "(?i)$pattern") { Add-Issue "audit description misses: $pattern" }
}
if ($audit.'x-cas-where' -ne 'status=PENDING' -or -not (Test-IsJsonArray $audit.'x-transaction-effects') -or (@($audit.'x-transaction-effects') -join ',') -ne 'UNIQUE_MEMBER_RELATION,APPLICATION_TERMINAL_STATE' -or -not (Test-IsJsonArray $audit.'x-revalidates') -or (@($audit.'x-revalidates') -join ',') -ne 'permission,genealogyState,accessPreset' -or -not (Test-JsonBoolean $audit.'x-same-decision-replays-receipt' $true) -or $audit.'x-opposite-decision-error' -ne 'JOIN_APPLICATION_DECISION_CONFLICT') { Add-Issue 'audit CAS/replay/transaction extensions drifted' }
}
$pendingOperation = $resolvedOperations['appListPendingGenealogyJoinApplications']
Assert-PathId $operations[5].Path $pendingOperation 'genealogyId' '#/components/schemas/GenealogyId'
foreach ($readId in @('appSearchPublicGenealogies','appListMyGenealogyJoinApplications','appListPendingGenealogyJoinApplications')) {
$read = $resolvedOperations[$readId]
if ($read -and $read.PSObject.Properties['requestBody']) { Add-Issue "$readId must not define a request body" }
}
foreach ($legacy in @('GenealogyJoinApplyBody','GenealogyJoinAuditBody','AppGenealogyJoinApplyBody','AppGenealogyJoinAuditBody')) {
if ($document.components.schemas.PSObject.Properties[$legacy]) { Add-Issue "legacy APP join schema must be removed: $legacy" }
}
# No successful join response may expose account, phone, inviter, or auditor identities anywhere in its recursive schema closure.
$queue = New-Object System.Collections.Generic.Queue[string]
$seen = @{}
foreach ($entry in $operations) { $queue.Enqueue(([string]$entry.SuccessRef).Split('/')[-1]) }
while ($queue.Count -gt 0) {
$name = $queue.Dequeue()
if ($seen.ContainsKey($name)) { continue }
$seen[$name] = $true
$owner = $document.components.schemas.PSObject.Properties[$name]
if (-not $owner) { Add-Issue "successful response closure is missing schema: $name"; continue }
$schema = $owner.Value
foreach ($forbidden in @('phone','appUserId','appUserPhone','inviterUserId','inviterPhone','auditUserId','auditPhone')) {
if ($schema.properties -and $schema.properties.PSObject.Properties.Name -contains $forbidden) { Add-Issue "$name leaks forbidden identity field: $forbidden" }
}
$json = $schema | ConvertTo-Json -Depth 40 -Compress
foreach ($match in [regex]::Matches($json, '#/components/schemas/(?<Name>[A-Za-z0-9._-]+)')) {
$queue.Enqueue($match.Groups['Name'].Value)
}
}
if ($issues.Count -gt 0) {
Write-Output 'JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED'
Write-Output "Issues: $($issues.Count)"
$issues | ForEach-Object { Write-Output "- $_" }
Write-Output 'Only accept APP.openapi.json and APP.openapi.yaml re-exported together from one backend version; never hand-edit the protected files.'
exit 1
}
Write-Output 'JOIN-APPLICATION-OPENAPI-CONTRACT PASS'