720 lines
32 KiB
PowerShell
720 lines
32 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]
|
|
|
|
$minePath = '/genealogy/app/genealogies/mine'
|
|
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
|
|
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
|
|
$httpMethods = @('get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace')
|
|
|
|
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.ToUpperInvariant()) $Path"
|
|
return $null
|
|
}
|
|
return $operationProperty.Value
|
|
}
|
|
|
|
function Assert-OnlyMethod {
|
|
param([string]$Path, [string]$Method)
|
|
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
|
if (-not $pathProperty) { return }
|
|
$actual = @($pathProperty.Value.PSObject.Properties.Name | Where-Object { $_ -in $httpMethods } | Sort-Object)
|
|
if ($actual.Count -ne 1 -or $actual[0] -ne $Method) {
|
|
Add-Issue "JSON $Path must expose only $($Method.ToUpperInvariant()); actual: $($actual -join ',')"
|
|
}
|
|
}
|
|
|
|
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, [bool]$StrictRefObject = $true)
|
|
if (-not $Response) { return $null }
|
|
if (-not $Response.'$ref') { return $Response }
|
|
$responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
|
|
if ($StrictRefObject -and $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-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 Assert-ExactParameters {
|
|
param([object[]]$Parameters, [string]$Label, [string[]]$Expected)
|
|
$actual = @($Parameters | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
|
|
$wanted = @($Expected | Sort-Object)
|
|
if (($actual -join ',') -cne ($wanted -join ',')) {
|
|
Add-Issue "JSON $Label parameters must be exactly $($wanted -join ','); actual: $($actual -join ',')"
|
|
}
|
|
}
|
|
|
|
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-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 Assert-GlobalOperationId {
|
|
param([string]$OperationId, [string]$ExpectedLabel)
|
|
$owners = 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 $httpMethods })) {
|
|
if ([string]$methodProperty.Value.operationId -ceq $OperationId) {
|
|
$owners.Add("$($methodProperty.Name.ToUpperInvariant()) $($pathProperty.Name)")
|
|
}
|
|
}
|
|
}
|
|
if ($owners.Count -ne 1 -or $owners[0] -cne $ExpectedLabel) {
|
|
Add-Issue "JSON operationId $OperationId must be globally unique on $ExpectedLabel; actual: $($owners -join ',')"
|
|
}
|
|
}
|
|
|
|
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 ',') -cne ($wanted -join ',')) {
|
|
Add-Issue "JSON $Label responses must be exactly $($wanted -join ','); actual: $($actual -join ',')"
|
|
}
|
|
}
|
|
|
|
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-ResponseSchemaRef {
|
|
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"
|
|
return ''
|
|
}
|
|
[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-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; workspace reads use the shared settingsVersion owner instead"
|
|
}
|
|
$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-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-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 a non-null 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-ExactObject {
|
|
param([object]$Schema, [string]$Name, [string[]]$Properties, [string[]]$Required)
|
|
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 ',') -cne ($expectedProperties -join ',') -or
|
|
($actualRequired -join ',') -cne ($expectedRequired -join ',')) {
|
|
Add-Issue "JSON $Name must be a non-null closed object with properties [$($expectedProperties -join ',')] and required [$($expectedRequired -join ',')]"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $Schema $Name
|
|
Assert-AllowedSchemaKeywords $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable')
|
|
}
|
|
|
|
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([string]$Name, [int]$Status, [string]$BusinessCode)
|
|
$schema = Get-Schema $Name
|
|
Assert-ExactObject $schema $Name @('code', 'businessCode') @('code', 'businessCode')
|
|
if (-not $schema) { return }
|
|
$codes = @($schema.properties.code.enum)
|
|
$businessCodes = @($schema.properties.businessCode.enum)
|
|
if ($schema.properties.code.type -ne 'integer' -or
|
|
-not (Test-IsNonNullable $schema.properties.code) -or
|
|
-not (Test-IsJsonArray $schema.properties.code.enum) -or $codes.Count -ne 1 -or $codes[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
|
|
$businessCodes.Count -ne 1 -or [string]$businessCodes[0] -cne $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')
|
|
}
|
|
|
|
function Get-AllOfPropertyNames {
|
|
param([object]$Schema, [hashtable]$Seen, [string]$Label)
|
|
if (-not $Schema -or $Schema.type -eq 'array') { return @() }
|
|
if ($Schema.'$ref') {
|
|
$name = Get-LocalComponentName ([string]$Schema.'$ref') 'schemas' $Label
|
|
if (-not $name -or $Seen.ContainsKey($name)) { return @() }
|
|
$Seen[$name] = $true
|
|
$owner = $document.components.schemas.PSObject.Properties[$name]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON $Label references missing schema owner: $name"
|
|
return @()
|
|
}
|
|
return @(Get-AllOfPropertyNames $owner.Value $Seen "$Label -> $name")
|
|
}
|
|
$names = New-Object System.Collections.Generic.List[string]
|
|
if ($Schema.properties) {
|
|
foreach ($name in @($Schema.properties.PSObject.Properties.Name)) { $names.Add($name) }
|
|
}
|
|
foreach ($branch in @($Schema.allOf)) {
|
|
foreach ($name in @(Get-AllOfPropertyNames $branch $Seen "$Label allOf")) { $names.Add($name) }
|
|
}
|
|
return @($names | Sort-Object -Unique)
|
|
}
|
|
|
|
function Test-SchemaClosureExposesSingleGenealogy {
|
|
param([object]$Schema, [hashtable]$Seen, [string]$Label)
|
|
if (-not $Schema) { return $false }
|
|
|
|
$ref = [string]$Schema.'$ref'
|
|
if ($ref) {
|
|
$leaf = @($ref -split '/')[-1]
|
|
if ($leaf -in @('RAppGenealogyVo', 'AppGenealogyVo')) {
|
|
$expectedRef = "#/components/schemas/$leaf"
|
|
if ($ref -cne $expectedRef) {
|
|
[void](Get-LocalComponentName $ref 'schemas' $Label)
|
|
}
|
|
return $true
|
|
}
|
|
$name = Get-LocalComponentName $ref 'schemas' $Label
|
|
if (-not $name -or $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-SchemaClosureExposesSingleGenealogy $owner.Value $Seen "$Label -> $name"
|
|
}
|
|
|
|
if ($Schema.type -eq 'array') { return $false }
|
|
$propertyNames = @(Get-AllOfPropertyNames $Schema @{} "$Label structural detail")
|
|
$detailFields = @(@('genealogyId', 'genealogyName', 'canView') | Where-Object { $_ -in $propertyNames })
|
|
if ($detailFields.Count -eq 3) {
|
|
return $true
|
|
}
|
|
foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) {
|
|
foreach ($branch in @($Schema.$keyword)) {
|
|
if (Test-SchemaClosureExposesSingleGenealogy $branch $Seen "$Label $keyword") { return $true }
|
|
}
|
|
}
|
|
if ($Schema.properties) {
|
|
foreach ($property in @($Schema.properties.PSObject.Properties)) {
|
|
if ($property.Value.type -eq 'array') { continue }
|
|
if (Test-SchemaClosureExposesSingleGenealogy $property.Value $Seen "$Label.$($property.Name)") { return $true }
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-OperationReturnsSingleGenealogy {
|
|
param([object]$Operation, [string]$Label)
|
|
if (-not $Operation -or -not $Operation.responses) { return $false }
|
|
$responseProperty = $Operation.responses.PSObject.Properties['200']
|
|
if (-not $responseProperty) { return $false }
|
|
$response = $responseProperty.Value
|
|
if ($response.'$ref') {
|
|
$leaf = @(([string]$response.'$ref') -split '/')[-1]
|
|
if ($leaf -eq 'RAppGenealogyVo') {
|
|
if ([string]$response.'$ref' -cne '#/components/responses/RAppGenealogyVo') {
|
|
[void](Get-LocalComponentName ([string]$response.'$ref') 'responses' "$Label 200 response")
|
|
}
|
|
return $true
|
|
}
|
|
$response = Resolve-Response $response "$Label 200 response" $true
|
|
if (-not $response) { return $false }
|
|
}
|
|
if (-not $response.content) { return $false }
|
|
foreach ($media in @($response.content.PSObject.Properties)) {
|
|
if (Test-SchemaClosureExposesSingleGenealogy $media.Value.schema @{} "$Label 200 $($media.Name)") {
|
|
return $true
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Assert-SoleSingleGenealogyReadOwner {
|
|
$owners = New-Object System.Collections.Generic.List[string]
|
|
foreach ($pathProperty in @($document.paths.PSObject.Properties | Where-Object { $_.Name -match '^/genealogy/app(?:/|$)' })) {
|
|
$getProperty = $pathProperty.Value.PSObject.Properties['get']
|
|
if (-not $getProperty -or $pathProperty.Name -ceq $minePath) { continue }
|
|
$label = "GET $($pathProperty.Name)"
|
|
if (Test-OperationReturnsSingleGenealogy $getProperty.Value $label) {
|
|
$owners.Add($label)
|
|
}
|
|
}
|
|
$expected = "GET $overviewPath"
|
|
$uniqueOwners = @($owners | Sort-Object -Unique)
|
|
if ($uniqueOwners.Count -ne 1 -or $uniqueOwners[0] -cne $expected) {
|
|
Add-Issue "JSON single-genealogy APP read must have exactly one global owner ($expected); actual: $($uniqueOwners -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 ' | ')"
|
|
}
|
|
|
|
$mine = Get-Operation $minePath 'get'
|
|
$overview = Get-Operation $overviewPath 'get'
|
|
Assert-OnlyMethod $minePath 'get'
|
|
Assert-OnlyMethod $overviewPath 'get'
|
|
Assert-SoleSingleGenealogyReadOwner
|
|
|
|
$operationContracts = @(
|
|
[pscustomobject]@{
|
|
Path = $minePath
|
|
Operation = $mine
|
|
Label = "GET $minePath"
|
|
OperationId = 'appListMyGenealogies'
|
|
Parameters = @('header:clientid')
|
|
Responses = [ordered]@{
|
|
'200' = '#/components/schemas/RListAppGenealogyVo'
|
|
'401' = '#/components/schemas/RGenealogyWorkspaceUnauthorized'
|
|
'429' = '#/components/schemas/RGenealogyWorkspaceRateLimited'
|
|
'500' = '#/components/schemas/RGenealogyWorkspaceUnavailable'
|
|
}
|
|
},
|
|
[pscustomobject]@{
|
|
Path = $overviewPath
|
|
Operation = $overview
|
|
Label = "GET $overviewPath"
|
|
OperationId = 'appGetGenealogyOverview'
|
|
Parameters = @('header:clientid', 'path:genealogyId')
|
|
Responses = [ordered]@{
|
|
'200' = '#/components/schemas/RAppGenealogyVo'
|
|
'400' = '#/components/schemas/RGenealogyWorkspaceBadRequest'
|
|
'401' = '#/components/schemas/RGenealogyWorkspaceUnauthorized'
|
|
'404' = '#/components/schemas/RGenealogyWorkspaceNotFound'
|
|
'429' = '#/components/schemas/RGenealogyWorkspaceRateLimited'
|
|
'500' = '#/components/schemas/RGenealogyWorkspaceUnavailable'
|
|
}
|
|
}
|
|
)
|
|
|
|
foreach ($contract in $operationContracts) {
|
|
$operation = $contract.Operation
|
|
if (-not $operation) { continue }
|
|
if ([string]$operation.operationId -cne $contract.OperationId) {
|
|
Add-Issue "JSON $($contract.Label) operationId must be $($contract.OperationId)"
|
|
}
|
|
Assert-GlobalOperationId $contract.OperationId $contract.Label
|
|
Assert-SaToken $operation $contract.Label
|
|
if ($operation.PSObject.Properties['requestBody']) {
|
|
Add-Issue "JSON $($contract.Label) must not define a request body"
|
|
}
|
|
$parameters = @(Get-OperationParameters $contract.Path $operation $contract.Label)
|
|
Assert-ExactParameters $parameters $contract.Label $contract.Parameters
|
|
$clientid = Get-Parameter $parameters 'clientid' 'header' $contract.Label
|
|
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 $($contract.Label) clientid must be a required non-null string bounded to 1..128"
|
|
}
|
|
if ($clientid) { Assert-NoConflictingSchemaKeywords $clientid.schema "$($contract.Label) clientid" }
|
|
if ($clientid) { Assert-AllowedSchemaKeywords $clientid.schema "$($contract.Label) clientid" @('type', 'minLength', 'maxLength', 'nullable') }
|
|
if ($contract.Path -eq $overviewPath) {
|
|
$genealogyId = Get-Parameter $parameters 'genealogyId' 'path' $contract.Label
|
|
if ($genealogyId) {
|
|
if (-not (Test-IsJsonBoolean $genealogyId.required $true)) {
|
|
Add-Issue "JSON $($contract.Label) genealogyId must be required"
|
|
}
|
|
[void](Test-IsPureSchemaRef $genealogyId.schema '#/components/schemas/GenealogyId' "$($contract.Label) genealogyId")
|
|
}
|
|
}
|
|
|
|
$statuses = @($contract.Responses.Keys)
|
|
Assert-ExactResponseSet $operation $contract.Label $statuses
|
|
foreach ($status in $statuses) {
|
|
$response = Get-Response $operation $contract.Label $status
|
|
$actualRef = Get-ResponseSchemaRef $response $contract.Label $status
|
|
if ($actualRef -cne $contract.Responses[$status]) {
|
|
Add-Issue "JSON $($contract.Label) $status must return $($contract.Responses[$status]); actual: $actualRef"
|
|
}
|
|
Assert-RequiredResponseHeaders $response $contract.Label $status
|
|
Assert-PrivateNoStore $response $contract.Label $status
|
|
if ($status -eq '429') { Assert-RetryAfter $response "$($contract.Label) 429" }
|
|
}
|
|
}
|
|
|
|
if ($mine -and (-not (Test-IsJsonBoolean $mine.'x-current-account-viewable-only' $true) -or
|
|
[string]$mine.'x-revocation-policy' -cne 'OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS')) {
|
|
Add-Issue 'JSON GET /mine must machine-bind current-account viewable-only scope and confirmed-revocation omission'
|
|
}
|
|
if ($overview -and [string]$overview.'x-object-authorization-failure' -cne 'NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE') {
|
|
Add-Issue 'JSON GET /overview must machine-bind non-disclosing object authorization failure'
|
|
}
|
|
|
|
$genealogyIdOwner = Get-Schema 'GenealogyId'
|
|
if ($genealogyIdOwner -and ($genealogyIdOwner.type -ne 'string' -or
|
|
-not (Test-IsNonNullable $genealogyIdOwner) -or [int]$genealogyIdOwner.minLength -ne 1 -or
|
|
[int]$genealogyIdOwner.maxLength -ne 128 -or [string]$genealogyIdOwner.pattern -cne $identifierPattern)) {
|
|
Add-Issue 'JSON GenealogyId must be a non-null 1..128 URL-safe lexical identifier'
|
|
}
|
|
if ($genealogyIdOwner) { Assert-NoConflictingSchemaKeywords $genealogyIdOwner 'GenealogyId' }
|
|
if ($genealogyIdOwner) { Assert-AllowedSchemaKeywords $genealogyIdOwner 'GenealogyId' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') }
|
|
$genealogyNameOwner = Get-Schema 'GenealogyName'
|
|
if ($genealogyNameOwner -and ($genealogyNameOwner.type -ne 'string' -or -not (Test-IsNonNullable $genealogyNameOwner))) {
|
|
Add-Issue 'JSON GenealogyName shared owner must be a non-null string; G11 owns its normalization and length semantics'
|
|
}
|
|
if ($genealogyNameOwner) { Assert-NoConflictingSchemaKeywords $genealogyNameOwner 'GenealogyName' }
|
|
if ($genealogyNameOwner) { Assert-AllowedSchemaKeywords $genealogyNameOwner 'GenealogyName' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') }
|
|
|
|
$listEnvelope = Get-Schema 'RListAppGenealogyVo'
|
|
$objectEnvelope = Get-Schema 'RAppGenealogyVo'
|
|
$genealogy = Get-Schema 'AppGenealogyVo'
|
|
|
|
Assert-ExactObject $listEnvelope 'RListAppGenealogyVo' @('code', 'data') @('code', 'data')
|
|
Assert-ExactObject $objectEnvelope 'RAppGenealogyVo' @('code', 'data') @('code', 'data')
|
|
foreach ($envelope in @(
|
|
[pscustomobject]@{ Name = 'RListAppGenealogyVo'; Schema = $listEnvelope },
|
|
[pscustomobject]@{ Name = 'RAppGenealogyVo'; Schema = $objectEnvelope }
|
|
)) {
|
|
if (-not $envelope.Schema) { continue }
|
|
$codes = @($envelope.Schema.properties.code.enum)
|
|
if ($envelope.Schema.properties.code.type -ne 'integer' -or
|
|
-not (Test-IsNonNullable $envelope.Schema.properties.code) -or
|
|
-not (Test-IsJsonArray $envelope.Schema.properties.code.enum) -or
|
|
$codes.Count -ne 1 -or $codes[0] -ne 200) {
|
|
Add-Issue "JSON $($envelope.Name).code must be fixed to 200"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $envelope.Schema.properties.code "$($envelope.Name).code" @('enum')
|
|
Assert-AllowedSchemaKeywords $envelope.Schema.properties.code "$($envelope.Name).code" @('type', 'enum', 'nullable')
|
|
}
|
|
if ($listEnvelope) {
|
|
$listData = $listEnvelope.properties.data
|
|
if ($listData.type -ne 'array' -or -not (Test-IsNonNullable $listData)) {
|
|
Add-Issue 'JSON RListAppGenealogyVo.data must be a non-null AppGenealogyVo array'
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $listData 'RListAppGenealogyVo.data'
|
|
Assert-AllowedSchemaKeywords $listData 'RListAppGenealogyVo.data' @('type', 'items', 'nullable')
|
|
[void](Test-IsPureSchemaRef $listData.items '#/components/schemas/AppGenealogyVo' 'RListAppGenealogyVo.data.items')
|
|
}
|
|
if ($objectEnvelope) {
|
|
[void](Test-IsPureSchemaRef $objectEnvelope.properties.data '#/components/schemas/AppGenealogyVo' 'RAppGenealogyVo.data')
|
|
}
|
|
|
|
$consumedFields = @('genealogyId', 'genealogyName', 'canView', 'canManage', 'canEditContent', 'roleType')
|
|
if ($genealogy) {
|
|
if ($genealogy.type -ne 'object' -or -not (Test-IsNonNullable $genealogy)) {
|
|
Add-Issue 'JSON AppGenealogyVo must be a non-null object'
|
|
}
|
|
Assert-AllowedSchemaKeywords $genealogy 'AppGenealogyVo' @('type', 'properties', 'required', 'additionalProperties', 'nullable')
|
|
foreach ($field in $consumedFields) {
|
|
if ($field -notin @($genealogy.required)) {
|
|
Add-Issue "JSON AppGenealogyVo.required missing workspace field: $field"
|
|
}
|
|
}
|
|
Assert-PropertyRef $genealogy 'AppGenealogyVo' 'genealogyId' '#/components/schemas/GenealogyId'
|
|
Assert-PropertyRef $genealogy 'AppGenealogyVo' 'genealogyName' '#/components/schemas/GenealogyName'
|
|
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
|
|
$property = $genealogy.properties.PSObject.Properties[$field]
|
|
if (-not $property -or $property.Value.type -ne 'boolean' -or -not (Test-IsNonNullable $property.Value)) {
|
|
Add-Issue "JSON AppGenealogyVo.$field must be a non-null boolean"
|
|
}
|
|
if ($property) {
|
|
Assert-NoConflictingSchemaKeywords $property.Value "AppGenealogyVo.$field"
|
|
Assert-AllowedSchemaKeywords $property.Value "AppGenealogyVo.$field" @('type', 'nullable')
|
|
}
|
|
}
|
|
$roleType = $genealogy.properties.PSObject.Properties['roleType']
|
|
$rawRoleValues = if ($roleType) { @($roleType.Value.enum) } else { @() }
|
|
$roleValues = @($rawRoleValues | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) })
|
|
$uniqueRoleValues = @($roleValues | Sort-Object -CaseSensitive -Unique)
|
|
if (-not $roleType -or $roleType.Value.type -ne 'string' -or -not (Test-IsNonNullable $roleType.Value) -or
|
|
-not (Test-IsJsonArray $roleType.Value.enum) -or $roleValues.Count -ne $rawRoleValues.Count -or
|
|
$uniqueRoleValues.Count -ne $rawRoleValues.Count -or $rawRoleValues.Count -lt 2) {
|
|
Add-Issue 'JSON AppGenealogyVo.roleType must be a non-null string with at least two stable non-empty enum values'
|
|
}
|
|
if ($roleType) {
|
|
Assert-NoConflictingSchemaKeywords $roleType.Value 'AppGenealogyVo.roleType' @('enum')
|
|
Assert-AllowedSchemaKeywords $roleType.Value 'AppGenealogyVo.roleType' @('type', 'enum', 'nullable')
|
|
}
|
|
}
|
|
|
|
Assert-FixedError 'RGenealogyWorkspaceBadRequest' 400 'GENEALOGY_ID_INVALID'
|
|
Assert-FixedError 'RGenealogyWorkspaceUnauthorized' 401 'AUTH_REQUIRED'
|
|
Assert-FixedError 'RGenealogyWorkspaceNotFound' 404 'GENEALOGY_NOT_AVAILABLE'
|
|
Assert-FixedError 'RGenealogyWorkspaceRateLimited' 429 'RATE_LIMITED'
|
|
Assert-FixedError 'RGenealogyWorkspaceUnavailable' 500 'GENEALOGY_WORKSPACE_UNAVAILABLE'
|
|
|
|
if ($issues.Count -gt 0) {
|
|
Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED'
|
|
Write-Output "Issues: $($issues.Count)"
|
|
$issues | ForEach-Object { Write-Output "- $_" }
|
|
Write-Output '- /mine and /overview are authenticated private reads; do not publish a generic duplicate detail read or HTTP-200-wrapped errors.'
|
|
Write-Output '- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.'
|
|
Write-Output '- Release still requires two-account deployment tests for revocation, cross-account isolation, deletion, rate limits, malformed identifiers, and service failure.'
|
|
exit 1
|
|
}
|
|
|
|
Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS'
|