875 lines
42 KiB
PowerShell
875 lines
42 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]
|
|
|
|
$settingsPath = '/genealogy/app/genealogies/{genealogyId}'
|
|
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
|
|
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
|
|
$genealogyNamePattern = '^[^\s\u0000-\u001F\u007F-\u009F](?:[^\r\n\u0000-\u001F\u007F-\u009F\u2028\u2029]*[^\s\u0000-\u001F\u007F-\u009F])?$'
|
|
$genealogyIntroPattern = '^[^\s\u0000-\u001F\u007F-\u009F](?:[^\r\u0000-\u0009\u000B-\u001F\u007F-\u009F\u2028\u2029]*[^\s\u0000-\u001F\u007F-\u009F])?$'
|
|
|
|
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
|
|
}
|
|
|
|
function Test-IsNonNullable {
|
|
param([object]$Schema)
|
|
if (-not $Schema) { return $false }
|
|
$nullable = $Schema.PSObject.Properties['nullable']
|
|
return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false)
|
|
}
|
|
|
|
function Assert-NoConflictingSchemaKeywords {
|
|
param(
|
|
[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 "JSON $Label must not define conflicting schema keyword: $keyword"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-AllowedSchemaKeywords {
|
|
param([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 "JSON $Label contains an unowned schema keyword: $($property.Name)"
|
|
}
|
|
}
|
|
|
|
function Test-IsPureSchemaRef {
|
|
param([object]$Schema, [string]$ExpectedRef, [string]$Label)
|
|
if (-not $Schema) { return $false }
|
|
$properties = @($Schema.PSObject.Properties.Name)
|
|
$actualRef = [string]$Schema.'$ref'
|
|
if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
|
|
Add-Issue "JSON $Label must be the sole exact local schema ref $ExpectedRef; actual: $actualRef"
|
|
return $false
|
|
}
|
|
[void](Get-LocalComponentName $actualRef 'schemas' $Label)
|
|
return $true
|
|
}
|
|
|
|
function Get-LocalComponentName {
|
|
param([string]$Ref, [string]$Section, [string]$Label)
|
|
$pattern = '^#/components/' + [regex]::Escape($Section) + '/(?<name>[^/]+)$'
|
|
$match = [regex]::Match($Ref, $pattern)
|
|
if (-not $match.Success) {
|
|
Add-Issue "JSON $Label must use an exact local #/components/$Section/... ref; actual: $Ref"
|
|
return ''
|
|
}
|
|
return $match.Groups['name'].Value
|
|
}
|
|
|
|
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-Operation {
|
|
param([string]$Path, [string]$Method)
|
|
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
|
if (-not $pathProperty) {
|
|
Add-Issue "JSON missing path: $Path"
|
|
return $null
|
|
}
|
|
$operationProperty = $pathProperty.Value.PSObject.Properties[$Method]
|
|
if (-not $operationProperty) {
|
|
Add-Issue "JSON missing operation: $($Method.ToUpper()) $Path"
|
|
return $null
|
|
}
|
|
return $operationProperty.Value
|
|
}
|
|
|
|
function Resolve-Parameter {
|
|
param([object]$Parameter, [string]$Label)
|
|
if (-not $Parameter) { return $null }
|
|
if (-not $Parameter.'$ref') { return $Parameter }
|
|
$parameterRefSiblings = @($Parameter.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
|
|
if ($parameterRefSiblings.Count -gt 0) {
|
|
Add-Issue "JSON $Label parameter ref contains semantic sibling keywords: $($parameterRefSiblings -join ',')"
|
|
}
|
|
$name = Get-LocalComponentName ([string]$Parameter.'$ref') 'parameters' $Label
|
|
if (-not $name) { return $null }
|
|
$owner = $document.components.parameters.PSObject.Properties[$name]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON $Label references missing parameter owner: $name"
|
|
return $null
|
|
}
|
|
return $owner.Value
|
|
}
|
|
|
|
function Resolve-Response {
|
|
param([object]$Response, [string]$Label)
|
|
if (-not $Response) { return $null }
|
|
if (-not $Response.'$ref') { return $Response }
|
|
$responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
|
|
if ($responseRefSiblings.Count -gt 0) {
|
|
Add-Issue "JSON $Label response ref contains semantic sibling keywords: $($responseRefSiblings -join ',')"
|
|
}
|
|
$name = Get-LocalComponentName ([string]$Response.'$ref') 'responses' $Label
|
|
if (-not $name) { return $null }
|
|
$owner = $document.components.responses.PSObject.Properties[$name]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON $Label references missing response owner: $name"
|
|
return $null
|
|
}
|
|
return $owner.Value
|
|
}
|
|
|
|
function Resolve-Header {
|
|
param([object]$Header, [string]$Label)
|
|
if (-not $Header) { return $null }
|
|
if (-not $Header.'$ref') { return $Header }
|
|
$headerRefSiblings = @($Header.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
|
|
if ($headerRefSiblings.Count -gt 0) {
|
|
Add-Issue "JSON $Label header ref contains semantic sibling keywords: $($headerRefSiblings -join ',')"
|
|
}
|
|
$name = Get-LocalComponentName ([string]$Header.'$ref') 'headers' $Label
|
|
if (-not $name) { return $null }
|
|
$owner = $document.components.headers.PSObject.Properties[$name]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON $Label references missing header owner: $name"
|
|
return $null
|
|
}
|
|
return $owner.Value
|
|
}
|
|
|
|
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 response: $Status"
|
|
return $null
|
|
}
|
|
return Resolve-Response $property.Value "$Label $Status"
|
|
}
|
|
|
|
function Get-JsonResponseRef {
|
|
param([object]$Response, [string]$Label, [string]$Status)
|
|
if (-not $Response) { return '' }
|
|
$media = @($Response.content.PSObject.Properties)
|
|
if ($media.Count -ne 1 -or $media[0].Name -ne 'application/json') {
|
|
Add-Issue "JSON $Label $Status must expose only application/json"
|
|
return ''
|
|
}
|
|
$schema = $media[0].Value.schema
|
|
$ref = [string]$schema.'$ref'
|
|
if (-not $ref) {
|
|
Add-Issue "JSON $Label $Status must use a component schema ref"
|
|
} else {
|
|
[void](Get-LocalComponentName $ref 'schemas' "$Label $Status response schema")
|
|
$schemaKeywords = @($schema.PSObject.Properties.Name)
|
|
if ($schemaKeywords.Count -ne 1 -or $schemaKeywords[0] -cne '$ref') {
|
|
Add-Issue "JSON $Label $Status response schema must contain only its exact local schema ref"
|
|
}
|
|
}
|
|
return $ref
|
|
}
|
|
|
|
function Assert-ExactResponseSet {
|
|
param([object]$Operation, [string]$Label, [string[]]$Expected)
|
|
if (-not $Operation) { return }
|
|
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
|
|
$wanted = @($Expected | Sort-Object)
|
|
if (($actual -join ',') -ne ($wanted -join ',')) {
|
|
Add-Issue "JSON $Label responses must be exactly $($wanted -join ','); actual: $($actual -join ',')"
|
|
}
|
|
}
|
|
|
|
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 = Resolve-Header $property.Value "$Label $Status Cache-Control"
|
|
if (-not $header) { return }
|
|
$values = @($header.schema.enum)
|
|
if ($header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema) -or
|
|
-not (Test-IsJsonArray $header.schema.enum) -or $values.Count -ne 1 -or
|
|
[string]$values[0] -cne 'private, no-store') {
|
|
Add-Issue "JSON $Label $Status Cache-Control must be fixed by a single enum value: private, no-store"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('enum')
|
|
Assert-AllowedSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('type', 'enum', 'nullable')
|
|
}
|
|
|
|
function Assert-RequiredResponseHeaders {
|
|
param([object]$Response, [string]$Label, [string]$Status)
|
|
if (-not $Response) { return }
|
|
$headerNames = if ($Response.headers) { @($Response.headers.PSObject.Properties.Name) } else { @() }
|
|
if ('Cache-Control' -notin $headerNames) {
|
|
Add-Issue "JSON $Label $Status response headers must include Cache-Control"
|
|
}
|
|
if ($Status -eq '429' -and 'Retry-After' -notin $headerNames) {
|
|
Add-Issue "JSON $Label $Status response headers must include Retry-After"
|
|
}
|
|
if (@($headerNames | Where-Object { $_ -ieq 'ETag' }).Count -gt 0) {
|
|
Add-Issue "JSON $Label $Status must not publish ETag; settings concurrency has one If-Match/settingsVersion owner"
|
|
}
|
|
$allowedHeaders = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')
|
|
if ($Status -eq '429') { $allowedHeaders += 'Retry-After' }
|
|
$unexpectedHeaders = @($headerNames | Where-Object { $_ -notin $allowedHeaders })
|
|
if ($unexpectedHeaders.Count -gt 0) {
|
|
Add-Issue "JSON $Label $Status response headers may add only traceparent/tracestate/x-request-id/x-correlation-id tracing headers; unexpected: $($unexpectedHeaders -join ',')"
|
|
}
|
|
foreach ($traceName in @('traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')) {
|
|
$property = if ($Response.headers) { $Response.headers.PSObject.Properties[$traceName] } else { $null }
|
|
if (-not $property) { continue }
|
|
$header = Resolve-Header $property.Value "$Label $Status $traceName"
|
|
if (-not $header) { continue }
|
|
foreach ($headerProperty in @($header.PSObject.Properties)) {
|
|
if ($headerProperty.Name -like 'x-*' -or $headerProperty.Name -in @('description', 'deprecated', 'schema')) { continue }
|
|
Add-Issue "JSON $Label $Status $traceName contains an unowned Header Object keyword: $($headerProperty.Name)"
|
|
}
|
|
if (-not $header.schema -or $header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema)) {
|
|
Add-Issue "JSON $Label $Status $traceName must resolve to a Header Object with a non-null string schema"
|
|
continue
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status $traceName schema"
|
|
Assert-AllowedSchemaKeywords $header.schema "$Label $Status $traceName schema" @('type', 'nullable')
|
|
}
|
|
}
|
|
|
|
function Assert-RetryAfter {
|
|
param([object]$Response, [string]$Label)
|
|
if (-not $Response) { return }
|
|
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Retry-After'] } else { $null }
|
|
if (-not $property) {
|
|
Add-Issue "JSON $Label must document Retry-After"
|
|
return
|
|
}
|
|
$header = Resolve-Header $property.Value "$Label Retry-After"
|
|
if (-not $header) { return }
|
|
if ($header.schema.type -ne 'integer' -or -not (Test-IsNonNullable $header.schema) -or
|
|
[int]$header.schema.minimum -ne 1 -or
|
|
[int]$header.schema.maximum -lt 1 -or [int]$header.schema.maximum -gt 300) {
|
|
Add-Issue "JSON $Label Retry-After must be an integer in a bounded 1..300 second range"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $header.schema "$Label Retry-After schema"
|
|
Assert-AllowedSchemaKeywords $header.schema "$Label Retry-After schema" @('type', 'minimum', 'maximum', 'nullable')
|
|
}
|
|
|
|
function Assert-SaToken {
|
|
param([object]$Operation, [string]$Label)
|
|
if (-not $Operation) { return }
|
|
$requirements = @($Operation.security)
|
|
if ($requirements.Count -ne 1) {
|
|
Add-Issue "JSON $Label must have exactly one SaToken security requirement"
|
|
return
|
|
}
|
|
$names = @($requirements[0].PSObject.Properties.Name)
|
|
if ($names.Count -ne 1 -or $names[0] -ne 'SaToken') {
|
|
Add-Issue "JSON $Label must require only SaToken"
|
|
}
|
|
}
|
|
|
|
function Get-OperationParameters {
|
|
param([string]$Path, [object]$Operation, [string]$Label)
|
|
$parameters = @()
|
|
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
|
if ($pathProperty -and $pathProperty.Value.parameters) {
|
|
foreach ($parameter in @($pathProperty.Value.parameters)) {
|
|
$resolved = Resolve-Parameter $parameter "$Label path parameter"
|
|
if ($resolved) { $parameters += $resolved }
|
|
}
|
|
}
|
|
if ($Operation -and $Operation.parameters) {
|
|
foreach ($parameter in @($Operation.parameters)) {
|
|
$resolved = Resolve-Parameter $parameter "$Label operation parameter"
|
|
if ($resolved) { $parameters += $resolved }
|
|
}
|
|
}
|
|
return $parameters
|
|
}
|
|
|
|
function Get-Parameter {
|
|
param([object[]]$Parameters, [string]$Name, [string]$In, [string]$Label)
|
|
$matches = @($Parameters | Where-Object { $_.name -eq $Name -and $_.in -eq $In })
|
|
if ($matches.Count -ne 1) {
|
|
Add-Issue "JSON $Label must declare exactly one $In parameter: $Name"
|
|
return $null
|
|
}
|
|
return $matches[0]
|
|
}
|
|
|
|
function Assert-ExactParameters {
|
|
param([object[]]$Parameters, [string]$Label, [string[]]$Expected)
|
|
$actual = @($Parameters | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
|
|
$wanted = @($Expected | Sort-Object)
|
|
if (($actual -join ',') -ne ($wanted -join ',')) {
|
|
Add-Issue "JSON $Label parameters must be exactly $($wanted -join ','); actual: $($actual -join ',')"
|
|
}
|
|
}
|
|
|
|
function Assert-ExactObject {
|
|
param(
|
|
[object]$Schema,
|
|
[string]$Name,
|
|
[string[]]$Properties,
|
|
[string[]]$Required,
|
|
[int]$MinProperties = -1,
|
|
[int]$MaxProperties = -1
|
|
)
|
|
if (-not $Schema) { return }
|
|
$actualProperties = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
|
|
$expectedProperties = @($Properties | Sort-Object)
|
|
$actualRequired = @($Schema.required | Sort-Object)
|
|
$expectedRequired = @($Required | Sort-Object)
|
|
if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or
|
|
-not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
|
|
($actualProperties -join ',') -ne ($expectedProperties -join ',') -or
|
|
($actualRequired -join ',') -ne ($expectedRequired -join ',')) {
|
|
Add-Issue "JSON $Name must be a closed object with properties [$($expectedProperties -join ',')] and required [$($expectedRequired -join ',')]"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $Schema $Name
|
|
$allowedObjectKeywords = @('type', 'properties', 'required', 'additionalProperties', 'nullable')
|
|
if ($MinProperties -ge 0) { $allowedObjectKeywords += 'minProperties' }
|
|
if ($MaxProperties -ge 0) { $allowedObjectKeywords += 'maxProperties' }
|
|
Assert-AllowedSchemaKeywords $Schema $Name $allowedObjectKeywords
|
|
if ($MinProperties -ge 0 -and [int]$Schema.minProperties -ne $MinProperties) {
|
|
Add-Issue "JSON $Name.minProperties must be $MinProperties"
|
|
}
|
|
if ($MaxProperties -ge 0 -and [int]$Schema.maxProperties -ne $MaxProperties) {
|
|
Add-Issue "JSON $Name.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]
|
|
if (-not $property) {
|
|
Add-Issue "JSON $SchemaName missing property: $Field"
|
|
return
|
|
}
|
|
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
|
|
}
|
|
|
|
function Assert-FixedError {
|
|
param(
|
|
[object]$Schema,
|
|
[string]$Name,
|
|
[int]$Status,
|
|
[string]$BusinessCode,
|
|
[switch]$Current
|
|
)
|
|
$properties = @('code', 'businessCode')
|
|
if ($Current) { $properties += 'current' }
|
|
Assert-ExactObject $Schema $Name $properties $properties
|
|
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 -or
|
|
$Schema.properties.businessCode.type -ne 'string' -or
|
|
-not (Test-IsNonNullable $Schema.properties.businessCode) -or
|
|
-not (Test-IsJsonArray $Schema.properties.businessCode.enum) -or
|
|
@($Schema.properties.businessCode.enum).Count -ne 1 -or
|
|
@($Schema.properties.businessCode.enum)[0] -ne $BusinessCode) {
|
|
Add-Issue "JSON $Name must fix code=$Status and businessCode=$BusinessCode"
|
|
}
|
|
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 ($Current) { [void](Test-IsPureSchemaRef $Schema.properties.current '#/components/schemas/AppGenealogyVo' "$Name.current") }
|
|
}
|
|
|
|
function Get-RequestBodySchemas {
|
|
param([object]$Operation, [string]$Label)
|
|
if (-not $Operation -or -not $Operation.requestBody) { return @() }
|
|
$requestBody = $Operation.requestBody
|
|
if ($requestBody.'$ref') {
|
|
$name = Get-LocalComponentName ([string]$requestBody.'$ref') 'requestBodies' "$Label request body"
|
|
if (-not $name) { return @() }
|
|
$owner = $document.components.requestBodies.PSObject.Properties[$name]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON $Label references missing request body owner: $name"
|
|
return @()
|
|
}
|
|
$requestBody = $owner.Value
|
|
}
|
|
if (-not $requestBody.content) { return @() }
|
|
return @($requestBody.content.PSObject.Properties | ForEach-Object { $_.Value.schema } | Where-Object { $_ })
|
|
}
|
|
|
|
function Test-SchemaContainsSettingsContract {
|
|
param(
|
|
[object]$Schema,
|
|
[hashtable]$Seen,
|
|
[string]$Label,
|
|
[bool]$AllowInlineIntro,
|
|
[bool]$AtRequestRoot = $true
|
|
)
|
|
if (-not $Schema) { return $false }
|
|
|
|
$ref = [string]$Schema.'$ref'
|
|
if ($ref) {
|
|
$leaf = @($ref -split '/')[-1]
|
|
if ($leaf -ceq 'AppGenealogySettingsUpdateBody') {
|
|
if ($ref -cne '#/components/schemas/AppGenealogySettingsUpdateBody') {
|
|
[void](Get-LocalComponentName $ref 'schemas' $Label)
|
|
}
|
|
return $true
|
|
}
|
|
$name = Get-LocalComponentName $ref 'schemas' $Label
|
|
if (-not $name) { return $false }
|
|
if ($Seen.ContainsKey($name)) { return $false }
|
|
$Seen[$name] = $true
|
|
$owner = $document.components.schemas.PSObject.Properties[$name]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON $Label references missing schema owner: $name"
|
|
return $false
|
|
}
|
|
return Test-SchemaContainsSettingsContract $owner.Value $Seen "$Label -> $name" $AllowInlineIntro $AtRequestRoot
|
|
}
|
|
|
|
if ($Schema.properties) {
|
|
$sharedOwners = @{
|
|
genealogyName = '#/components/schemas/GenealogyName'
|
|
intro = '#/components/schemas/GenealogyIntro'
|
|
accessPreset = '#/components/schemas/GenealogyAccessPreset'
|
|
}
|
|
foreach ($field in $sharedOwners.Keys) {
|
|
$property = $Schema.properties.PSObject.Properties[$field]
|
|
if (-not $property) { continue }
|
|
$propertyKeywords = @($property.Value.PSObject.Properties.Name)
|
|
if ($propertyKeywords.Count -eq 1 -and $propertyKeywords[0] -ceq '$ref' -and
|
|
[string]$property.Value.'$ref' -ceq $sharedOwners[$field]) {
|
|
return $true
|
|
}
|
|
if ($AtRequestRoot -and ($field -ne 'intro' -or $AllowInlineIntro)) { return $true }
|
|
}
|
|
}
|
|
|
|
foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) {
|
|
foreach ($branch in @($Schema.$keyword)) {
|
|
if (Test-SchemaContainsSettingsContract $branch $Seen "$Label $keyword" $AllowInlineIntro $AtRequestRoot) { return $true }
|
|
}
|
|
}
|
|
if ($Schema.items -and (Test-SchemaContainsSettingsContract $Schema.items $Seen "$Label items" $AllowInlineIntro $false)) { return $true }
|
|
if ($Schema.properties) {
|
|
foreach ($property in @($Schema.properties.PSObject.Properties)) {
|
|
if (Test-SchemaContainsSettingsContract $property.Value $Seen "$Label.$($property.Name)" $AllowInlineIntro $false) { return $true }
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Assert-GlobalSettingsWriteOwner {
|
|
$methods = @('get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace')
|
|
$writeMethods = @('post', 'put', 'patch', 'delete')
|
|
$settingsOwners = New-Object System.Collections.Generic.List[string]
|
|
$operationIdOwners = New-Object System.Collections.Generic.List[string]
|
|
|
|
foreach ($pathProperty in @($document.paths.PSObject.Properties)) {
|
|
foreach ($methodProperty in @($pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -in $methods })) {
|
|
$method = [string]$methodProperty.Name
|
|
$operation = $methodProperty.Value
|
|
$label = "$($method.ToUpperInvariant()) $($pathProperty.Name)"
|
|
if ([string]$operation.operationId -ceq 'appUpdateGenealogySettings') {
|
|
$operationIdOwners.Add($label)
|
|
}
|
|
if ($method -notin $writeMethods) { continue }
|
|
|
|
$isSettingsOwner = $method -eq 'put' -and $pathProperty.Name -ceq $settingsPath
|
|
$settingsSemanticPath = $pathProperty.Name -match '(?i)/genealogies(?:/\{[^}]+\})?/settings(?:[-_/]|$)'
|
|
if ($settingsSemanticPath -or
|
|
[string]$operation.operationId -ceq 'appUpdateGenealogySettings') {
|
|
$isSettingsOwner = $true
|
|
}
|
|
$isGenealogyCreate = $method -eq 'post' -and
|
|
$pathProperty.Name -ceq '/genealogy/app/genealogies'
|
|
if (-not $isGenealogyCreate) {
|
|
if ($operation.requestBody -and $operation.requestBody.'$ref') {
|
|
$requestBodyLeaf = @(([string]$operation.requestBody.'$ref') -split '/')[-1]
|
|
if ($requestBodyLeaf -ceq 'AppGenealogySettingsUpdateBody') {
|
|
$isSettingsOwner = $true
|
|
}
|
|
}
|
|
$allowInlineIntro = $settingsSemanticPath -or
|
|
$pathProperty.Name -match '^/genealogy/app/genealogies(?:/|$)'
|
|
foreach ($schema in @(Get-RequestBodySchemas $operation $label)) {
|
|
if (Test-SchemaContainsSettingsContract $schema @{} "$label request schema" $allowInlineIntro $true) {
|
|
$isSettingsOwner = $true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if ($isSettingsOwner) { $settingsOwners.Add("$method $($pathProperty.Name)") }
|
|
}
|
|
}
|
|
|
|
$uniqueSettingsOwners = @($settingsOwners | Sort-Object -Unique)
|
|
$expected = "put $settingsPath"
|
|
if ($uniqueSettingsOwners.Count -ne 1 -or $uniqueSettingsOwners[0] -cne $expected) {
|
|
Add-Issue "JSON settings write contract must have exactly one global owner ($expected); actual: $($uniqueSettingsOwners -join ',')"
|
|
}
|
|
if ($operationIdOwners.Count -ne 1 -or $operationIdOwners[0] -cne "PUT $settingsPath") {
|
|
Add-Issue "JSON operationId appUpdateGenealogySettings must be globally unique on PUT $settingsPath; actual: $($operationIdOwners -join ',')"
|
|
}
|
|
}
|
|
|
|
$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 ' | ')"
|
|
}
|
|
|
|
$workspaceScript = Join-Path $PSScriptRoot 'genealogy-workspace-openapi-contract.ps1'
|
|
$previousErrorActionPreference = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
$workspaceOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $workspaceScript 2>&1)
|
|
$workspaceExitCode = $LASTEXITCODE
|
|
$ErrorActionPreference = $previousErrorActionPreference
|
|
$workspaceLines = @($workspaceOutput | ForEach-Object { [string]$_ })
|
|
if ($workspaceExitCode -ne 0 -or 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS' -notin $workspaceLines) {
|
|
Add-Issue 'canonical /mine and /overview workspace prerequisite must pass its sole owner gate before G11 can pass'
|
|
}
|
|
|
|
Assert-GlobalSettingsWriteOwner
|
|
|
|
$settingsPathProperty = $document.paths.PSObject.Properties[$settingsPath]
|
|
$settingsPathItem = if ($settingsPathProperty) { $settingsPathProperty.Value } else { $null }
|
|
$settingsOperation = Get-Operation $settingsPath 'put'
|
|
|
|
if ($settingsPathItem -and $settingsPathItem.patch) {
|
|
Add-Issue "JSON $settingsPath must not publish a second PATCH settings owner"
|
|
}
|
|
if ($settingsOperation) {
|
|
if ($settingsOperation.operationId -ne 'appUpdateGenealogySettings') {
|
|
Add-Issue "JSON PUT $settingsPath operationId must be appUpdateGenealogySettings"
|
|
}
|
|
Assert-SaToken $settingsOperation "PUT $settingsPath"
|
|
$settingsParameters = @(Get-OperationParameters $settingsPath $settingsOperation "PUT $settingsPath")
|
|
Assert-ExactParameters $settingsParameters "PUT $settingsPath" @(
|
|
'path:genealogyId', 'header:clientid', 'header:If-Match'
|
|
)
|
|
$genealogyId = Get-Parameter $settingsParameters 'genealogyId' 'path' "PUT $settingsPath"
|
|
if ($genealogyId) {
|
|
if (-not (Test-IsJsonBoolean $genealogyId.required $true)) {
|
|
Add-Issue "JSON PUT $settingsPath genealogyId must be required"
|
|
}
|
|
[void](Test-IsPureSchemaRef $genealogyId.schema '#/components/schemas/GenealogyId' "PUT $settingsPath genealogyId")
|
|
}
|
|
$clientid = Get-Parameter $settingsParameters 'clientid' 'header' "PUT $settingsPath"
|
|
if ($clientid -and (-not (Test-IsJsonBoolean $clientid.required $true) -or
|
|
$clientid.schema.type -ne 'string' -or -not (Test-IsNonNullable $clientid.schema) -or
|
|
[int]$clientid.schema.minLength -ne 1 -or
|
|
[int]$clientid.schema.maxLength -ne 128)) {
|
|
Add-Issue "JSON PUT $settingsPath clientid must be a required non-null bounded non-empty string"
|
|
}
|
|
if ($clientid) { Assert-NoConflictingSchemaKeywords $clientid.schema "PUT $settingsPath clientid" }
|
|
if ($clientid) { Assert-AllowedSchemaKeywords $clientid.schema "PUT $settingsPath clientid" @('type', 'minLength', 'maxLength', 'nullable') }
|
|
$ifMatch = Get-Parameter $settingsParameters 'If-Match' 'header' "PUT $settingsPath"
|
|
if ($ifMatch) {
|
|
if (-not (Test-IsJsonBoolean $ifMatch.required $true)) {
|
|
Add-Issue "JSON PUT $settingsPath If-Match must be required"
|
|
}
|
|
[void](Test-IsPureSchemaRef $ifMatch.schema '#/components/schemas/GenealogySettingsVersion' "PUT $settingsPath If-Match")
|
|
}
|
|
|
|
$requestContent = if ($settingsOperation.requestBody) { @($settingsOperation.requestBody.content.PSObject.Properties) } else { @() }
|
|
if (-not $settingsOperation.requestBody -or -not (Test-IsJsonBoolean $settingsOperation.requestBody.required $true) -or
|
|
$requestContent.Count -ne 1 -or $requestContent[0].Name -ne 'application/json') {
|
|
Add-Issue "JSON PUT $settingsPath must require only application/json AppGenealogySettingsUpdateBody"
|
|
} elseif ($requestContent.Count -eq 1) {
|
|
[void](Test-IsPureSchemaRef $requestContent[0].Value.schema '#/components/schemas/AppGenealogySettingsUpdateBody' "PUT $settingsPath request schema")
|
|
}
|
|
|
|
if ([string]$settingsOperation.'x-update-semantics' -ne 'ATOMIC_DIRTY_ONLY_MERGE' -or
|
|
[string]$settingsOperation.'x-omitted-fields' -ne 'UNCHANGED' -or
|
|
[string]$settingsOperation.'x-version-precondition' -ne 'IF_MATCH_SETTINGS_VERSION_CAS' -or
|
|
[string]$settingsOperation.'x-canonical-noop-policy' -ne 'RETURN_200_KEEP_VERSION_NO_DOMAIN_SIDE_EFFECTS') {
|
|
Add-Issue "JSON PUT $settingsPath must machine-bind dirty-only merge, omission, version CAS, and canonical no-op semantics"
|
|
}
|
|
$precedence = @($settingsOperation.'x-conflict-precedence')
|
|
$expectedPrecedence = @(
|
|
'GENEALOGY_SETTINGS_VERSION_CHANGED',
|
|
'GENEALOGY_NOT_READY',
|
|
'ACTIVE_PENDING_APPLICATIONS'
|
|
)
|
|
if (-not (Test-IsJsonArray $settingsOperation.'x-conflict-precedence') -or
|
|
($precedence -join ',') -ne ($expectedPrecedence -join ',')) {
|
|
Add-Issue "JSON PUT $settingsPath must order version, READY, then active-pending conflicts"
|
|
}
|
|
if ([string]$settingsOperation.'x-active-pending-policy' -ne 'BLOCK_ONLY_PUBLIC_APPLY_TO_MEMBER_ONLY' -or
|
|
[string]$settingsOperation.'x-public-apply-coordination' -ne 'ATOMIC_SINGLE_WINNER' -or
|
|
-not (Test-IsJsonBoolean $settingsOperation.'x-authorization-revalidated-in-transaction' $true) -or
|
|
-not (Test-IsJsonBoolean $settingsOperation.'x-conflict-disclosure-requires-current-permission' $true)) {
|
|
Add-Issue "JSON PUT $settingsPath must bind permission revalidation and atomic single-winner PUBLIC_APPLY-to-MEMBER_ONLY pending protection"
|
|
}
|
|
}
|
|
|
|
$statuses = @('200', '400', '401', '403', '404', '409', '422', '429', '500')
|
|
Assert-ExactResponseSet $settingsOperation "PUT $settingsPath" $statuses
|
|
$responseRefs = @{
|
|
'200' = '#/components/schemas/RAppGenealogyVo'
|
|
'400' = '#/components/schemas/RGenealogySettingsBadRequest'
|
|
'401' = '#/components/schemas/RGenealogySettingsUnauthorized'
|
|
'403' = '#/components/schemas/RGenealogySettingsForbidden'
|
|
'404' = '#/components/schemas/RGenealogySettingsNotFound'
|
|
'409' = '#/components/schemas/RGenealogySettingsConflict'
|
|
'422' = '#/components/schemas/RGenealogySettingsValidationError'
|
|
'429' = '#/components/schemas/RGenealogySettingsRateLimited'
|
|
'500' = '#/components/schemas/RGenealogySettingsOutcomeUnknown'
|
|
}
|
|
$settingsResponses = @{}
|
|
foreach ($status in $statuses) {
|
|
$response = Get-Response $settingsOperation "PUT $settingsPath" $status
|
|
$settingsResponses[$status] = $response
|
|
$actualRef = Get-JsonResponseRef $response "PUT $settingsPath" $status
|
|
if ($actualRef -ne $responseRefs[$status]) {
|
|
Add-Issue "JSON PUT $settingsPath $status must return $($responseRefs[$status]); actual: $actualRef"
|
|
}
|
|
Assert-RequiredResponseHeaders $response "PUT $settingsPath" $status
|
|
Assert-PrivateNoStore $response "PUT $settingsPath" $status
|
|
}
|
|
Assert-RetryAfter $settingsResponses['429'] "PUT $settingsPath 429"
|
|
|
|
$nameOwner = Get-Schema 'GenealogyName'
|
|
if ($nameOwner) {
|
|
if ($nameOwner.type -ne 'string' -or [int]$nameOwner.minLength -ne 1 -or
|
|
[int]$nameOwner.maxLength -ne 24 -or [string]$nameOwner.pattern -cne $genealogyNamePattern -or
|
|
-not (Test-IsNonNullable $nameOwner) -or
|
|
[string]$nameOwner.'x-unicode-normalization' -ne 'NFC' -or
|
|
[string]$nameOwner.'x-length-unit' -ne 'UNICODE_CODE_POINT') {
|
|
Add-Issue 'JSON GenealogyName must be non-null NFC, 1..24 Unicode code points, without boundary whitespace, line breaks, or control characters'
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $nameOwner 'GenealogyName'
|
|
Assert-AllowedSchemaKeywords $nameOwner 'GenealogyName' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
|
|
}
|
|
|
|
$introOwner = Get-Schema 'GenealogyIntro'
|
|
if ($introOwner) {
|
|
$branches = @($introOwner.oneOf)
|
|
$clear = @($branches | Where-Object {
|
|
$_.type -eq 'string' -and (Test-IsNonNullable $_) -and (Test-IsJsonArray $_.enum) -and
|
|
@($_.enum).Count -eq 1 -and [string]$_.enum[0] -eq ''
|
|
})
|
|
$value = @($branches | Where-Object {
|
|
$_.type -eq 'string' -and (Test-IsNonNullable $_) -and [int]$_.minLength -eq 1
|
|
})
|
|
if (-not (Test-IsJsonArray $introOwner.oneOf) -or $branches.Count -ne 2 -or
|
|
$clear.Count -ne 1 -or $value.Count -ne 1 -or
|
|
-not (Test-IsNonNullable $introOwner) -or
|
|
[int]$value[0].maxLength -ne 80 -or [string]$value[0].pattern -cne $genealogyIntroPattern -or
|
|
[string]$introOwner.'x-unicode-normalization' -ne 'NFC' -or
|
|
[string]$introOwner.'x-length-unit' -ne 'UNICODE_CODE_POINT' -or
|
|
[string]$introOwner.'x-line-ending-normalization' -ne 'LF') {
|
|
Add-Issue 'JSON GenealogyIntro must be non-null, allow exact empty clear or boundary-trimmed NFC 1..80 code points, permit internal LF only, and reject other controls'
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $introOwner 'GenealogyIntro' @('oneOf')
|
|
Assert-AllowedSchemaKeywords $introOwner 'GenealogyIntro' @('oneOf', 'nullable')
|
|
foreach ($branch in $clear) {
|
|
Assert-NoConflictingSchemaKeywords $branch 'GenealogyIntro empty branch' @('enum')
|
|
Assert-AllowedSchemaKeywords $branch 'GenealogyIntro empty branch' @('type', 'enum', 'nullable')
|
|
}
|
|
foreach ($branch in $value) {
|
|
Assert-NoConflictingSchemaKeywords $branch 'GenealogyIntro value branch'
|
|
Assert-AllowedSchemaKeywords $branch 'GenealogyIntro value branch' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
|
|
}
|
|
}
|
|
|
|
$versionOwner = Get-Schema 'GenealogySettingsVersion'
|
|
if ($versionOwner) {
|
|
if ($versionOwner.type -ne 'string' -or [int]$versionOwner.minLength -ne 1 -or
|
|
[int]$versionOwner.maxLength -ne 128 -or [string]$versionOwner.pattern -ne $identifierPattern -or
|
|
-not (Test-IsNonNullable $versionOwner) -or
|
|
[string]$versionOwner.'x-semantics' -ne 'OPAQUE_SETTINGS_CAS_VERSION' -or
|
|
-not (Test-IsJsonArray $versionOwner.'x-version-scope-fields') -or
|
|
(@($versionOwner.'x-version-scope-fields') -join ',') -cne 'genealogyName,intro,accessPreset' -or
|
|
[string]$versionOwner.'x-version-change-policy' -ne 'CANONICAL_SETTINGS_CHANGE_ONLY') {
|
|
Add-Issue 'JSON GenealogySettingsVersion must be a non-null 1..128 URL-safe opaque CAS token that changes only when canonical genealogyName/intro/accessPreset changes'
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $versionOwner 'GenealogySettingsVersion'
|
|
Assert-AllowedSchemaKeywords $versionOwner 'GenealogySettingsVersion' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
|
|
}
|
|
|
|
$settingsBody = Get-Schema 'AppGenealogySettingsUpdateBody'
|
|
Assert-ExactObject $settingsBody 'AppGenealogySettingsUpdateBody' @(
|
|
'genealogyName', 'intro', 'accessPreset'
|
|
) @() 1 3
|
|
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'genealogyName' '#/components/schemas/GenealogyName'
|
|
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'intro' '#/components/schemas/GenealogyIntro'
|
|
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
|
|
|
|
$appGenealogy = Get-Schema 'AppGenealogyVo'
|
|
if ($appGenealogy) {
|
|
if (-not (Test-IsNonNullable $appGenealogy)) {
|
|
Add-Issue 'JSON AppGenealogyVo must reject nullable=true'
|
|
}
|
|
foreach ($field in @('genealogyId', 'genealogyName', 'intro', 'accessPreset', 'settingsVersion', 'canManage')) {
|
|
if ($field -notin @($appGenealogy.required)) {
|
|
Add-Issue "JSON AppGenealogyVo.required missing settings baseline field: $field"
|
|
}
|
|
}
|
|
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'genealogyId' '#/components/schemas/GenealogyId'
|
|
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'genealogyName' '#/components/schemas/GenealogyName'
|
|
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'intro' '#/components/schemas/GenealogyIntro'
|
|
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
|
|
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'settingsVersion' '#/components/schemas/GenealogySettingsVersion'
|
|
if ($appGenealogy.properties.canManage.type -ne 'boolean' -or
|
|
-not (Test-IsNonNullable $appGenealogy.properties.canManage)) {
|
|
Add-Issue 'JSON AppGenealogyVo.canManage must be a non-null boolean and is only an entry capability, not write authorization proof'
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $appGenealogy.properties.canManage 'AppGenealogyVo.canManage'
|
|
Assert-AllowedSchemaKeywords $appGenealogy 'AppGenealogyVo' @('type', 'properties', 'required', 'additionalProperties', 'nullable')
|
|
Assert-AllowedSchemaKeywords $appGenealogy.properties.canManage 'AppGenealogyVo.canManage' @('type', 'nullable')
|
|
}
|
|
|
|
$successEnvelope = Get-Schema 'RAppGenealogyVo'
|
|
if ($successEnvelope) {
|
|
Assert-ExactObject $successEnvelope 'RAppGenealogyVo' @('code', 'data') @('code', 'data')
|
|
if ($successEnvelope.properties.code.type -ne 'integer' -or
|
|
-not (Test-IsNonNullable $successEnvelope.properties.code) -or
|
|
-not (Test-IsJsonArray $successEnvelope.properties.code.enum) -or
|
|
@($successEnvelope.properties.code.enum).Count -ne 1 -or
|
|
@($successEnvelope.properties.code.enum)[0] -ne 200) {
|
|
Add-Issue 'JSON RAppGenealogyVo must expose fixed code=200 and canonical AppGenealogyVo data'
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $successEnvelope.properties.code 'RAppGenealogyVo.code' @('enum')
|
|
Assert-AllowedSchemaKeywords $successEnvelope.properties.code 'RAppGenealogyVo.code' @('type', 'enum', 'nullable')
|
|
[void](Test-IsPureSchemaRef $successEnvelope.properties.data '#/components/schemas/AppGenealogyVo' 'RAppGenealogyVo.data')
|
|
}
|
|
|
|
$badRequest = Get-Schema 'RGenealogySettingsBadRequest'
|
|
$unauthorized = Get-Schema 'RGenealogySettingsUnauthorized'
|
|
$forbidden = Get-Schema 'RGenealogySettingsForbidden'
|
|
$notFound = Get-Schema 'RGenealogySettingsNotFound'
|
|
$versionChanged = Get-Schema 'RGenealogySettingsVersionChanged'
|
|
$notReady = Get-Schema 'RGenealogySettingsNotReady'
|
|
$pendingApplications = Get-Schema 'RGenealogySettingsPendingApplications'
|
|
$conflict = Get-Schema 'RGenealogySettingsConflict'
|
|
$validation = Get-Schema 'RGenealogySettingsValidationError'
|
|
$rateLimited = Get-Schema 'RGenealogySettingsRateLimited'
|
|
$outcomeUnknown = Get-Schema 'RGenealogySettingsOutcomeUnknown'
|
|
|
|
Assert-FixedError $badRequest 'RGenealogySettingsBadRequest' 400 'GENEALOGY_SETTINGS_REQUEST_INVALID'
|
|
Assert-FixedError $unauthorized 'RGenealogySettingsUnauthorized' 401 'AUTH_REQUIRED'
|
|
Assert-FixedError $forbidden 'RGenealogySettingsForbidden' 403 'GENEALOGY_SETTINGS_FORBIDDEN'
|
|
Assert-FixedError $notFound 'RGenealogySettingsNotFound' 404 'GENEALOGY_NOT_AVAILABLE'
|
|
Assert-FixedError $versionChanged 'RGenealogySettingsVersionChanged' 409 'GENEALOGY_SETTINGS_VERSION_CHANGED' -Current
|
|
Assert-FixedError $notReady 'RGenealogySettingsNotReady' 409 'GENEALOGY_NOT_READY'
|
|
Assert-FixedError $pendingApplications 'RGenealogySettingsPendingApplications' 409 'ACTIVE_PENDING_APPLICATIONS'
|
|
Assert-FixedError $rateLimited 'RGenealogySettingsRateLimited' 429 'RATE_LIMITED'
|
|
Assert-FixedError $outcomeUnknown 'RGenealogySettingsOutcomeUnknown' 500 'GENEALOGY_SETTINGS_OUTCOME_UNKNOWN'
|
|
|
|
if ($conflict) {
|
|
$actualRefs = @($conflict.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object)
|
|
$expectedRefs = @(
|
|
'#/components/schemas/RGenealogySettingsVersionChanged',
|
|
'#/components/schemas/RGenealogySettingsNotReady',
|
|
'#/components/schemas/RGenealogySettingsPendingApplications'
|
|
) | Sort-Object
|
|
$mapping = $conflict.discriminator.mapping
|
|
$actualMapping = if ($mapping) {
|
|
@($mapping.PSObject.Properties | ForEach-Object { "$($_.Name)=$($_.Value)" } | Sort-Object)
|
|
} else { @() }
|
|
$expectedMapping = @(
|
|
'GENEALOGY_SETTINGS_VERSION_CHANGED=#/components/schemas/RGenealogySettingsVersionChanged',
|
|
'GENEALOGY_NOT_READY=#/components/schemas/RGenealogySettingsNotReady',
|
|
'ACTIVE_PENDING_APPLICATIONS=#/components/schemas/RGenealogySettingsPendingApplications'
|
|
) | Sort-Object
|
|
$conflictKeywords = @($conflict.PSObject.Properties.Name | Sort-Object)
|
|
$discriminatorKeywords = if ($conflict.discriminator) {
|
|
@($conflict.discriminator.PSObject.Properties.Name | Sort-Object)
|
|
} else { @() }
|
|
$pureBranches = @($conflict.oneOf | Where-Object {
|
|
(@($_.PSObject.Properties.Name) -join ',') -ceq '$ref' -and
|
|
[string]$_.'$ref' -match '^#/components/schemas/[^/]+$'
|
|
})
|
|
if (($conflictKeywords -join ',') -cne 'discriminator,oneOf' -or
|
|
($discriminatorKeywords -join ',') -cne 'mapping,propertyName' -or
|
|
-not (Test-IsJsonArray $conflict.oneOf) -or $pureBranches.Count -ne 3 -or
|
|
($actualRefs -join ',') -ne ($expectedRefs -join ',') -or
|
|
$conflict.discriminator.propertyName -ne 'businessCode' -or
|
|
($actualMapping -join ',') -ne ($expectedMapping -join ',')) {
|
|
Add-Issue 'JSON RGenealogySettingsConflict must contain only an exact businessCode discriminator and three local-ref oneOf branches for version, READY, and active-pending conflicts'
|
|
}
|
|
}
|
|
|
|
Assert-ExactObject $validation 'RGenealogySettingsValidationError' @(
|
|
'code', 'businessCode', 'fieldErrors'
|
|
) @('code', 'businessCode', 'fieldErrors')
|
|
if ($validation) {
|
|
if ($validation.properties.code.type -ne 'integer' -or
|
|
-not (Test-IsNonNullable $validation.properties.code) -or
|
|
-not (Test-IsJsonArray $validation.properties.code.enum) -or
|
|
@($validation.properties.code.enum).Count -ne 1 -or @($validation.properties.code.enum)[0] -ne 422 -or
|
|
$validation.properties.businessCode.type -ne 'string' -or
|
|
-not (Test-IsNonNullable $validation.properties.businessCode) -or
|
|
-not (Test-IsJsonArray $validation.properties.businessCode.enum) -or
|
|
@($validation.properties.businessCode.enum).Count -ne 1 -or
|
|
@($validation.properties.businessCode.enum)[0] -ne 'GENEALOGY_SETTINGS_INVALID') {
|
|
Add-Issue 'JSON RGenealogySettingsValidationError must fix code=422 and businessCode=GENEALOGY_SETTINGS_INVALID'
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $validation.properties.code 'RGenealogySettingsValidationError.code' @('enum')
|
|
Assert-NoConflictingSchemaKeywords $validation.properties.businessCode 'RGenealogySettingsValidationError.businessCode' @('enum')
|
|
Assert-AllowedSchemaKeywords $validation.properties.code 'RGenealogySettingsValidationError.code' @('type', 'enum', 'nullable')
|
|
Assert-AllowedSchemaKeywords $validation.properties.businessCode 'RGenealogySettingsValidationError.businessCode' @('type', 'enum', 'nullable')
|
|
$fieldErrors = $validation.properties.fieldErrors
|
|
Assert-ExactObject $fieldErrors 'RGenealogySettingsValidationError.fieldErrors' @(
|
|
'genealogyName', 'intro', 'accessPreset'
|
|
) @() 1 3
|
|
if ($fieldErrors) {
|
|
foreach ($field in @('genealogyName', 'intro', 'accessPreset')) {
|
|
$property = $fieldErrors.properties.PSObject.Properties[$field]
|
|
if (-not $property -or $property.Value.type -ne 'string' -or
|
|
-not (Test-IsNonNullable $property.Value) -or
|
|
[int]$property.Value.minLength -ne 1 -or [int]$property.Value.maxLength -ne 200) {
|
|
Add-Issue "JSON RGenealogySettingsValidationError.fieldErrors.$field must be an optional non-null bounded non-empty string"
|
|
}
|
|
if ($property) {
|
|
Assert-NoConflictingSchemaKeywords $property.Value "RGenealogySettingsValidationError.fieldErrors.$field"
|
|
Assert-AllowedSchemaKeywords $property.Value "RGenealogySettingsValidationError.fieldErrors.$field" @('type', 'minLength', 'maxLength', 'nullable')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($issues.Count -gt 0) {
|
|
Write-Output 'G11-SETTINGS-OPENAPI-CONTRACT BLOCKED'
|
|
Write-Output "Issues: $($issues.Count)"
|
|
$issues | ForEach-Object { Write-Output "- $_" }
|
|
Write-Output '- Keep PUT as the only settings owner and /overview as the only single-genealogy read owner; do not add PATCH or revive the generic GET.'
|
|
Write-Output '- Use one atomic dirty-only merge with GenealogySettingsVersion in AppGenealogyVo, required If-Match, typed 409 conflicts, and no version field in the body.'
|
|
Write-Output '- Block only PUBLIC_APPLY-to-MEMBER_ONLY while active PENDING applications exist, and serialize that transition with new application admission.'
|
|
Write-Output '- Timeout, cancellation, 5xx, or malformed success is outcome-unknown; reconcile with a fresh /overview and never auto-repeat PUT or claim this request succeeded.'
|
|
Write-Output '- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.'
|
|
exit 1
|
|
}
|
|
|
|
Write-Output 'G11-SETTINGS-OPENAPI-CONTRACT PASS'
|