989 lines
46 KiB
PowerShell
989 lines
46 KiB
PowerShell
$ErrorActionPreference = 'Stop'
|
|
|
|
$root = Split-Path -Parent $PSScriptRoot
|
|
$jsonPath = Join-Path $root 'APP.openapi.json'
|
|
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
|
|
$issues = New-Object System.Collections.Generic.List[string]
|
|
|
|
$createPath = '/genealogy/app/genealogies'
|
|
$statusPath = '/genealogy/app/genealogy-bootstrap-operations/{operationKey}'
|
|
$settingsPath = '/genealogy/app/genealogies/{genealogyId}'
|
|
$regionSearchPath = '/genealogy/app/region/search'
|
|
$personPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons'
|
|
$personDetailPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}'
|
|
$personParentsPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/parents'
|
|
$minePath = '/genealogy/app/genealogies/mine'
|
|
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
|
|
|
|
function Add-Issue {
|
|
param([string]$Message)
|
|
$script:issues.Add($Message)
|
|
}
|
|
|
|
function Test-IsJsonArray {
|
|
param([object]$Value)
|
|
return $null -ne $Value -and $Value.GetType().IsArray
|
|
}
|
|
|
|
function Test-IsJsonBoolean {
|
|
param([object]$Value, [bool]$Expected)
|
|
return $Value -is [System.Boolean] -and $Value -eq $Expected
|
|
}
|
|
|
|
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
|
|
$parityOutput = @(& node $parityScript 2>&1)
|
|
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
|
|
Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')"
|
|
}
|
|
|
|
function Get-Schema {
|
|
param([string]$Name)
|
|
$property = $document.components.schemas.PSObject.Properties[$Name]
|
|
if (-not $property) {
|
|
Add-Issue "JSON missing schema owner: $Name"
|
|
return $null
|
|
}
|
|
return $property.Value
|
|
}
|
|
|
|
function Get-ComposedPropertyDefinitions {
|
|
param(
|
|
[object]$Schema,
|
|
[string]$Field,
|
|
[hashtable]$Seen = @{}
|
|
)
|
|
if (-not $Schema) { return @() }
|
|
$definitions = New-Object System.Collections.Generic.List[object]
|
|
$direct = if ($Schema.properties) { $Schema.properties.PSObject.Properties[$Field] } else { $null }
|
|
if ($direct) { $definitions.Add($direct.Value) }
|
|
|
|
if ($Schema.'$ref') {
|
|
$name = ([string]$Schema.'$ref').Split('/')[-1]
|
|
if (-not $Seen.ContainsKey($name)) {
|
|
$Seen[$name] = $true
|
|
$owner = $document.components.schemas.PSObject.Properties[$name]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON missing composed schema owner: $name"
|
|
} else {
|
|
foreach ($definition in @(Get-ComposedPropertyDefinitions $owner.Value $Field $Seen)) {
|
|
$definitions.Add($definition)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) {
|
|
$branches = $Schema.PSObject.Properties[$keyword]
|
|
if (-not $branches) { continue }
|
|
foreach ($branch in @($branches.Value)) {
|
|
foreach ($definition in @(Get-ComposedPropertyDefinitions $branch $Field $Seen)) {
|
|
$definitions.Add($definition)
|
|
}
|
|
}
|
|
}
|
|
return @($definitions)
|
|
}
|
|
|
|
function Get-Operation {
|
|
param([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 "JSON missing $($Method.ToUpperInvariant()) $Path"
|
|
return $null
|
|
}
|
|
return $operation.Value
|
|
}
|
|
|
|
function Get-EffectiveParameters {
|
|
param([string]$Path, [object]$Operation)
|
|
if (-not $Operation) { return @() }
|
|
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
|
$pathParameters = if ($pathProperty) { @($pathProperty.Value.parameters) } else { @() }
|
|
$parametersByIdentity = [ordered]@{}
|
|
|
|
# OpenAPI lets an operation override a path-item parameter with the same in/name identity.
|
|
# Reject duplicates inside each scope before applying the legal operation-level override.
|
|
$scopes = @(
|
|
[pscustomobject]@{ Name = 'path-item'; Parameters = @($pathParameters) },
|
|
[pscustomobject]@{ Name = 'operation'; Parameters = @($Operation.parameters) }
|
|
)
|
|
foreach ($scope in $scopes) {
|
|
$scopeIdentities = @{}
|
|
foreach ($parameter in @($scope.Parameters)) {
|
|
if (-not $parameter) { continue }
|
|
$resolved = $parameter
|
|
if ($parameter.'$ref') {
|
|
$name = ([string]$parameter.'$ref').Split('/')[-1]
|
|
$owner = $document.components.parameters.PSObject.Properties[$name]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON missing parameter owner: $name"
|
|
continue
|
|
}
|
|
$resolved = $owner.Value
|
|
}
|
|
$identity = (([string]$resolved.in) + ':' + ([string]$resolved.name)).ToLowerInvariant()
|
|
if ($identity -eq ':') {
|
|
Add-Issue "JSON $Path contains a parameter without in/name"
|
|
continue
|
|
}
|
|
if ($scopeIdentities.ContainsKey($identity)) {
|
|
Add-Issue "JSON $Path contains duplicate $($scope.Name) parameter: $identity"
|
|
continue
|
|
}
|
|
$scopeIdentities[$identity] = $true
|
|
$parametersByIdentity[$identity] = $resolved
|
|
}
|
|
}
|
|
return @($parametersByIdentity.Values)
|
|
}
|
|
|
|
function Get-Parameter {
|
|
param([object]$Operation, [string]$Name, [string]$In, [string]$Path = '')
|
|
if (-not $Operation) { return $null }
|
|
$parameters = if ($Path) { Get-EffectiveParameters $Path $Operation } else { @($Operation.parameters) }
|
|
$matches = @($parameters | Where-Object { $_.name -eq $Name -and $_.in -eq $In })
|
|
if ($matches.Count -ne 1) {
|
|
Add-Issue "JSON operation must define exactly one $In parameter named $Name"
|
|
return $null
|
|
}
|
|
return $matches[0]
|
|
}
|
|
|
|
function Assert-SaTokenOnly {
|
|
param([object]$Operation, [string]$Label)
|
|
if (-not $Operation) { return }
|
|
$requirements = if ($Operation.PSObject.Properties['security']) {
|
|
@($Operation.security)
|
|
} else {
|
|
@($document.security)
|
|
}
|
|
$valid = $requirements.Count -eq 1 -and
|
|
$requirements[0].PSObject.Properties.Count -eq 1 -and
|
|
$requirements[0].PSObject.Properties.Name -contains 'SaToken'
|
|
if (-not $valid) { Add-Issue "JSON $Label must require SaToken without an anonymous alternative" }
|
|
}
|
|
|
|
function Assert-RequiredClientId {
|
|
param([object]$Operation, [string]$Label, [string]$Path)
|
|
$header = Get-Parameter $Operation 'clientid' 'header' $Path
|
|
if (-not $header) { return }
|
|
if (-not (Test-IsJsonBoolean $header.required $true) -or
|
|
$header.schema.type -ne 'string' -or [int]$header.schema.minLength -lt 1) {
|
|
Add-Issue "JSON $Label clientid must be a required non-empty string header"
|
|
}
|
|
}
|
|
|
|
function Assert-RequestBodyRef {
|
|
param([object]$Operation, [string]$Label, [string]$ExpectedRef)
|
|
if (-not $Operation) { return }
|
|
$media = $Operation.requestBody.content.PSObject.Properties['application/json']
|
|
if (-not (Test-IsJsonBoolean $Operation.requestBody.required $true) -or -not $media) {
|
|
Add-Issue "JSON $Label must require an application/json body"
|
|
} elseif ([string]$media.Value.schema.'$ref' -ne $ExpectedRef) {
|
|
Add-Issue "JSON $Label request body must use $ExpectedRef"
|
|
}
|
|
}
|
|
|
|
function Assert-ExactObject {
|
|
param(
|
|
[object]$Schema,
|
|
[string]$Label,
|
|
[string[]]$Fields,
|
|
[string[]]$Required,
|
|
[int]$MinProperties = -1,
|
|
[int]$MaxProperties = -1
|
|
)
|
|
if (-not $Schema) { return }
|
|
$actualFields = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
|
|
$expectedFields = @($Fields | Sort-Object)
|
|
$actualRequired = @($Schema.required | Sort-Object)
|
|
$expectedRequired = @($Required | Sort-Object)
|
|
$requiredProperty = $Schema.PSObject.Properties['required']
|
|
$requiredShapeValid = if ($expectedRequired.Count -gt 0) {
|
|
Test-IsJsonArray $Schema.required
|
|
} elseif ($requiredProperty) {
|
|
Test-IsJsonArray $Schema.required
|
|
} else { $true }
|
|
if ($Schema.type -ne 'object' -or -not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
|
|
-not $requiredShapeValid -or
|
|
($actualFields -join ',') -ne ($expectedFields -join ',') -or
|
|
($actualRequired -join ',') -ne ($expectedRequired -join ',')) {
|
|
Add-Issue "JSON $Label must be a closed object; fields=$($expectedFields -join ','); required=$($expectedRequired -join ',')"
|
|
}
|
|
if ($MinProperties -ge 0 -and [int]$Schema.minProperties -ne $MinProperties) {
|
|
Add-Issue "JSON $Label minProperties must be $MinProperties"
|
|
}
|
|
if ($MaxProperties -ge 0 -and [int]$Schema.maxProperties -ne $MaxProperties) {
|
|
Add-Issue "JSON $Label maxProperties must be $MaxProperties"
|
|
}
|
|
}
|
|
|
|
function Assert-PropertyRef {
|
|
param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef)
|
|
if (-not $Schema) { return }
|
|
$property = $Schema.properties.PSObject.Properties[$Field]
|
|
$actual = if ($property) { [string]$property.Value.'$ref' } else { '' }
|
|
if ($actual -ne $ExpectedRef) {
|
|
Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual"
|
|
}
|
|
}
|
|
|
|
function Assert-ErrorSchema {
|
|
param([string]$Name, [int]$HttpStatus, [string[]]$BusinessCodes)
|
|
$schema = Get-Schema $Name
|
|
if (-not $schema) { return }
|
|
Assert-ExactObject $schema $Name @('code', 'businessCode') @('code', 'businessCode')
|
|
$actualCodes = @($schema.properties.businessCode.enum | Sort-Object)
|
|
$expectedCodes = @($BusinessCodes | Sort-Object)
|
|
if ($schema.properties.code.type -ne 'integer' -or
|
|
-not (Test-IsJsonArray $schema.properties.code.enum) -or
|
|
@($schema.properties.code.enum).Count -ne 1 -or
|
|
@($schema.properties.code.enum)[0] -ne $HttpStatus -or
|
|
$schema.properties.businessCode.type -ne 'string' -or
|
|
-not (Test-IsJsonArray $schema.properties.businessCode.enum) -or
|
|
($actualCodes -join ',') -ne ($expectedCodes -join ',')) {
|
|
Add-Issue "JSON $Name business-code contract drifted: $($actualCodes -join ',')"
|
|
}
|
|
}
|
|
|
|
function Assert-StringField {
|
|
param(
|
|
[object]$Schema,
|
|
[string]$SchemaName,
|
|
[string]$Field,
|
|
[int]$MinLength,
|
|
[int]$MaxLength,
|
|
[string]$Format = ''
|
|
)
|
|
if (-not $Schema) { return }
|
|
$property = $Schema.properties.PSObject.Properties[$Field]
|
|
if (-not $property) {
|
|
Add-Issue "JSON $SchemaName missing field: $Field"
|
|
return
|
|
}
|
|
$value = $property.Value
|
|
if ($value.type -ne 'string' -or [int]$value.minLength -ne $MinLength -or [int]$value.maxLength -ne $MaxLength) {
|
|
Add-Issue "JSON $SchemaName.$Field must be a string with length $MinLength..$MaxLength"
|
|
}
|
|
if ($Format -and $value.format -ne $Format) {
|
|
Add-Issue "JSON $SchemaName.$Field format must be $Format"
|
|
}
|
|
}
|
|
|
|
function Get-Response {
|
|
param([object]$Operation, [string]$Label, [string]$Status)
|
|
if (-not $Operation) { return $null }
|
|
$property = $Operation.responses.PSObject.Properties[$Status]
|
|
if (-not $property) {
|
|
Add-Issue "JSON $Label missing $Status response"
|
|
return $null
|
|
}
|
|
$response = $property.Value
|
|
if ($response.'$ref') {
|
|
$name = ([string]$response.'$ref').Split('/')[-1]
|
|
$owner = $document.components.responses.PSObject.Properties[$name]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON missing response owner: $name"
|
|
return $null
|
|
}
|
|
$response = $owner.Value
|
|
}
|
|
return $response
|
|
}
|
|
|
|
function Get-JsonResponseRef {
|
|
param([object]$Response, [string]$Label, [string]$Status)
|
|
if (-not $Response) { return '' }
|
|
$media = $Response.content.PSObject.Properties['application/json']
|
|
if (-not $media) {
|
|
Add-Issue "JSON $Label $Status must use application/json"
|
|
return ''
|
|
}
|
|
return [string]$media.Value.schema.'$ref'
|
|
}
|
|
|
|
function Assert-PrivateNoStore {
|
|
param([object]$Response, [string]$Label, [string]$Status)
|
|
if (-not $Response) { return }
|
|
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
|
|
if (-not $property) {
|
|
Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store"
|
|
return
|
|
}
|
|
$header = $property.Value
|
|
if ($header.'$ref') {
|
|
$name = ([string]$header.'$ref').Split('/')[-1]
|
|
$owner = $document.components.headers.PSObject.Properties[$name]
|
|
if ($owner) { $header = $owner.Value }
|
|
}
|
|
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
|
|
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
|
|
Add-Issue "JSON $Label $Status Cache-Control must specify private, no-store"
|
|
}
|
|
}
|
|
|
|
function Assert-RetryAfter {
|
|
param([object]$Response, [string]$Label, [string]$Status)
|
|
$header = if ($Response -and $Response.headers) { $Response.headers.PSObject.Properties['Retry-After'] } else { $null }
|
|
if (-not $header) {
|
|
Add-Issue "JSON $Label $Status must document Retry-After"
|
|
return
|
|
}
|
|
$value = $header.Value
|
|
if ($value.'$ref') {
|
|
$name = ([string]$value.'$ref').Split('/')[-1]
|
|
$owner = $document.components.headers.PSObject.Properties[$name]
|
|
if ($owner) { $value = $owner.Value }
|
|
}
|
|
if ($value.schema.type -notin @('integer', 'string')) {
|
|
Add-Issue "JSON $Label $Status Retry-After must have an integer or string schema"
|
|
}
|
|
}
|
|
|
|
function Assert-ExactResponseSet {
|
|
param([object]$Operation, [string]$Label, [string[]]$Expected)
|
|
if (-not $Operation) { return }
|
|
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
|
|
$expectedSorted = @($Expected | Sort-Object)
|
|
if (($actual -join ',') -ne ($expectedSorted -join ',')) {
|
|
Add-Issue "JSON $Label response statuses must be exactly $($expectedSorted -join ','); actual: $($actual -join ',')"
|
|
}
|
|
}
|
|
|
|
function Assert-AllDeclaredResponsesPrivateJson {
|
|
param([object]$Operation, [string]$Label)
|
|
if (-not $Operation) { return }
|
|
foreach ($responseProperty in $Operation.responses.PSObject.Properties) {
|
|
$status = [string]$responseProperty.Name
|
|
$response = Get-Response $Operation $Label $status
|
|
[void](Get-JsonResponseRef $response $Label $status)
|
|
Assert-PrivateNoStore $response $Label $status
|
|
}
|
|
}
|
|
|
|
$createOperation = Get-Operation $createPath 'post'
|
|
$statusOperation = Get-Operation $statusPath 'get'
|
|
$settingsOperation = Get-Operation $settingsPath 'put'
|
|
$regionOperation = Get-Operation $regionSearchPath 'get'
|
|
$personOperation = Get-Operation $personPath 'post'
|
|
$personUpdateOperation = Get-Operation $personDetailPath 'put'
|
|
$personDeleteOperation = Get-Operation $personDetailPath 'delete'
|
|
$personParentsOperation = Get-Operation $personParentsPath 'post'
|
|
$mineOperation = Get-Operation $minePath 'get'
|
|
$overviewOperation = Get-Operation $overviewPath 'get'
|
|
if ($document.paths.PSObject.Properties['/genealogy/region/search']) {
|
|
Add-Issue 'JSON legacy /genealogy/region/search must be removed; APP consumers use the authenticated /genealogy/app/region/search owner'
|
|
}
|
|
|
|
foreach ($entry in @(
|
|
[pscustomobject]@{ Operation = $createOperation; Label = "POST $createPath"; Path = $createPath },
|
|
[pscustomobject]@{ Operation = $statusOperation; Label = "GET $statusPath"; Path = $statusPath },
|
|
[pscustomobject]@{ Operation = $settingsOperation; Label = "PUT $settingsPath"; Path = $settingsPath },
|
|
[pscustomobject]@{ Operation = $regionOperation; Label = "GET $regionSearchPath"; Path = $regionSearchPath },
|
|
[pscustomobject]@{ Operation = $personOperation; Label = "POST $personPath"; Path = $personPath },
|
|
[pscustomobject]@{ Operation = $personUpdateOperation; Label = "PUT $personDetailPath"; Path = $personDetailPath },
|
|
[pscustomobject]@{ Operation = $personDeleteOperation; Label = "DELETE $personDetailPath"; Path = $personDetailPath },
|
|
[pscustomobject]@{ Operation = $personParentsOperation; Label = "POST $personParentsPath"; Path = $personParentsPath }
|
|
)) {
|
|
Assert-SaTokenOnly $entry.Operation $entry.Label
|
|
Assert-RequiredClientId $entry.Operation $entry.Label $entry.Path
|
|
}
|
|
|
|
Assert-RequestBodyRef $createOperation "POST $createPath" '#/components/schemas/AppGenealogyBootstrapBody'
|
|
Assert-RequestBodyRef $settingsOperation "PUT $settingsPath" '#/components/schemas/AppGenealogySettingsUpdateBody'
|
|
|
|
$idempotencyHeader = Get-Parameter $createOperation 'Idempotency-Key' 'header' $createPath
|
|
if ($idempotencyHeader) {
|
|
if (-not (Test-IsJsonBoolean $idempotencyHeader.required $true) -or
|
|
[string]$idempotencyHeader.schema.'$ref' -ne '#/components/schemas/GenealogyBootstrapOperationKey') {
|
|
Add-Issue 'JSON create Idempotency-Key must be required and reference GenealogyBootstrapOperationKey'
|
|
}
|
|
}
|
|
$statusKey = Get-Parameter $statusOperation 'operationKey' 'path' $statusPath
|
|
if ($statusKey) {
|
|
if (-not (Test-IsJsonBoolean $statusKey.required $true) -or
|
|
[string]$statusKey.schema.'$ref' -ne '#/components/schemas/GenealogyBootstrapOperationKey') {
|
|
Add-Issue 'JSON bootstrap status operationKey must be required and reference GenealogyBootstrapOperationKey'
|
|
}
|
|
}
|
|
|
|
if ($createOperation) {
|
|
$semantics = [string]$createOperation.description
|
|
foreach ($pattern in @(
|
|
'(?i)before acceptUntil.*short transaction.*claim PENDING.*canonical digest'
|
|
'(?i)unique.*account.*tenant.*client.*path.*key'
|
|
'(?i)fencing lease.*stale worker.*cannot commit'
|
|
'(?i)business transaction.*quota.*genealogy.*OWNER.*unique generation-one root.*READY.*SUCCEEDED receipt'
|
|
'(?i)failure.*rolls back.*all.*FAILED_NO_COMMIT'
|
|
'(?i)watchdog.*resolveBy.*compare-and-set.*FAILED_NO_COMMIT'
|
|
'(?i)resolveBy.*no later than.*2 minutes'
|
|
'(?i)expired absent key.*reject.*late POST'
|
|
'(?i)same key.*same canonical.*same receipt'
|
|
'(?i)same key.*different.*digest.*IDEMPOTENCY_KEY_REUSED'
|
|
'(?i)200.*committed'
|
|
'(?i)transaction.*revalidates.*region.*selectable'
|
|
'(?i)duplicate name.*advisory.*not unique'
|
|
'(?i)400.*401.*403.*before claim.*no operation record'
|
|
'(?i)create limit.*FAILED_NO_COMMIT'
|
|
'(?i)unprocessable.*FAILED_NO_COMMIT'
|
|
'(?i)429.*same key.*retry'
|
|
'(?i)500.*outcome unknown.*status'
|
|
)) {
|
|
if ($semantics -notmatch $pattern) {
|
|
Add-Issue "JSON POST $createPath description is missing atomic/idempotent semantics: $pattern"
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($statusOperation) {
|
|
if ($statusOperation.PSObject.Properties['requestBody']) {
|
|
Add-Issue "JSON GET $statusPath must not define a request body"
|
|
}
|
|
$actualParameters = @(Get-EffectiveParameters $statusPath $statusOperation | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
|
|
$expectedParameters = @('header:clientid', 'path:operationKey') | Sort-Object
|
|
if (($actualParameters -join ',') -ne ($expectedParameters -join ',')) {
|
|
Add-Issue "JSON GET $statusPath parameters must be exactly clientid plus operationKey; actual: $($actualParameters -join ',')"
|
|
}
|
|
$semantics = [string]$statusOperation.description
|
|
foreach ($pattern in @(
|
|
'(?i)current account.*tenant.*client'
|
|
'(?i)PENDING.*SUCCEEDED.*FAILED_NO_COMMIT'
|
|
'(?i)SUCCEEDED.*same receipt'
|
|
'(?i)read-only.*no side effect'
|
|
'(?i)PENDING.*must.*terminal.*claimedAt.*2 minutes'
|
|
'(?i)404.*before.*acceptUntil.*unknown.*must not.*new key'
|
|
'(?i)after.*acceptUntil.*absent.*computed.*FAILED_NO_COMMIT'
|
|
'(?i)expired.*POST.*permanently rejected'
|
|
'(?i)no request payload.*personal data'
|
|
'(?i)does not consume.*quota'
|
|
'(?i)SUCCEEDED.*retained.*entity lifecycle'
|
|
'(?i)FAILED_NO_COMMIT.*retained.*30 days'
|
|
'(?i)cross-account.*tenant.*client.*404.*non-disclosing'
|
|
)) {
|
|
if ($semantics -notmatch $pattern) {
|
|
Add-Issue "JSON GET $statusPath description is missing crash-recovery semantics: $pattern"
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($personOperation) {
|
|
$personSemantics = [string]$personOperation.description
|
|
foreach ($pattern in @(
|
|
'(?i)READY.*ordinary person.*not.*bootstrap.*root'
|
|
'(?i)database.*bootstrap-root marker.*authoritative'
|
|
'(?i)generation.*1.*cannot.*create.*replace.*bootstrap root'
|
|
)) {
|
|
if ($personSemantics -notmatch $pattern) {
|
|
Add-Issue "JSON POST $personPath is missing root-protection semantics: $pattern"
|
|
}
|
|
}
|
|
foreach ($status in @('409', '422')) {
|
|
$response = Get-Response $personOperation "POST $personPath" $status
|
|
$responseRef = Get-JsonResponseRef $response "POST $personPath" $status
|
|
$expectedRef = if ($status -eq '409') {
|
|
'#/components/schemas/RLineageRootConflict'
|
|
} else {
|
|
'#/components/schemas/RLineageRootUnprocessable'
|
|
}
|
|
if ($responseRef -ne $expectedRef) {
|
|
Add-Issue "JSON POST $personPath $status must use $expectedRef; actual: $responseRef"
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($mutation in @(
|
|
[pscustomobject]@{
|
|
Operation = $personUpdateOperation
|
|
Label = "PUT $personDetailPath"
|
|
Patterns = @(
|
|
'(?i)bootstrap root.*profile fields.*may.*edit'
|
|
'(?i)root identity.*generation.*parentage.*immutable'
|
|
'(?i)database.*bootstrap-root marker.*authoritative'
|
|
)
|
|
},
|
|
[pscustomobject]@{
|
|
Operation = $personDeleteOperation
|
|
Label = "DELETE $personDetailPath"
|
|
Patterns = @(
|
|
'(?i)bootstrap root.*cannot.*delete'
|
|
'(?i)database.*bootstrap-root marker.*authoritative'
|
|
)
|
|
},
|
|
[pscustomobject]@{
|
|
Operation = $personParentsOperation
|
|
Label = "POST $personParentsPath"
|
|
Patterns = @(
|
|
'(?i)bootstrap root.*cannot.*add.*reassign.*parent'
|
|
'(?i)database.*bootstrap-root marker.*authoritative'
|
|
)
|
|
}
|
|
)) {
|
|
if (-not $mutation.Operation) { continue }
|
|
$semantics = [string]$mutation.Operation.description
|
|
foreach ($pattern in $mutation.Patterns) {
|
|
if ($semantics -notmatch $pattern) {
|
|
Add-Issue "JSON $($mutation.Label) is missing bootstrap-root guard: $pattern"
|
|
}
|
|
}
|
|
foreach ($status in @('409', '422')) {
|
|
$response = Get-Response $mutation.Operation $mutation.Label $status
|
|
$actualRef = Get-JsonResponseRef $response $mutation.Label $status
|
|
$expectedRef = if ($status -eq '409') {
|
|
'#/components/schemas/RLineageRootConflict'
|
|
} else {
|
|
'#/components/schemas/RLineageRootUnprocessable'
|
|
}
|
|
if ($actualRef -ne $expectedRef) {
|
|
Add-Issue "JSON $($mutation.Label) $status must use $expectedRef; actual: $actualRef"
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($personUpdateOperation) {
|
|
$rootEditableFields = $personUpdateOperation.'x-bootstrap-root-editable-fields'
|
|
$actualRootEditableFields = @($rootEditableFields | Sort-Object)
|
|
$expectedRootEditableFields = @('biography', 'birthDate', 'name', 'sex') | Sort-Object
|
|
if (-not (Test-IsJsonArray $rootEditableFields) -or
|
|
($actualRootEditableFields -join ',') -ne ($expectedRootEditableFields -join ',') -or
|
|
[string]$personUpdateOperation.'x-bootstrap-root-noneditable-policy' -ne 'REJECT_422_BOOTSTRAP_ROOT_IMMUTABLE') {
|
|
Add-Issue 'JSON root PUT must machine-bind the exact name/sex/birthDate/biography editable allowlist and reject every other root field with typed 422'
|
|
}
|
|
}
|
|
|
|
$bootstrapBody = Get-Schema 'AppGenealogyBootstrapBody'
|
|
$rootPersonBody = Get-Schema 'AppGenealogyRootPersonBody'
|
|
$operationKey = Get-Schema 'GenealogyBootstrapOperationKey'
|
|
$regionCode = Get-Schema 'GenealogyRegionCode'
|
|
$accessPreset = Get-Schema 'GenealogyAccessPreset'
|
|
$settingsBody = Get-Schema 'AppGenealogySettingsUpdateBody'
|
|
$bootstrapResult = Get-Schema 'GenealogyBootstrapResult'
|
|
$operationStatus = Get-Schema 'GenealogyBootstrapOperationStatus'
|
|
$pendingStatus = Get-Schema 'GenealogyBootstrapPendingStatus'
|
|
$succeededStatus = Get-Schema 'GenealogyBootstrapSucceededStatus'
|
|
$failedStatus = Get-Schema 'GenealogyBootstrapFailedStatus'
|
|
$resultEnvelope = Get-Schema 'RAppGenealogyBootstrapResult'
|
|
$statusEnvelope = Get-Schema 'RAppGenealogyBootstrapOperationStatus'
|
|
$regionItem = Get-Schema 'RegionSelectVo'
|
|
$regionEnvelope = Get-Schema 'RListRegionSelectVo'
|
|
|
|
Assert-ExactObject $bootstrapBody 'AppGenealogyBootstrapBody' @(
|
|
'genealogyName', 'surname', 'ancestralHall', 'regionCode', 'accessPreset', 'rootPerson'
|
|
) @('genealogyName', 'surname', 'regionCode', 'accessPreset', 'rootPerson')
|
|
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
|
|
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'rootPerson' '#/components/schemas/AppGenealogyRootPersonBody'
|
|
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'regionCode' '#/components/schemas/GenealogyRegionCode'
|
|
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'genealogyName' 1 24
|
|
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'surname' 1 4
|
|
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'ancestralHall' 1 12
|
|
|
|
Assert-ExactObject $rootPersonBody 'AppGenealogyRootPersonBody' @(
|
|
'name', 'sex', 'birthDate', 'biography'
|
|
) @('name', 'sex')
|
|
Assert-StringField $rootPersonBody 'AppGenealogyRootPersonBody' 'name' 1 20
|
|
Assert-StringField $rootPersonBody 'AppGenealogyRootPersonBody' 'birthDate' 10 10 'date'
|
|
Assert-StringField $rootPersonBody 'AppGenealogyRootPersonBody' 'biography' 1 200
|
|
if ($rootPersonBody) {
|
|
$sexValues = @($rootPersonBody.properties.sex.enum | Sort-Object)
|
|
$expectedSex = @('FEMALE', 'MALE', 'UNKNOWN') | Sort-Object
|
|
if ($rootPersonBody.properties.sex.type -ne 'string' -or
|
|
-not (Test-IsJsonArray $rootPersonBody.properties.sex.enum) -or
|
|
($sexValues -join ',') -ne ($expectedSex -join ',')) {
|
|
Add-Issue 'JSON AppGenealogyRootPersonBody.sex must be MALE/FEMALE/UNKNOWN'
|
|
}
|
|
$description = [string]$rootPersonBody.description
|
|
foreach ($pattern in @('(?i)server.*generation.*1', '(?i)no.*generationName', '(?i)Unicode code point.*NFC.*boundary whitespace')) {
|
|
if ($description -notmatch $pattern) { Add-Issue "JSON root-person description is missing invariant: $pattern" }
|
|
}
|
|
}
|
|
|
|
if ($operationKey) {
|
|
if ($operationKey.type -ne 'string' -or [int]$operationKey.minLength -ne 40 -or
|
|
[int]$operationKey.maxLength -ne 61 -or
|
|
[string]$operationKey.pattern -ne '^gcb\.[0-9]{13}\.[A-Za-z0-9_-]{22,43}$') {
|
|
Add-Issue 'JSON GenealogyBootstrapOperationKey must encode epoch milliseconds plus 128-bit-or-stronger CSPRNG material'
|
|
}
|
|
$keyDescription = [string]$operationKey.description
|
|
foreach ($pattern in @(
|
|
'(?i)issuedAt'
|
|
'(?i)128-bit.*CSPRNG'
|
|
'(?i)acceptUntil\s*=\s*issuedAt\s*\+\s*10 minutes'
|
|
'(?i)server time.*future.*5 minutes.*OPERATION_KEY_INVALID'
|
|
'(?i)existing operation.*replay.*after acceptUntil'
|
|
)) {
|
|
if ($keyDescription -notmatch $pattern) { Add-Issue "JSON operation-key description is missing invariant: $pattern" }
|
|
}
|
|
if ([int]$operationKey.'x-accept-window-seconds' -ne 600 -or
|
|
[int]$operationKey.'x-max-future-skew-seconds' -ne 300 -or
|
|
[string]$operationKey.'x-issued-at-source' -ne 'KEY_EPOCH_MILLISECONDS') {
|
|
Add-Issue 'JSON GenealogyBootstrapOperationKey must machine-bind issuedAt, the 600-second accept window, and 300-second future skew'
|
|
}
|
|
}
|
|
|
|
if ($regionCode) {
|
|
if ($regionCode.type -ne 'string' -or [int]$regionCode.minLength -ne 1 -or
|
|
[int]$regionCode.maxLength -ne 32 -or
|
|
[string]$regionCode.pattern -ne '^[A-Za-z0-9][A-Za-z0-9._~-]{0,31}$') {
|
|
Add-Issue 'JSON GenealogyRegionCode must be a bounded lexical identifier, not a JavaScript number'
|
|
}
|
|
}
|
|
|
|
if ($accessPreset) {
|
|
$values = @($accessPreset.enum | Sort-Object)
|
|
$expected = @('MEMBER_ONLY', 'PUBLIC_APPLY') | Sort-Object
|
|
if ($accessPreset.type -ne 'string' -or -not (Test-IsJsonArray $accessPreset.enum) -or
|
|
($values -join ',') -ne ($expected -join ',')) {
|
|
Add-Issue 'JSON GenealogyAccessPreset must contain only MEMBER_ONLY/PUBLIC_APPLY'
|
|
}
|
|
}
|
|
|
|
Assert-ExactObject $settingsBody 'AppGenealogySettingsUpdateBody' @(
|
|
'genealogyName', 'intro', 'accessPreset'
|
|
) @() 1 3
|
|
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
|
|
|
|
foreach ($legacySchema in @('GenealogyCreateBody', 'AppGenealogyCreateBody', 'GenealogyUpdateBody', 'AppGenealogyUpdateBody')) {
|
|
if ($document.components.schemas.PSObject.Properties[$legacySchema]) {
|
|
Add-Issue "JSON legacy schema must be removed in the same migration: $legacySchema"
|
|
}
|
|
}
|
|
|
|
$appGenealogy = $document.components.schemas.PSObject.Properties['AppGenealogyVo']
|
|
if (-not $appGenealogy) {
|
|
Add-Issue 'JSON missing AppGenealogyVo for the shared read contract'
|
|
} else {
|
|
$view = $appGenealogy.Value
|
|
Assert-PropertyRef $view 'AppGenealogyVo' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
|
|
if ('accessPreset' -notin @($view.required)) {
|
|
Add-Issue 'JSON AppGenealogyVo.accessPreset must be required for every APP read projection'
|
|
}
|
|
foreach ($legacyField in @('visibility', 'joinMode')) {
|
|
if ($view.properties.PSObject.Properties[$legacyField]) {
|
|
Add-Issue "JSON AppGenealogyVo must remove legacy access field: $legacyField"
|
|
}
|
|
}
|
|
}
|
|
|
|
# accessPreset owns the entire APP genealogy wire, including every AppGenealogy* model.
|
|
foreach ($schemaProperty in $document.components.schemas.PSObject.Properties) {
|
|
if ($schemaProperty.Name -notmatch '^AppGenealogy') { continue }
|
|
foreach ($legacyField in @('visibility', 'joinMode')) {
|
|
if (@(Get-ComposedPropertyDefinitions $schemaProperty.Value $legacyField @{}).Count -gt 0) {
|
|
Add-Issue "JSON $($schemaProperty.Name) retains legacy APP access field: $legacyField"
|
|
}
|
|
}
|
|
foreach ($presetDefinition in @(Get-ComposedPropertyDefinitions $schemaProperty.Value 'accessPreset' @{})) {
|
|
if ([string]$presetDefinition.'$ref' -ne '#/components/schemas/GenealogyAccessPreset') {
|
|
Add-Issue "JSON $($schemaProperty.Name).accessPreset must reference GenealogyAccessPreset"
|
|
}
|
|
}
|
|
}
|
|
|
|
Assert-ExactObject $bootstrapResult 'GenealogyBootstrapResult' @(
|
|
'genealogyId', 'rootPersonId', 'setupState', 'roleType', 'canView'
|
|
) @('genealogyId', 'rootPersonId', 'setupState', 'roleType', 'canView')
|
|
if ($bootstrapResult) {
|
|
foreach ($idField in @('genealogyId', 'rootPersonId')) {
|
|
$id = $bootstrapResult.properties.$idField
|
|
if ($id.type -ne 'string' -or [int]$id.minLength -ne 1 -or [int]$id.maxLength -ne 128 -or
|
|
[string]$id.pattern -ne '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$') {
|
|
Add-Issue "JSON GenealogyBootstrapResult.$idField must be a 1..128 URL-safe lexical string"
|
|
}
|
|
}
|
|
if ($bootstrapResult.properties.setupState.type -ne 'string' -or
|
|
-not (Test-IsJsonArray $bootstrapResult.properties.setupState.enum) -or
|
|
@($bootstrapResult.properties.setupState.enum).Count -ne 1 -or
|
|
($bootstrapResult.properties.setupState.enum -join ',') -ne 'READY') {
|
|
Add-Issue 'JSON GenealogyBootstrapResult.setupState must be the single value READY'
|
|
}
|
|
if ($bootstrapResult.properties.roleType.type -ne 'string' -or
|
|
-not (Test-IsJsonArray $bootstrapResult.properties.roleType.enum) -or
|
|
@($bootstrapResult.properties.roleType.enum).Count -ne 1 -or
|
|
($bootstrapResult.properties.roleType.enum -join ',') -ne 'OWNER') {
|
|
Add-Issue 'JSON GenealogyBootstrapResult.roleType must be the single value OWNER'
|
|
}
|
|
if ($bootstrapResult.properties.canView.type -ne 'boolean' -or
|
|
-not (Test-IsJsonArray $bootstrapResult.properties.canView.enum) -or
|
|
@($bootstrapResult.properties.canView.enum).Count -ne 1 -or
|
|
-not (Test-IsJsonBoolean @($bootstrapResult.properties.canView.enum)[0] $true)) {
|
|
Add-Issue 'JSON GenealogyBootstrapResult.canView must be the single boolean value true'
|
|
}
|
|
}
|
|
|
|
if ($operationStatus) {
|
|
$actualBranches = @($operationStatus.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object)
|
|
$expectedBranches = @(
|
|
'#/components/schemas/GenealogyBootstrapFailedStatus'
|
|
'#/components/schemas/GenealogyBootstrapPendingStatus'
|
|
'#/components/schemas/GenealogyBootstrapSucceededStatus'
|
|
) | Sort-Object
|
|
$mapping = $operationStatus.discriminator.mapping
|
|
$mappingPairs = if ($mapping) {
|
|
@($mapping.PSObject.Properties | ForEach-Object { "$($_.Name)=$($_.Value)" } | Sort-Object)
|
|
} else { @() }
|
|
$expectedMappingPairs = @(
|
|
'FAILED_NO_COMMIT=#/components/schemas/GenealogyBootstrapFailedStatus'
|
|
'PENDING=#/components/schemas/GenealogyBootstrapPendingStatus'
|
|
'SUCCEEDED=#/components/schemas/GenealogyBootstrapSucceededStatus'
|
|
) | Sort-Object
|
|
if (-not (Test-IsJsonArray $operationStatus.oneOf) -or
|
|
($actualBranches -join ',') -ne ($expectedBranches -join ',') -or
|
|
$operationStatus.discriminator.propertyName -ne 'status' -or
|
|
($mappingPairs -join ',') -ne ($expectedMappingPairs -join ',')) {
|
|
Add-Issue 'JSON GenealogyBootstrapOperationStatus must use three status-discriminated oneOf branches'
|
|
}
|
|
$transitions = $operationStatus.'x-state-transitions'
|
|
$transitionNames = if ($transitions) { @($transitions.PSObject.Properties.Name | Sort-Object) } else { @() }
|
|
$expectedTransitionNames = @('ABSENT', 'FAILED_NO_COMMIT', 'PENDING', 'SUCCEEDED') | Sort-Object
|
|
$absentRaw = if ($transitions) { $transitions.ABSENT } else { $null }
|
|
$pendingRaw = if ($transitions) { $transitions.PENDING } else { $null }
|
|
$succeededRaw = if ($transitions) { $transitions.SUCCEEDED } else { $null }
|
|
$failedRaw = if ($transitions) { $transitions.FAILED_NO_COMMIT } else { $null }
|
|
$absentTargets = @($absentRaw)
|
|
$pendingTargets = @($pendingRaw | Sort-Object)
|
|
$succeededTargets = @($succeededRaw)
|
|
$failedTargets = @($failedRaw)
|
|
if (($transitionNames -join ',') -ne ($expectedTransitionNames -join ',') -or
|
|
-not (Test-IsJsonArray $absentRaw) -or -not (Test-IsJsonArray $pendingRaw) -or
|
|
-not (Test-IsJsonArray $succeededRaw) -or -not (Test-IsJsonArray $failedRaw) -or
|
|
($absentTargets -join ',') -ne 'PENDING' -or
|
|
($pendingTargets -join ',') -ne 'FAILED_NO_COMMIT,SUCCEEDED' -or
|
|
$succeededTargets.Count -ne 0 -or $failedTargets.Count -ne 0 -or
|
|
-not (Test-IsJsonBoolean $operationStatus.'x-terminal-immutable' $true)) {
|
|
Add-Issue 'JSON bootstrap state machine must allow only ABSENT->PENDING->SUCCEEDED/FAILED_NO_COMMIT and make both terminal states immutable'
|
|
}
|
|
}
|
|
|
|
Assert-ExactObject $pendingStatus 'GenealogyBootstrapPendingStatus' @(
|
|
'status', 'resolveBy', 'retryAfterSeconds'
|
|
) @('status', 'resolveBy', 'retryAfterSeconds')
|
|
if ($pendingStatus) {
|
|
if ($pendingStatus.properties.status.type -ne 'string' -or
|
|
-not (Test-IsJsonArray $pendingStatus.properties.status.enum) -or
|
|
@($pendingStatus.properties.status.enum).Count -ne 1 -or
|
|
($pendingStatus.properties.status.enum -join ',') -ne 'PENDING' -or
|
|
$pendingStatus.properties.resolveBy.type -ne 'string' -or
|
|
$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 'JSON GenealogyBootstrapPendingStatus must require PENDING, RFC3339 resolveBy, and retryAfterSeconds=1..30'
|
|
}
|
|
if ([string]$pendingStatus.description -notmatch '(?i)resolveBy\s*<=\s*claimedAt\s*\+\s*2 minutes') {
|
|
Add-Issue 'JSON GenealogyBootstrapPendingStatus must bind resolveBy to a maximum two-minute claim SLA'
|
|
}
|
|
if ([int]$pendingStatus.'x-resolve-sla-seconds' -ne 120 -or
|
|
[string]$pendingStatus.'x-resolve-from' -ne 'CLAIMED_AT') {
|
|
Add-Issue 'JSON GenealogyBootstrapPendingStatus must machine-bind resolveBy to claimedAt plus at most 120 seconds'
|
|
}
|
|
}
|
|
Assert-ExactObject $succeededStatus 'GenealogyBootstrapSucceededStatus' @('status', 'result') @('status', 'result')
|
|
if ($succeededStatus) {
|
|
if ($succeededStatus.properties.status.type -ne 'string' -or
|
|
-not (Test-IsJsonArray $succeededStatus.properties.status.enum) -or
|
|
@($succeededStatus.properties.status.enum).Count -ne 1 -or
|
|
($succeededStatus.properties.status.enum -join ',') -ne 'SUCCEEDED' -or
|
|
[string]$succeededStatus.properties.result.'$ref' -ne '#/components/schemas/GenealogyBootstrapResult') {
|
|
Add-Issue 'JSON GenealogyBootstrapSucceededStatus must require SUCCEEDED plus the strict receipt'
|
|
}
|
|
}
|
|
Assert-ExactObject $failedStatus 'GenealogyBootstrapFailedStatus' @('status') @('status')
|
|
if ($failedStatus -and ($failedStatus.properties.status.type -ne 'string' -or
|
|
-not (Test-IsJsonArray $failedStatus.properties.status.enum) -or
|
|
@($failedStatus.properties.status.enum).Count -ne 1 -or
|
|
($failedStatus.properties.status.enum -join ',') -ne 'FAILED_NO_COMMIT')) {
|
|
Add-Issue 'JSON GenealogyBootstrapFailedStatus must contain only FAILED_NO_COMMIT'
|
|
}
|
|
if ($failedStatus -and ([string]$failedStatus.'x-domain-effects' -ne 'NONE' -or
|
|
-not (Test-IsJsonBoolean $failedStatus.'x-quota-consumed' $false))) {
|
|
Add-Issue 'JSON FAILED_NO_COMMIT must machine-guarantee zero domain effects and zero quota consumption'
|
|
}
|
|
|
|
foreach ($envelopeEntry in @(
|
|
[pscustomobject]@{ Schema = $resultEnvelope; Name = 'RAppGenealogyBootstrapResult'; DataRef = '#/components/schemas/GenealogyBootstrapResult' },
|
|
[pscustomobject]@{ Schema = $statusEnvelope; Name = 'RAppGenealogyBootstrapOperationStatus'; DataRef = '#/components/schemas/GenealogyBootstrapOperationStatus' }
|
|
)) {
|
|
$schema = $envelopeEntry.Schema
|
|
if (-not $schema) { continue }
|
|
Assert-ExactObject $schema $envelopeEntry.Name @('code', 'data') @('code', 'data')
|
|
if ($schema.properties.code.type -ne 'integer' -or
|
|
-not (Test-IsJsonArray $schema.properties.code.enum) -or
|
|
@($schema.properties.code.enum).Count -ne 1 -or
|
|
@($schema.properties.code.enum)[0] -ne 200 -or
|
|
[string]$schema.properties.data.'$ref' -ne $envelopeEntry.DataRef) {
|
|
Add-Issue "JSON $($envelopeEntry.Name) must expose fixed code=200 and typed data only"
|
|
}
|
|
}
|
|
|
|
Assert-ErrorSchema 'RGenealogyBootstrapBadRequest' 400 @('OPERATION_KEY_INVALID', 'REQUEST_MALFORMED')
|
|
Assert-ErrorSchema 'RGenealogyBootstrapStatusBadRequest' 400 @('OPERATION_KEY_INVALID')
|
|
Assert-ErrorSchema 'RGenealogyBootstrapUnauthorized' 401 @('AUTH_REQUIRED')
|
|
Assert-ErrorSchema 'RGenealogyBootstrapForbidden' 403 @('GENEALOGY_CREATE_FORBIDDEN')
|
|
Assert-ErrorSchema 'RGenealogyBootstrapConflict' 409 @(
|
|
'GENEALOGY_CREATE_LIMIT_REACHED', 'IDEMPOTENCY_KEY_REUSED', 'OPERATION_KEY_EXPIRED'
|
|
)
|
|
Assert-ErrorSchema 'RGenealogyBootstrapUnprocessable' 422 @('ACCESS_PRESET_INVALID', 'REGION_NOT_SELECTABLE', 'ROOT_PERSON_INVALID')
|
|
Assert-ErrorSchema 'RGenealogyBootstrapRateLimited' 429 @('RATE_LIMITED')
|
|
Assert-ErrorSchema 'RGenealogyBootstrapOutcomeUnknown' 500 @('BOOTSTRAP_OUTCOME_UNKNOWN')
|
|
Assert-ErrorSchema 'RGenealogyBootstrapStatusUnavailable' 500 @('BOOTSTRAP_STATUS_UNAVAILABLE')
|
|
Assert-ErrorSchema 'RLineageRootConflict' 409 @('GENEALOGY_NOT_READY')
|
|
Assert-ErrorSchema 'RLineageRootUnprocessable' 422 @('BOOTSTRAP_ROOT_IMMUTABLE')
|
|
$operationNotFound = Get-Schema 'RGenealogyBootstrapOperationNotFound'
|
|
Assert-ExactObject $operationNotFound 'RGenealogyBootstrapOperationNotFound' @(
|
|
'code', 'businessCode', 'acceptUntil'
|
|
) @('code', 'businessCode', 'acceptUntil')
|
|
if ($operationNotFound) {
|
|
if ($operationNotFound.properties.code.type -ne 'integer' -or
|
|
-not (Test-IsJsonArray $operationNotFound.properties.code.enum) -or
|
|
@($operationNotFound.properties.code.enum).Count -ne 1 -or
|
|
@($operationNotFound.properties.code.enum)[0] -ne 404 -or
|
|
$operationNotFound.properties.businessCode.type -ne 'string' -or
|
|
-not (Test-IsJsonArray $operationNotFound.properties.businessCode.enum) -or
|
|
@($operationNotFound.properties.businessCode.enum).Count -ne 1 -or
|
|
($operationNotFound.properties.businessCode.enum -join ',') -ne 'BOOTSTRAP_OPERATION_NOT_AVAILABLE' -or
|
|
$operationNotFound.properties.acceptUntil.type -ne 'string' -or
|
|
$operationNotFound.properties.acceptUntil.format -ne 'date-time') {
|
|
Add-Issue 'JSON RGenealogyBootstrapOperationNotFound must expose the non-secret server acceptUntil boundary'
|
|
}
|
|
}
|
|
|
|
if ($regionOperation) {
|
|
$keyword = Get-Parameter $regionOperation 'keyword' 'query' $regionSearchPath
|
|
if ($keyword -and (-not (Test-IsJsonBoolean $keyword.required $true) -or $keyword.schema.type -ne 'string' -or
|
|
[int]$keyword.schema.minLength -ne 1 -or [int]$keyword.schema.maxLength -lt 1 -or
|
|
[int]$keyword.schema.maxLength -gt 50)) {
|
|
Add-Issue 'JSON region search keyword must be a bounded required string'
|
|
}
|
|
$region200 = Get-Response $regionOperation "GET $regionSearchPath" '200'
|
|
if ((Get-JsonResponseRef $region200 "GET $regionSearchPath" '200') -ne '#/components/schemas/RListRegionSelectVo') {
|
|
Add-Issue 'JSON region search 200 must return RListRegionSelectVo'
|
|
}
|
|
Assert-AllDeclaredResponsesPrivateJson $regionOperation "GET $regionSearchPath"
|
|
}
|
|
|
|
if ($mineOperation) {
|
|
$mine200 = Get-Response $mineOperation "GET $minePath" '200'
|
|
if ((Get-JsonResponseRef $mine200 "GET $minePath" '200') -ne '#/components/schemas/RListAppGenealogyVo') {
|
|
Add-Issue 'JSON mine read must return RListAppGenealogyVo so accessPreset reaches the actual list consumer'
|
|
}
|
|
Assert-AllDeclaredResponsesPrivateJson $mineOperation "GET $minePath"
|
|
}
|
|
if ($overviewOperation) {
|
|
$overview200 = Get-Response $overviewOperation "GET $overviewPath" '200'
|
|
if ((Get-JsonResponseRef $overview200 "GET $overviewPath" '200') -ne '#/components/schemas/RAppGenealogyVo') {
|
|
Add-Issue 'JSON overview read must return RAppGenealogyVo so accessPreset reaches the actual detail consumer'
|
|
}
|
|
Assert-AllDeclaredResponsesPrivateJson $overviewOperation "GET $overviewPath"
|
|
}
|
|
$listGenealogyEnvelope = $document.components.schemas.PSObject.Properties['RListAppGenealogyVo']
|
|
$objectGenealogyEnvelope = $document.components.schemas.PSObject.Properties['RAppGenealogyVo']
|
|
if (-not $listGenealogyEnvelope) {
|
|
Add-Issue 'JSON missing RListAppGenealogyVo for the actual mine read'
|
|
} elseif (
|
|
[string]$listGenealogyEnvelope.Value.properties.data.items.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
|
|
Add-Issue 'JSON RListAppGenealogyVo.data items must reference AppGenealogyVo'
|
|
}
|
|
if (-not $objectGenealogyEnvelope) {
|
|
Add-Issue 'JSON missing RAppGenealogyVo for the actual overview read'
|
|
} elseif (
|
|
[string]$objectGenealogyEnvelope.Value.properties.data.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
|
|
Add-Issue 'JSON RAppGenealogyVo.data must reference AppGenealogyVo'
|
|
}
|
|
if ($regionItem) {
|
|
foreach ($required in @('regionCode', 'label', 'selectable')) {
|
|
if ($required -notin @($regionItem.required)) { Add-Issue "JSON RegionSelectVo.required missing: $required" }
|
|
}
|
|
if ([string]$regionItem.properties.regionCode.'$ref' -ne '#/components/schemas/GenealogyRegionCode') {
|
|
Add-Issue 'JSON RegionSelectVo.regionCode must use GenealogyRegionCode'
|
|
}
|
|
if ($regionItem.properties.label.type -ne 'string' -or [int]$regionItem.properties.label.minLength -lt 1) {
|
|
Add-Issue 'JSON RegionSelectVo.label must be a non-empty string'
|
|
}
|
|
if ($regionItem.properties.selectable.type -ne 'boolean') {
|
|
Add-Issue 'JSON RegionSelectVo.selectable must be boolean and must not be inferred from leaf'
|
|
}
|
|
}
|
|
if ($regionEnvelope) {
|
|
foreach ($required in @('code', 'data')) {
|
|
if ($required -notin @($regionEnvelope.required)) { Add-Issue "JSON RListRegionSelectVo.required missing: $required" }
|
|
}
|
|
if ($regionEnvelope.properties.code.type -ne 'integer' -or
|
|
$regionEnvelope.properties.data.type -ne 'array' -or
|
|
[string]$regionEnvelope.properties.data.items.'$ref' -ne '#/components/schemas/RegionSelectVo') {
|
|
Add-Issue 'JSON RListRegionSelectVo must expose integer code and RegionSelectVo[] data'
|
|
}
|
|
}
|
|
|
|
$createResponses = @{}
|
|
Assert-ExactResponseSet $createOperation "POST $createPath" @('200', '400', '401', '403', '409', '422', '429', '500')
|
|
foreach ($status in @('200', '400', '401', '403', '409', '422', '429', '500')) {
|
|
$createResponses[$status] = Get-Response $createOperation "POST $createPath" $status
|
|
[void](Get-JsonResponseRef $createResponses[$status] "POST $createPath" $status)
|
|
Assert-PrivateNoStore $createResponses[$status] "POST $createPath" $status
|
|
}
|
|
if ((Get-JsonResponseRef $createResponses['200'] "POST $createPath" '200') -ne '#/components/schemas/RAppGenealogyBootstrapResult') {
|
|
Add-Issue 'JSON create 200 must return RAppGenealogyBootstrapResult'
|
|
}
|
|
$createErrorRefs = @{
|
|
'400' = '#/components/schemas/RGenealogyBootstrapBadRequest'
|
|
'401' = '#/components/schemas/RGenealogyBootstrapUnauthorized'
|
|
'403' = '#/components/schemas/RGenealogyBootstrapForbidden'
|
|
'409' = '#/components/schemas/RGenealogyBootstrapConflict'
|
|
'422' = '#/components/schemas/RGenealogyBootstrapUnprocessable'
|
|
'429' = '#/components/schemas/RGenealogyBootstrapRateLimited'
|
|
'500' = '#/components/schemas/RGenealogyBootstrapOutcomeUnknown'
|
|
}
|
|
foreach ($status in $createErrorRefs.Keys) {
|
|
$actualRef = Get-JsonResponseRef $createResponses[$status] "POST $createPath" $status
|
|
if ($actualRef -ne $createErrorRefs[$status]) {
|
|
Add-Issue "JSON create $status must return $($createErrorRefs[$status]); actual: $actualRef"
|
|
}
|
|
}
|
|
Assert-RetryAfter $createResponses['429'] "POST $createPath" '429'
|
|
|
|
$statusResponses = @{}
|
|
Assert-ExactResponseSet $statusOperation "GET $statusPath" @('200', '400', '401', '404', '429', '500')
|
|
foreach ($status in @('200', '400', '401', '404', '429', '500')) {
|
|
$statusResponses[$status] = Get-Response $statusOperation "GET $statusPath" $status
|
|
[void](Get-JsonResponseRef $statusResponses[$status] "GET $statusPath" $status)
|
|
Assert-PrivateNoStore $statusResponses[$status] "GET $statusPath" $status
|
|
}
|
|
if ((Get-JsonResponseRef $statusResponses['200'] "GET $statusPath" '200') -ne '#/components/schemas/RAppGenealogyBootstrapOperationStatus') {
|
|
Add-Issue 'JSON bootstrap status 200 must return RAppGenealogyBootstrapOperationStatus'
|
|
}
|
|
$statusErrorRefs = @{
|
|
'400' = '#/components/schemas/RGenealogyBootstrapStatusBadRequest'
|
|
'401' = '#/components/schemas/RGenealogyBootstrapUnauthorized'
|
|
'404' = '#/components/schemas/RGenealogyBootstrapOperationNotFound'
|
|
'429' = '#/components/schemas/RGenealogyBootstrapRateLimited'
|
|
'500' = '#/components/schemas/RGenealogyBootstrapStatusUnavailable'
|
|
}
|
|
foreach ($status in $statusErrorRefs.Keys) {
|
|
$actualRef = Get-JsonResponseRef $statusResponses[$status] "GET $statusPath" $status
|
|
if ($actualRef -ne $statusErrorRefs[$status]) {
|
|
Add-Issue "JSON bootstrap status $status must return $($statusErrorRefs[$status]); actual: $actualRef"
|
|
}
|
|
}
|
|
Assert-RetryAfter $statusResponses['404'] "GET $statusPath" '404'
|
|
Assert-RetryAfter $statusResponses['429'] "GET $statusPath" '429'
|
|
|
|
Assert-AllDeclaredResponsesPrivateJson $settingsOperation "PUT $settingsPath"
|
|
Assert-AllDeclaredResponsesPrivateJson $personOperation "POST $personPath"
|
|
Assert-AllDeclaredResponsesPrivateJson $personUpdateOperation "PUT $personDetailPath"
|
|
Assert-AllDeclaredResponsesPrivateJson $personDeleteOperation "DELETE $personDetailPath"
|
|
Assert-AllDeclaredResponsesPrivateJson $personParentsOperation "POST $personParentsPath"
|
|
|
|
if ($issues.Count -gt 0) {
|
|
$lines = New-Object System.Collections.Generic.List[string]
|
|
$lines.Add('G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED')
|
|
foreach ($issue in $issues) { $lines.Add("- $issue") }
|
|
$lines.Add('- Replace the existing create body with one closed atomic bootstrap body; the first in-page step performs no network write.')
|
|
$lines.Add('- One transaction must create the genealogy, OWNER membership, unique generation-one root, READY state, and idempotency receipt, or roll everything back.')
|
|
$lines.Add('- Use one GenealogyAccessPreset across APP read/create/update and remove visibility/joinMode in the same backend version; do not keep parallel mappings.')
|
|
$lines.Add('- Persist only operationKey/sessionEpoch/startedAt on device; use the authenticated status operation after process death instead of storing ancestor PII or guessing from /mine.')
|
|
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
|
|
throw ($lines -join [Environment]::NewLine)
|
|
}
|
|
|
|
Write-Output 'G03-BOOTSTRAP-OPENAPI-CONTRACT PASS'
|