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

1378 lines
60 KiB
PowerShell

param(
[switch]$SkipProtectedParity,
[switch]$ReturnIssues,
[string]$DocumentPath = ''
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($DocumentPath)) {
$DocumentPath = Join-Path $root 'APP.openapi.json'
}
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $DocumentPath | ConvertFrom-Json
$issues = New-Object System.Collections.Generic.List[string]
$feedListPath = '/genealogy/app/genealogies/{genealogyId}/feeds'
$feedDetailPath = '/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}'
$rootCommentPath = '/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments'
$legacyFeedPagePath = '/genealogy/app/genealogies/{genealogyId}/feeds/page'
$legacyCommentPagePath = '/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments/page'
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
$cursorPattern = '^[A-Za-z0-9_-]{1,512}$'
$operations = @(
[pscustomobject]@{
Path = $feedListPath
Method = 'get'
Id = 'appListFamilyFeeds'
Parameters = @('header:clientid', 'path:genealogyId', 'query:cursor', 'query:limit')
SuccessRef = '#/components/schemas/RAppFamilyFeedCursorPage'
CursorRef = '#/components/schemas/FamilyFeedCursor'
CursorScope = @('tenant', 'account', 'authSession', 'client', 'genealogyId', 'projection', 'order', 'limit', 'windowUpperBound', 'lastTuple')
CursorOrder = @('publishedAt:DESC', 'feedId:DESC_ORDINAL')
},
[pscustomobject]@{
Path = $feedDetailPath
Method = 'get'
Id = 'appGetFamilyFeed'
Parameters = @('header:clientid', 'path:feedId', 'path:genealogyId')
SuccessRef = '#/components/schemas/RAppFamilyFeedReadItem'
CursorRef = ''
CursorScope = @()
CursorOrder = @()
},
[pscustomobject]@{
Path = $rootCommentPath
Method = 'get'
Id = 'appListFamilyFeedRootComments'
Parameters = @('header:clientid', 'path:feedId', 'path:genealogyId', 'query:cursor', 'query:limit')
SuccessRef = '#/components/schemas/RAppFamilyFeedRootCommentCursorPage'
CursorRef = '#/components/schemas/FamilyFeedRootCommentCursor'
CursorScope = @('tenant', 'account', 'authSession', 'client', 'genealogyId', 'feedId', 'projection', 'order', 'limit', 'windowUpperBound', 'lastTuple')
CursorOrder = @('publishedAt:ASC', 'commentId:ASC_ORDINAL')
}
)
$responseRefs = [ordered]@{
'400' = '#/components/schemas/RFamilyFeedReadBadRequest'
'401' = '#/components/schemas/RFamilyFeedReadUnauthorized'
'404' = '#/components/schemas/RFamilyFeedReadNotFound'
'429' = '#/components/schemas/RFamilyFeedReadRateLimited'
'500' = '#/components/schemas/RFamilyFeedReadUnavailable'
}
function Add-Issue([string]$Message) {
$script:issues.Add($Message)
}
function Get-ExactProperty([object]$Object, [string]$Name) {
if ($null -eq $Object) { return $null }
$matches = @($Object.PSObject.Properties | Where-Object { $_.Name -ceq $Name })
if ($matches.Count -eq 1) { return $matches[0] }
return $null
}
function Test-ContainsExact([object]$Object, [string]$Name) {
return $null -ne (Get-ExactProperty $Object $Name)
}
function Test-IsJsonArray([object]$Value) {
return $null -ne $Value -and $Value.GetType().IsArray
}
function Test-IsJsonBoolean([object]$Value, [bool]$Expected) {
return $Value -is [System.Boolean] -and $Value -eq $Expected
}
function Test-ContainsExactString([object[]]$Values, [string]$Candidate) {
foreach ($value in @($Values)) {
if ([string]$value -ceq $Candidate) { return $true }
}
return $false
}
function Assert-CanonicalKeyCasing(
[object]$Object,
[string[]]$CanonicalNames,
[string]$Label,
[bool]$AllowExtensions = $true
) {
if (-not $Object) { return }
foreach ($property in @($Object.PSObject.Properties)) {
if ($AllowExtensions -and $property.Name.StartsWith('x-', [System.StringComparison]::Ordinal)) { continue }
if (Test-ContainsExactString $CanonicalNames $property.Name) { continue }
if (@($CanonicalNames | Where-Object { $_ -ieq $property.Name }).Count -gt 0) {
Add-Issue "$Label keyword casing is invalid: $($property.Name)"
} else {
Add-Issue "$Label contains an unowned keyword: $($property.Name)"
}
}
}
function Assert-AllowedOperationKeys(
[object]$Object,
[string[]]$ExpectedExtensions,
[string]$Label
) {
if (-not $Object) { return }
$standardNames = @(
'tags', 'summary', 'description', 'externalDocs', 'operationId',
'parameters', 'requestBody', 'responses', 'callbacks', 'deprecated',
'security', 'servers'
)
foreach ($property in @($Object.PSObject.Properties)) {
if ((Test-ContainsExactString $standardNames $property.Name) -or
(Test-ContainsExactString $ExpectedExtensions $property.Name)) {
continue
}
$allNames = @($standardNames) + @($ExpectedExtensions)
if (@($allNames | Where-Object { $_ -ieq $property.Name }).Count -gt 0) {
Add-Issue "$Label operation keyword casing is invalid: $($property.Name)"
} else {
Add-Issue "$Label operation contains an unowned keyword: $($property.Name)"
}
}
}
function Test-IsNonNullable([object]$Schema) {
if (-not $Schema) { return $false }
$nullable = Get-ExactProperty $Schema 'nullable'
return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false)
}
function Assert-ExactArray([object]$Value, [string[]]$Expected, [string]$Label) {
if (-not (Test-IsJsonArray $Value)) {
Add-Issue "$Label must be the exact JSON array [$($Expected -join ',')]"
return
}
$actual = @($Value)
if ($actual.Count -ne $Expected.Count) {
Add-Issue "$Label must be the exact JSON array [$($Expected -join ',')]"
return
}
for ($index = 0; $index -lt $Expected.Count; $index++) {
if ($actual[$index] -isnot [string] -or [string]$actual[$index] -cne $Expected[$index]) {
Add-Issue "$Label must be the exact JSON array [$($Expected -join ',')]"
return
}
}
}
function Get-LocalComponentName([string]$Ref, [string]$Section, [string]$Label) {
$pattern = '^#/components/' + [regex]::Escape($Section) + '/(?<name>[^/]+)$'
$match = [regex]::Match($Ref, $pattern)
if (-not $match.Success) {
Add-Issue "$Label must use an exact local #/components/$Section/... ref; actual: $Ref"
return ''
}
return $match.Groups['name'].Value
}
function Assert-NoConflictingSchemaKeywords([object]$Schema, [string]$Label, [string[]]$Allowed = @()) {
if (-not $Schema) { return }
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) {
if (-not (Test-ContainsExactString $Allowed $keyword) -and (Test-ContainsExact $Schema $keyword)) {
Add-Issue "$Label must not define conflicting schema keyword: $keyword"
}
}
}
function Assert-AllowedSchemaKeywords(
[object]$Schema,
[string]$Label,
[string[]]$Allowed,
[string[]]$AllowedExtensions = @()
) {
if (-not $Schema) { return }
$annotations = @('title', 'description', 'example', 'examples', 'deprecated', 'externalDocs', 'xml')
foreach ($property in @($Schema.PSObject.Properties)) {
if ((Test-ContainsExactString $annotations $property.Name) -or
(Test-ContainsExactString $Allowed $property.Name) -or
(Test-ContainsExactString $AllowedExtensions $property.Name)) { continue }
Add-Issue "$Label contains an unowned schema keyword: $($property.Name)"
}
}
function Test-IsPureComponentRef(
[object]$Value,
[string]$ExpectedRef,
[string]$Section,
[string]$Label
) {
if (-not $Value) { return $false }
$names = @($Value.PSObject.Properties.Name)
$actualRef = [string]$Value.'$ref'
if ($names.Count -ne 1 -or $names[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
Add-Issue "$Label must be the sole exact local ref $ExpectedRef; actual: $actualRef"
return $false
}
[void](Get-LocalComponentName $actualRef $Section $Label)
return $true
}
function Test-IsPureSchemaRef([object]$Schema, [string]$ExpectedRef, [string]$Label) {
return Test-IsPureComponentRef $Schema $ExpectedRef 'schemas' $Label
}
function Get-Schema([string]$Name) {
$schemas = Get-ExactProperty $document.components 'schemas'
$property = if ($schemas) { Get-ExactProperty $schemas.Value $Name } else { $null }
if (-not $property) {
Add-Issue "missing schema owner: $Name"
return $null
}
return $property.Value
}
function Assert-CanonicalPathItemKeys([string]$Path) {
$pathProperty = Get-ExactProperty $document.paths $Path
if (-not $pathProperty) { return }
$canonical = @('$ref', 'summary', 'description', 'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace', 'servers', 'parameters')
foreach ($property in @($pathProperty.Value.PSObject.Properties)) {
if ($property.Name.StartsWith('x-', [System.StringComparison]::Ordinal) -or
(Test-ContainsExactString $canonical $property.Name)) { continue }
if (@($canonical | Where-Object { $_ -ieq $property.Name }).Count -gt 0) {
Add-Issue "$Path Path Item keyword casing is invalid: $($property.Name)"
} else {
Add-Issue "$Path Path Item contains an unowned keyword: $($property.Name)"
}
}
}
function Get-Operation([string]$Path, [string]$Method) {
$pathProperty = Get-ExactProperty $document.paths $Path
if (-not $pathProperty) {
Add-Issue "missing path: $Path"
return $null
}
Assert-CanonicalPathItemKeys $Path
$operationProperty = Get-ExactProperty $pathProperty.Value $Method
if (-not $operationProperty) {
Add-Issue "missing $($Method.ToUpperInvariant()) $Path"
return $null
}
return $operationProperty.Value
}
function Resolve-Parameter([object]$Parameter, [string]$Label) {
if (-not $Parameter) { return $null }
$refProperty = Get-ExactProperty $Parameter '$ref'
if (-not $refProperty) { return $Parameter }
$referenceKeys = @($Parameter.PSObject.Properties.Name)
if ($referenceKeys.Count -ne 1 -or $referenceKeys[0] -cne '$ref') {
Add-Issue "$Label parameter ref must contain only its exact local `$ref under OpenAPI 3.0.1"
}
$name = Get-LocalComponentName ([string]$refProperty.Value) 'parameters' $Label
if (-not $name) { return $null }
$parameters = Get-ExactProperty $document.components 'parameters'
$owner = if ($parameters) { Get-ExactProperty $parameters.Value $name } else { $null }
if (-not $owner) {
Add-Issue "$Label references missing parameter owner: $name"
return $null
}
return $owner.Value
}
function Assert-ParameterObject([object]$Parameter, [string]$Label) {
if (-not $Parameter) { return }
$allowed = @(
'name', 'in', 'description', 'required', 'deprecated',
'schema', 'example', 'examples'
)
foreach ($property in @($Parameter.PSObject.Properties)) {
if (Test-ContainsExactString $allowed $property.Name) { continue }
if (@($allowed | Where-Object { $_ -ieq $property.Name }).Count -gt 0) {
Add-Issue "$Label Parameter Object keyword casing is invalid: $($property.Name)"
} else {
Add-Issue "$Label Parameter Object contains an unowned keyword: $($property.Name)"
}
}
$schemaProperty = Get-ExactProperty $Parameter 'schema'
if (-not $schemaProperty) {
Add-Issue "$Label Parameter Object must define exact schema"
}
}
function Get-OperationParameters([string]$Path, [object]$Operation, [string]$Label) {
if (-not $Operation) { return @() }
$pathItem = (Get-ExactProperty $document.paths $Path).Value
$merged = [System.Collections.Generic.Dictionary[string, object]]::new(
[System.StringComparer]::Ordinal
)
foreach ($entry in @(
[pscustomobject]@{ Scope = 'path'; Owner = $pathItem },
[pscustomobject]@{ Scope = 'operation'; Owner = $Operation }
)) {
$parametersProperty = Get-ExactProperty $entry.Owner 'parameters'
if (-not $parametersProperty) { continue }
if (-not (Test-IsJsonArray $parametersProperty.Value)) {
Add-Issue "$Label $($entry.Scope) parameters must be a JSON array"
continue
}
$scopeSeen = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::Ordinal
)
foreach ($parameter in @($parametersProperty.Value)) {
$resolved = Resolve-Parameter $parameter "$Label $($entry.Scope)"
if (-not $resolved) { continue }
$nameProperty = Get-ExactProperty $resolved 'name'
$inProperty = Get-ExactProperty $resolved 'in'
if (-not $nameProperty -or -not $inProperty) {
Add-Issue "$Label $($entry.Scope) parameter must define exact name and in"
continue
}
Assert-ParameterObject $resolved "$Label $($entry.Scope) $([string]$inProperty.Value):$([string]$nameProperty.Value)"
$parameterIn = [string]$inProperty.Value
$parameterName = [string]$nameProperty.Value
if (-not (Test-ContainsExactString @('query', 'header', 'path', 'cookie') $parameterIn) -or
[string]::IsNullOrWhiteSpace($parameterName)) {
Add-Issue "$Label $($entry.Scope) parameter identity is invalid: ${parameterIn}:${parameterName}"
continue
}
$identityName = if ($parameterIn -ceq 'header') {
$parameterName.ToLowerInvariant()
} else {
$parameterName
}
$identity = "${parameterIn}:${identityName}"
if (-not $scopeSeen.Add($identity)) {
Add-Issue "$Label contains a duplicate parameter in one scope: ${parameterIn}:${parameterName}"
continue
}
$merged[$identity] = $resolved
}
}
return @($merged.Values)
}
function Get-Parameter([object[]]$Parameters, [string]$In, [string]$Name, [string]$Label) {
$matches = @($Parameters | Where-Object {
[string]$_.in -ceq $In -and [string]$_.name -ceq $Name
})
if ($matches.Count -ne 1) {
Add-Issue "$Label must define exactly one ${In}:${Name}"
return $null
}
return $matches[0]
}
function Assert-ExactParameters([object[]]$Parameters, [string[]]$Expected, [string]$Label) {
$actual = @($Parameters | ForEach-Object { "$([string]$_.in):$([string]$_.name)" } | Sort-Object)
$wanted = @($Expected | Sort-Object)
if (($actual -join ',') -cne ($wanted -join ',')) {
Add-Issue "$Label parameters must be exactly [$($wanted -join ',')]; actual: [$($actual -join ',')]"
}
}
function Assert-ParameterRequired([object]$Parameter, [bool]$Expected, [string]$Label) {
if (-not $Parameter) { return }
$required = Get-ExactProperty $Parameter 'required'
if (-not $required -or -not (Test-IsJsonBoolean $required.Value $Expected)) {
Add-Issue "$Label required must be the JSON boolean $Expected"
}
}
function Resolve-Response([object]$Response, [string]$Label) {
if (-not $Response) { return $null }
$refProperty = Get-ExactProperty $Response '$ref'
if (-not $refProperty) { return $Response }
$referenceKeys = @($Response.PSObject.Properties.Name)
if ($referenceKeys.Count -ne 1 -or $referenceKeys[0] -cne '$ref') {
Add-Issue "$Label response ref must contain only its exact local `$ref under OpenAPI 3.0.1"
}
$name = Get-LocalComponentName ([string]$refProperty.Value) 'responses' $Label
if (-not $name) { return $null }
$responses = Get-ExactProperty $document.components 'responses'
$owner = if ($responses) { Get-ExactProperty $responses.Value $name } else { $null }
if (-not $owner) {
Add-Issue "$Label references missing response owner: $name"
return $null
}
return $owner.Value
}
function Resolve-Header([object]$Header, [string]$Label) {
if (-not $Header) { return $null }
$refProperty = Get-ExactProperty $Header '$ref'
if (-not $refProperty) { return $Header }
$referenceKeys = @($Header.PSObject.Properties.Name)
if ($referenceKeys.Count -ne 1 -or $referenceKeys[0] -cne '$ref') {
Add-Issue "$Label header ref must contain only its exact local `$ref under OpenAPI 3.0.1"
}
$name = Get-LocalComponentName ([string]$refProperty.Value) 'headers' $Label
if (-not $name) { return $null }
$headers = Get-ExactProperty $document.components 'headers'
$owner = if ($headers) { Get-ExactProperty $headers.Value $name } else { $null }
if (-not $owner) {
Add-Issue "$Label references missing header owner: $name"
return $null
}
return $owner.Value
}
function Get-Response([object]$Operation, [string]$Status, [string]$Label) {
if (-not $Operation) { return $null }
$responsesProperty = Get-ExactProperty $Operation 'responses'
$property = if ($responsesProperty) { Get-ExactProperty $responsesProperty.Value $Status } else { $null }
if (-not $property) {
Add-Issue "$Label missing response $Status"
return $null
}
return Resolve-Response $property.Value "$Label $Status"
}
function Assert-ExactResponseSet([object]$Operation, [string]$Label) {
if (-not $Operation) { return }
$responsesProperty = Get-ExactProperty $Operation 'responses'
if (-not $responsesProperty) {
Add-Issue "$Label must define responses"
return
}
$actual = @($responsesProperty.Value.PSObject.Properties.Name | Sort-Object)
$wanted = @('200', '400', '401', '404', '429', '500')
if (($actual -join ',') -cne ($wanted -join ',')) {
Add-Issue "$Label responses must be exactly [$($wanted -join ',')]; actual: [$($actual -join ',')]"
}
}
function Get-JsonResponseSchemaRef([object]$Response, [string]$Label) {
if (-not $Response) { return '' }
$contentProperty = Get-ExactProperty $Response 'content'
$media = if ($contentProperty) { @($contentProperty.Value.PSObject.Properties) } else { @() }
if ($media.Count -ne 1 -or $media[0].Name -cne 'application/json') {
Add-Issue "$Label must expose only application/json"
return ''
}
$schemaProperty = Get-ExactProperty $media[0].Value 'schema'
if (-not $schemaProperty) {
Add-Issue "$Label must define a response schema"
return ''
}
$schema = $schemaProperty.Value
$ref = [string]$schema.'$ref'
if (-not $ref) {
Add-Issue "$Label response schema must use an exact local component ref"
return ''
}
[void](Test-IsPureSchemaRef $schema $ref "$Label response schema")
return $ref
}
function Assert-PrivateNoStore([object]$Response, [string]$Label) {
if (-not $Response) { return }
$headersProperty = Get-ExactProperty $Response 'headers'
$cacheProperty = if ($headersProperty) { Get-ExactProperty $headersProperty.Value 'Cache-Control' } else { $null }
if (-not $cacheProperty) {
Add-Issue "$Label must define Cache-Control: private, no-store"
return
}
$header = Resolve-Header $cacheProperty.Value "$Label Cache-Control"
if (-not $header) { return }
foreach ($property in @($header.PSObject.Properties)) {
if (-not (Test-ContainsExactString @('description', 'deprecated', 'schema') $property.Name)) {
Add-Issue "$Label Cache-Control contains an unowned Header Object keyword: $($property.Name)"
}
}
$schemaProperty = Get-ExactProperty $header 'schema'
$schema = if ($schemaProperty) { $schemaProperty.Value } else { $null }
if (-not $schema -or [string]$schema.type -cne 'string' -or
-not (Test-IsNonNullable $schema) -or
-not (Test-IsJsonArray $schema.enum) -or
@($schema.enum).Count -ne 1 -or
[string]$schema.enum[0] -cne 'private, no-store') {
Add-Issue "$Label Cache-Control must be the single non-null value private, no-store"
}
if ($schema) {
Assert-NoConflictingSchemaKeywords $schema "$Label Cache-Control" @('enum')
Assert-AllowedSchemaKeywords $schema "$Label Cache-Control" @('type', 'enum', 'nullable')
}
}
function Assert-RetryAfter([object]$Response, [string]$Label) {
if (-not $Response) { return }
$headersProperty = Get-ExactProperty $Response 'headers'
$retryProperty = if ($headersProperty) { Get-ExactProperty $headersProperty.Value 'Retry-After' } else { $null }
if (-not $retryProperty) {
Add-Issue "$Label must define Retry-After"
return
}
$header = Resolve-Header $retryProperty.Value "$Label Retry-After"
if (-not $header) { return }
foreach ($property in @($header.PSObject.Properties)) {
if (-not (Test-ContainsExactString @('description', 'deprecated', 'schema') $property.Name)) {
Add-Issue "$Label Retry-After contains an unowned Header Object keyword: $($property.Name)"
}
}
$schemaProperty = Get-ExactProperty $header 'schema'
$schema = if ($schemaProperty) { $schemaProperty.Value } else { $null }
if (-not $schema -or [string]$schema.type -cne 'integer' -or
-not (Test-IsNonNullable $schema) -or
[int]$schema.minimum -ne 1 -or
[int]$schema.maximum -ne 300) {
Add-Issue "$Label Retry-After must be a non-null integer in 1..300 seconds"
}
if ($schema) {
Assert-NoConflictingSchemaKeywords $schema "$Label Retry-After"
Assert-AllowedSchemaKeywords $schema "$Label Retry-After" @('type', 'minimum', 'maximum', 'nullable')
}
}
function Assert-ResponseHeaders([object]$Response, [string]$Status, [string]$Label) {
if (-not $Response) { return }
foreach ($property in @($Response.PSObject.Properties)) {
if (-not (Test-ContainsExactString @('description', 'headers', 'content') $property.Name)) {
Add-Issue "$Label contains an unowned Response Object keyword: $($property.Name)"
}
}
$headersProperty = Get-ExactProperty $Response 'headers'
$headerNames = if ($headersProperty) { @($headersProperty.Value.PSObject.Properties.Name) } else { @() }
$allowed = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')
if ($Status -ceq '429') { $allowed += 'Retry-After' }
$unexpected = @($headerNames | Where-Object { -not (Test-ContainsExactString $allowed $_) })
if ($unexpected.Count -gt 0) {
Add-Issue "$Label contains unowned response headers: $($unexpected -join ',')"
}
Assert-PrivateNoStore $Response $Label
if ($Status -ceq '429') { Assert-RetryAfter $Response $Label }
}
function Assert-SaToken([object]$Operation, [string]$Label) {
if (-not $Operation) { return }
$securityProperty = Get-ExactProperty $Operation 'security'
if (-not $securityProperty -or -not (Test-IsJsonArray $securityProperty.Value)) {
Add-Issue "$Label security must be a JSON array"
return
}
$requirements = @($securityProperty.Value)
if ($requirements.Count -ne 1) {
Add-Issue "$Label must have exactly one security requirement"
return
}
$names = @($requirements[0].PSObject.Properties.Name)
if ($names.Count -ne 1 -or $names[0] -cne 'SaToken') {
Add-Issue "$Label must require only exact SaToken"
return
}
$scopes = $requirements[0].SaToken
if (-not (Test-IsJsonArray $scopes) -or @($scopes).Count -ne 0) {
Add-Issue "$Label SaToken scopes must be an empty JSON array"
}
}
function Assert-SaTokenScheme {
$componentsProperty = Get-ExactProperty $document 'components'
$schemesProperty = if ($componentsProperty) {
Get-ExactProperty $componentsProperty.Value 'securitySchemes'
} else {
$null
}
$schemeProperty = if ($schemesProperty) {
Get-ExactProperty $schemesProperty.Value 'SaToken'
} else {
$null
}
if (-not $schemeProperty) {
Add-Issue 'components.securitySchemes.SaToken must be the exact security owner'
return
}
$scheme = $schemeProperty.Value
$allowed = @('type', 'description', 'name', 'in')
foreach ($property in @($scheme.PSObject.Properties)) {
if (Test-ContainsExactString $allowed $property.Name) { continue }
if (@($allowed | Where-Object { $_ -ieq $property.Name }).Count -gt 0) {
Add-Issue "SaToken Security Scheme keyword casing is invalid: $($property.Name)"
} else {
Add-Issue "SaToken Security Scheme contains an unowned keyword: $($property.Name)"
}
}
$typeProperty = Get-ExactProperty $scheme 'type'
$inProperty = Get-ExactProperty $scheme 'in'
$nameProperty = Get-ExactProperty $scheme 'name'
if (-not $typeProperty -or [string]$typeProperty.Value -cne 'apiKey' -or
-not $inProperty -or [string]$inProperty.Value -cne 'header' -or
-not $nameProperty -or [string]$nameProperty.Value -cne 'Authorization') {
Add-Issue 'SaToken must be the exact apiKey/header/Authorization security owner'
}
}
function Assert-ClosedObject(
[object]$Schema,
[string]$Name,
[string[]]$Fields,
[string[]]$Required,
[string[]]$AllowedExtensions = @()
) {
if (-not $Schema) { return }
$propertiesProperty = Get-ExactProperty $Schema 'properties'
$requiredProperty = Get-ExactProperty $Schema 'required'
$additionalProperty = Get-ExactProperty $Schema 'additionalProperties'
$actualFields = if ($propertiesProperty) { @($propertiesProperty.Value.PSObject.Properties.Name | Sort-Object) } else { @() }
$actualRequired = if ($requiredProperty -and (Test-IsJsonArray $requiredProperty.Value)) { @($requiredProperty.Value | Sort-Object) } else { @() }
$expectedFields = @($Fields | Sort-Object)
$expectedRequired = @($Required | Sort-Object)
if ([string]$Schema.type -cne 'object' -or
-not (Test-IsNonNullable $Schema) -or
-not $additionalProperty -or
-not (Test-IsJsonBoolean $additionalProperty.Value $false) -or
($actualFields -join ',') -cne ($expectedFields -join ',') -or
($actualRequired -join ',') -cne ($expectedRequired -join ',')) {
Add-Issue "$Name must be closed with fields [$($expectedFields -join ',')] and required [$($expectedRequired -join ',')]"
}
Assert-NoConflictingSchemaKeywords $Schema $Name
Assert-AllowedSchemaKeywords $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable') $AllowedExtensions
}
function Assert-PropertyRef([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef) {
if (-not $Schema) { return }
$properties = Get-ExactProperty $Schema 'properties'
$property = if ($properties) { Get-ExactProperty $properties.Value $Field } else { $null }
if (-not $property) {
Add-Issue "$SchemaName.$Field must use $ExpectedRef"
return
}
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
}
function Assert-StringOwner(
[string]$Name,
[int]$MinLength,
[int]$MaxLength,
[string]$Pattern = '',
[string[]]$AllowedExtensions = @()
) {
$schema = Get-Schema $Name
if (-not $schema) { return }
if ([string]$schema.type -cne 'string' -or
-not (Test-IsNonNullable $schema) -or
[int]$schema.minLength -ne $MinLength -or
[int]$schema.maxLength -ne $MaxLength -or
($Pattern -and [string]$schema.pattern -cne $Pattern)) {
Add-Issue "$Name must be a non-null string length $MinLength..$MaxLength$(if ($Pattern) { ' with its exact pattern' } else { '' })"
}
Assert-NoConflictingSchemaKeywords $schema $Name
$keywords = @('type', 'minLength', 'maxLength', 'nullable')
if ($Pattern) { $keywords += 'pattern' }
Assert-AllowedSchemaKeywords $schema $Name $keywords $AllowedExtensions
return $schema
}
function Assert-DateTimeOwner([string]$Name) {
$schema = Get-Schema $Name
if (-not $schema) { return }
if ([string]$schema.type -cne 'string' -or
[string]$schema.format -cne 'date-time' -or
-not (Test-IsNonNullable $schema) -or
-not (Test-IsJsonBoolean $schema.'x-server-generated' $true) -or
-not (Test-IsJsonBoolean $schema.'x-immutable' $true)) {
Add-Issue "$Name must be a non-null immutable server-generated RFC3339 date-time"
}
Assert-NoConflictingSchemaKeywords $schema $Name
Assert-AllowedSchemaKeywords $schema $Name @('type', 'format', 'nullable') @('x-server-generated', 'x-immutable')
}
function Assert-SuccessEnvelope([string]$Name, [string]$DataRef) {
$schema = Get-Schema $Name
Assert-ClosedObject $schema $Name @('code', 'data') @('code', 'data')
if (-not $schema) { return }
$code = $schema.properties.code
if ([string]$code.type -cne 'integer' -or
-not (Test-IsNonNullable $code) -or
-not (Test-IsJsonArray $code.enum) -or
@($code.enum).Count -ne 1 -or
[int]$code.enum[0] -ne 200) {
Add-Issue "$Name.code must be the single non-null integer value 200"
}
Assert-NoConflictingSchemaKeywords $code "$Name.code" @('enum')
Assert-AllowedSchemaKeywords $code "$Name.code" @('type', 'enum', 'nullable')
Assert-PropertyRef $schema $Name 'data' $DataRef
}
function Assert-FixedError([string]$Name, [int]$Status, [string[]]$BusinessCodes) {
$schema = Get-Schema $Name
Assert-ClosedObject $schema $Name @('businessCode', 'code', 'message') @('businessCode', 'code', 'message')
if (-not $schema) { return }
$code = $schema.properties.code
$businessCode = $schema.properties.businessCode
$message = $schema.properties.message
if ([string]$code.type -cne 'integer' -or
-not (Test-IsNonNullable $code) -or
-not (Test-IsJsonArray $code.enum) -or
@($code.enum).Count -ne 1 -or
[int]$code.enum[0] -ne $Status) {
Add-Issue "$Name.code must be the single non-null integer value $Status"
}
$actualCodes = @($businessCode.enum | Sort-Object)
$expectedCodes = @($BusinessCodes | Sort-Object)
if ([string]$businessCode.type -cne 'string' -or
-not (Test-IsNonNullable $businessCode) -or
-not (Test-IsJsonArray $businessCode.enum) -or
($actualCodes -join ',') -cne ($expectedCodes -join ',')) {
Add-Issue "$Name.businessCode must be exactly [$($expectedCodes -join ',')]"
}
if ([string]$message.type -cne 'string' -or
-not (Test-IsNonNullable $message) -or
[int]$message.minLength -ne 1 -or
[int]$message.maxLength -ne 200) {
Add-Issue "$Name.message must be a non-null string length 1..200"
}
Assert-NoConflictingSchemaKeywords $code "$Name.code" @('enum')
Assert-NoConflictingSchemaKeywords $businessCode "$Name.businessCode" @('enum')
Assert-NoConflictingSchemaKeywords $message "$Name.message"
Assert-AllowedSchemaKeywords $code "$Name.code" @('type', 'enum', 'nullable')
Assert-AllowedSchemaKeywords $businessCode "$Name.businessCode" @('type', 'enum', 'nullable')
Assert-AllowedSchemaKeywords $message "$Name.message" @('type', 'minLength', 'maxLength', 'nullable')
}
function Assert-CursorPage(
[string]$Name,
[string]$ItemRef,
[string]$CursorRef,
[string[]]$Order
) {
$schema = Get-Schema $Name
Assert-ClosedObject $schema $Name @('items', 'nextCursor') @('items') @(
'x-no-total',
'x-next-cursor-absent-at-end',
'x-order',
'x-read-window',
'x-new-items-after-window',
'x-deletion-or-visibility-change',
'x-edit-policy'
)
if (-not $schema) { return }
$items = $schema.properties.items
if ([string]$items.type -cne 'array' -or
-not (Test-IsNonNullable $items) -or
[int]$items.minItems -ne 0 -or
[int]$items.maxItems -ne 50) {
Add-Issue "$Name.items must be a non-null array with 0..50 rows"
}
if ($items) {
[void](Test-IsPureSchemaRef $items.items $ItemRef "$Name.items.items")
Assert-NoConflictingSchemaKeywords $items "$Name.items"
Assert-AllowedSchemaKeywords $items "$Name.items" @('type', 'items', 'minItems', 'maxItems', 'nullable')
}
Assert-PropertyRef $schema $Name 'nextCursor' $CursorRef
$noTotal = Get-ExactProperty $schema 'x-no-total'
$absentAtEnd = Get-ExactProperty $schema 'x-next-cursor-absent-at-end'
$orderProperty = Get-ExactProperty $schema 'x-order'
$readWindow = Get-ExactProperty $schema 'x-read-window'
$newItems = Get-ExactProperty $schema 'x-new-items-after-window'
$deletion = Get-ExactProperty $schema 'x-deletion-or-visibility-change'
$editPolicy = Get-ExactProperty $schema 'x-edit-policy'
if (-not $noTotal -or -not (Test-IsJsonBoolean $noTotal.Value $true) -or
-not $absentAtEnd -or -not (Test-IsJsonBoolean $absentAtEnd.Value $true) -or
-not $readWindow -or [string]$readWindow.Value -cne 'UPPER_BOUND_KEYSET_LATEST_VISIBLE' -or
-not $newItems -or [string]$newItems.Value -cne 'EXCLUDED_UNTIL_REFRESH' -or
-not $deletion -or [string]$deletion.Value -cne 'OMIT_ON_LATER_PAGE' -or
-not $editPolicy -or [string]$editPolicy.Value -cne 'LATEST_VISIBLE_AT_PAGE_READ') {
Add-Issue "$Name cursor window/order/no-total semantics drifted"
}
Assert-ExactArray $(if ($orderProperty) { $orderProperty.Value } else { $null }) $Order "$Name x-order"
}
function Assert-NoForbiddenSuccessFields([string[]]$RootRefs) {
$queue = New-Object System.Collections.Generic.Queue[string]
$visited = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::Ordinal)
foreach ($rootRef in $RootRefs) {
$name = Get-LocalComponentName $rootRef 'schemas' 'success graph root'
if ($name) { $queue.Enqueue($name) }
}
$forbidden = '(?i)(phone|mobile|tenant|userId|appUser|publisherUser|audit|moderation|reviewer|operator|remark|createBy|updateBy|deleteFlag|userDeleted|parentComment|commentLevel|status|mediaOssId)'
while ($queue.Count -gt 0) {
$name = $queue.Dequeue()
if (-not $visited.Add($name)) { continue }
$schema = Get-Schema $name
if (-not $schema) { continue }
$properties = Get-ExactProperty $schema 'properties'
if ($properties) {
foreach ($property in @($properties.Value.PSObject.Properties)) {
if ($property.Name -match $forbidden) {
Add-Issue "success graph leaks forbidden/internal field: $name.$($property.Name)"
}
$propertyRef = Get-ExactProperty $property.Value '$ref'
if ($propertyRef) {
$child = Get-LocalComponentName ([string]$propertyRef.Value) 'schemas' "$name.$($property.Name)"
if ($child) { $queue.Enqueue($child) }
}
$itemsProperty = Get-ExactProperty $property.Value 'items'
$itemsRef = if ($itemsProperty) { Get-ExactProperty $itemsProperty.Value '$ref' } else { $null }
if ($itemsRef) {
$child = Get-LocalComponentName ([string]$itemsRef.Value) 'schemas' "$name.$($property.Name).items"
if ($child) { $queue.Enqueue($child) }
}
}
}
}
}
function Test-SchemaNodeExposesFamilyFeedRead(
[object]$Schema,
[System.Collections.Generic.HashSet[string]]$VisitedSchemas,
[string]$Label
) {
if (-not $Schema) { return $false }
$ownedNames = @(
'RAppFamilyFeedCursorPage',
'RAppFamilyFeedReadItem',
'RAppFamilyFeedRootCommentCursorPage',
'AppFamilyFeedCursorPage',
'AppFamilyFeedReadItem',
'AppFamilyFeedRootCommentCursorPage',
'AppFamilyFeedRootCommentReadItem'
)
$refProperty = Get-ExactProperty $Schema '$ref'
$refCaseVariants = @($Schema.PSObject.Properties | Where-Object { $_.Name -ieq '$ref' })
if (-not $refProperty -and $refCaseVariants.Count -gt 0) {
Add-Issue "$Label schema ref keyword casing is invalid"
return $false
}
if ($refProperty) {
$match = [regex]::Match(
[string]$refProperty.Value,
'^#/components/schemas/(?<name>[^/]+)$'
)
if (-not $match.Success) {
Add-Issue "$Label schema ref is not an inspectable exact local component ref"
return $false
}
$name = $match.Groups['name'].Value
if (Test-ContainsExactString $ownedNames $name) { return $true }
if (-not $VisitedSchemas.Add($name)) { return $false }
$components = Get-ExactProperty $document 'components'
$schemas = if ($components) { Get-ExactProperty $components.Value 'schemas' } else { $null }
$owner = if ($schemas) { Get-ExactProperty $schemas.Value $name } else { $null }
if (-not $owner) {
Add-Issue "$Label references a missing schema owner: $name"
return $false
}
return Test-SchemaNodeExposesFamilyFeedRead $owner.Value $VisitedSchemas "$Label -> $name"
}
$properties = Get-ExactProperty $Schema 'properties'
if ($properties) {
$fieldNames = @($properties.Value.PSObject.Properties.Name | Sort-Object)
$feedProjectionFields = @(
@('authorDisplayName', 'feedContent', 'feedId', 'hasMedia', 'publishedAt') |
Sort-Object
)
$commentProjectionFields = @(
@('authorDisplayName', 'commentContent', 'commentId', 'publishedAt') |
Sort-Object
)
$isFeedProjection =
($fieldNames -join ',') -ceq ($feedProjectionFields -join ',')
$isCommentProjection =
($fieldNames -join ',') -ceq ($commentProjectionFields -join ',')
if ($isFeedProjection -or $isCommentProjection) { return $true }
foreach ($property in @($properties.Value.PSObject.Properties)) {
if (Test-SchemaNodeExposesFamilyFeedRead $property.Value $VisitedSchemas "$Label.$($property.Name)") {
return $true
}
}
}
$items = Get-ExactProperty $Schema 'items'
if ($items -and
(Test-SchemaNodeExposesFamilyFeedRead $items.Value $VisitedSchemas "$Label.items")) {
return $true
}
$additionalProperties = Get-ExactProperty $Schema 'additionalProperties'
if ($additionalProperties -and $additionalProperties.Value -isnot [System.Boolean] -and
(Test-SchemaNodeExposesFamilyFeedRead $additionalProperties.Value $VisitedSchemas "$Label.additionalProperties")) {
return $true
}
foreach ($compositionName in @('allOf', 'oneOf', 'anyOf')) {
$composition = Get-ExactProperty $Schema $compositionName
if (-not $composition -or -not (Test-IsJsonArray $composition.Value)) { continue }
foreach ($branch in @($composition.Value)) {
if (Test-SchemaNodeExposesFamilyFeedRead $branch $VisitedSchemas "$Label.$compositionName") {
return $true
}
}
}
return $false
}
function Test-ResponseNodeExposesFamilyFeedRead(
[object]$Response,
[System.Collections.Generic.HashSet[string]]$VisitedResponses,
[System.Collections.Generic.HashSet[string]]$VisitedSchemas,
[string]$Label
) {
if (-not $Response) { return $false }
$refProperty = Get-ExactProperty $Response '$ref'
$refCaseVariants = @($Response.PSObject.Properties | Where-Object { $_.Name -ieq '$ref' })
if (-not $refProperty -and $refCaseVariants.Count -gt 0) {
Add-Issue "$Label response ref keyword casing is invalid"
return $false
}
if ($refProperty) {
$match = [regex]::Match(
[string]$refProperty.Value,
'^#/components/responses/(?<name>[^/]+)$'
)
if (-not $match.Success) {
Add-Issue "$Label response ref is not an inspectable exact local component ref"
return $false
}
$name = $match.Groups['name'].Value
if (-not $VisitedResponses.Add($name)) { return $false }
$components = Get-ExactProperty $document 'components'
$responses = if ($components) { Get-ExactProperty $components.Value 'responses' } else { $null }
$owner = if ($responses) { Get-ExactProperty $responses.Value $name } else { $null }
if (-not $owner) {
Add-Issue "$Label references a missing response owner: $name"
return $false
}
return Test-ResponseNodeExposesFamilyFeedRead $owner.Value $VisitedResponses $VisitedSchemas "$Label -> $name"
}
$content = Get-ExactProperty $Response 'content'
$contentCaseVariants = @($Response.PSObject.Properties | Where-Object { $_.Name -ieq 'content' })
if (-not $content -and $contentCaseVariants.Count -gt 0) {
Add-Issue "$Label Response Object content keyword casing is invalid"
return $false
}
if (-not $content) { return $false }
foreach ($media in @($content.Value.PSObject.Properties)) {
$schema = Get-ExactProperty $media.Value 'schema'
$schemaCaseVariants = @($media.Value.PSObject.Properties | Where-Object { $_.Name -ieq 'schema' })
if (-not $schema -and $schemaCaseVariants.Count -gt 0) {
Add-Issue "$Label media schema keyword casing is invalid"
continue
}
if ($schema -and
(Test-SchemaNodeExposesFamilyFeedRead $schema.Value $VisitedSchemas "$Label $($media.Name)")) {
return $true
}
}
return $false
}
function Scan-AppCallbackObject(
[object]$Callback,
[string]$Context,
[System.Collections.Generic.HashSet[string]]$VisitedCallbacks
) {
if (-not $Callback) { return }
$refProperty = Get-ExactProperty $Callback '$ref'
if ($refProperty) {
$keys = @($Callback.PSObject.Properties.Name)
if ($keys.Count -ne 1 -or $keys[0] -cne '$ref') {
Add-Issue "$Context callback ref must contain only its exact local `$ref under OpenAPI 3.0.1"
}
$match = [regex]::Match(
[string]$refProperty.Value,
'^#/components/callbacks/(?<name>[^/]+)$'
)
if (-not $match.Success) {
Add-Issue "$Context callback ref is not an inspectable exact local component ref"
return
}
$name = $match.Groups['name'].Value
if (-not $VisitedCallbacks.Add($name)) { return }
$components = Get-ExactProperty $document 'components'
$callbacks = if ($components) { Get-ExactProperty $components.Value 'callbacks' } else { $null }
$owner = if ($callbacks) { Get-ExactProperty $callbacks.Value $name } else { $null }
if (-not $owner) {
Add-Issue "$Context references a missing callback owner: $name"
return
}
Scan-AppCallbackObject $owner.Value "$Context -> $name" $VisitedCallbacks
return
}
foreach ($expression in @($Callback.PSObject.Properties)) {
Scan-AppPathItem $expression.Value "$Context callback $($expression.Name)" '' $false $VisitedCallbacks
}
}
function Scan-AppPathItem(
[object]$PathItem,
[string]$Context,
[string]$TopLevelPath,
[bool]$IsTopLevel,
[System.Collections.Generic.HashSet[string]]$VisitedCallbacks
) {
if (-not $PathItem) { return }
foreach ($method in @('get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace')) {
$operationProperty = Get-ExactProperty $PathItem $method
if (-not $operationProperty) { continue }
$operation = $operationProperty.Value
$responses = Get-ExactProperty $operation 'responses'
if ($responses) {
foreach ($responseProperty in @($responses.Value.PSObject.Properties)) {
if ($responseProperty.Name -notmatch '^(2[0-9][0-9]|default)$') { continue }
$visitedResponses = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::Ordinal
)
$visitedSchemas = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::Ordinal
)
$exposes = Test-ResponseNodeExposesFamilyFeedRead `
$responseProperty.Value `
$visitedResponses `
$visitedSchemas `
"$Context $($method.ToUpperInvariant()) $($responseProperty.Name)"
if (-not $exposes) { continue }
$isCanonicalOwner =
$IsTopLevel -and
$method -ceq 'get' -and
$responseProperty.Name -ceq '200' -and
(Test-ContainsExactString @($feedListPath, $feedDetailPath, $rootCommentPath) $TopLevelPath)
if (-not $isCanonicalOwner) {
Add-Issue "alternate APP operation exposes the family-feed read projection: $Context $($method.ToUpperInvariant()) $($responseProperty.Name)"
}
}
}
$callbacks = Get-ExactProperty $operation 'callbacks'
if ($callbacks) {
foreach ($callbackProperty in @($callbacks.Value.PSObject.Properties)) {
Scan-AppCallbackObject $callbackProperty.Value "$Context $($method.ToUpperInvariant()) callback $($callbackProperty.Name)" $VisitedCallbacks
}
}
}
}
if (-not $SkipProtectedParity) {
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
$parityOutput = @(& node $parityScript 2>&1)
if ($LASTEXITCODE -ne 0 -or -not (Test-ContainsExactString $parityOutput 'OPENAPI-YAML-JSON-PARITY PASS')) {
Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')"
}
}
Assert-CanonicalKeyCasing $document @(
'openapi', 'info', 'servers', 'paths',
'components', 'security', 'tags', 'externalDocs'
) 'OpenAPI root'
$openapiProperty = Get-ExactProperty $document 'openapi'
if (-not $openapiProperty -or [string]$openapiProperty.Value -cne '3.0.1') {
Add-Issue 'OpenAPI version must be exact 3.0.1'
}
$pathsProperty = Get-ExactProperty $document 'paths'
if (-not $pathsProperty) {
Add-Issue 'OpenAPI root must define exact paths'
}
$componentsProperty = Get-ExactProperty $document 'components'
if (-not $componentsProperty) {
Add-Issue 'OpenAPI root must define exact components'
}
Assert-CanonicalKeyCasing $(if ($componentsProperty) { $componentsProperty.Value } else { $null }) @(
'schemas', 'responses', 'parameters', 'examples', 'requestBodies', 'headers',
'securitySchemes', 'links', 'callbacks'
) 'OpenAPI components'
Assert-SaTokenScheme
foreach ($legacyPath in @($legacyFeedPagePath, $legacyCommentPagePath)) {
$pathProperty = Get-ExactProperty $document.paths $legacyPath
if (-not $pathProperty) { continue }
$legacyGets = @($pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -ieq 'get' })
if ($legacyGets.Count -gt 0) {
Add-Issue "legacy duplicate GET owner must be removed: $legacyPath"
}
}
$resolvedOperations = @{}
foreach ($spec in $operations) {
$operation = Get-Operation $spec.Path $spec.Method
if (-not $operation) { continue }
$resolvedOperations[$spec.Id] = $operation
$operationIdProperty = Get-ExactProperty $operation 'operationId'
if (-not $operationIdProperty -or [string]$operationIdProperty.Value -cne $spec.Id) {
Add-Issue "$($spec.Method.ToUpperInvariant()) $($spec.Path) operationId must be $($spec.Id)"
}
$extensionNames = @(
'x-read-only',
'x-non-disclosing-not-found',
'x-authorize-every-request',
'x-cors-policy-owner',
'x-authorization-scope'
)
if ($spec.CursorRef) {
$extensionNames += @(
'x-cursor-scope',
'x-cursor-order',
'x-read-window',
'x-cursor-no-total',
'x-refresh-discards-cursor',
'x-authorize-every-page',
'x-invalid-or-expired-cursor',
'x-cross-scope-cursor'
)
}
Assert-AllowedOperationKeys $operation $extensionNames $spec.Id
$requestBodyProperty = Get-ExactProperty $operation 'requestBody'
if ($requestBodyProperty) {
Add-Issue "$($spec.Id) is read-only and must not define requestBody"
}
if (Get-ExactProperty $operation 'callbacks') {
Add-Issue "$($spec.Id) is read-only and must not define callbacks"
}
$pathItem = (Get-ExactProperty $document.paths $spec.Path).Value
foreach ($forbiddenMethod in @('head', 'options', 'trace')) {
if (Get-ExactProperty $pathItem $forbiddenMethod) {
Add-Issue "$($spec.Path) must not expose an explicit $($forbiddenMethod.ToUpperInvariant()) read bypass"
}
}
Assert-SaToken $operation $spec.Id
Assert-ExactResponseSet $operation $spec.Id
$parameters = @(Get-OperationParameters $spec.Path $operation $spec.Id)
Assert-ExactParameters $parameters $spec.Parameters $spec.Id
$clientid = Get-Parameter $parameters 'header' 'clientid' $spec.Id
Assert-ParameterRequired $clientid $true "$($spec.Id) clientid"
if ($clientid) {
$clientSchemaProperty = Get-ExactProperty $clientid 'schema'
$clientSchema = if ($clientSchemaProperty) { $clientSchemaProperty.Value } else { $null }
if ([string]$clientSchema.type -cne 'string' -or
-not (Test-IsNonNullable $clientSchema) -or
[int]$clientSchema.minLength -ne 1 -or
[int]$clientSchema.maxLength -ne 128) {
Add-Issue "$($spec.Id) clientid must be a required non-null string length 1..128"
}
Assert-NoConflictingSchemaKeywords $clientSchema "$($spec.Id) clientid"
Assert-AllowedSchemaKeywords $clientSchema "$($spec.Id) clientid" @('type', 'minLength', 'maxLength', 'nullable')
}
$genealogyId = Get-Parameter $parameters 'path' 'genealogyId' $spec.Id
Assert-ParameterRequired $genealogyId $true "$($spec.Id) genealogyId"
if ($genealogyId) {
$schemaProperty = Get-ExactProperty $genealogyId 'schema'
[void](Test-IsPureSchemaRef $(if ($schemaProperty) { $schemaProperty.Value } else { $null }) '#/components/schemas/GenealogyId' "$($spec.Id) genealogyId")
}
if ($spec.Path -match '\{feedId\}') {
$feedId = Get-Parameter $parameters 'path' 'feedId' $spec.Id
Assert-ParameterRequired $feedId $true "$($spec.Id) feedId"
if ($feedId) {
$schemaProperty = Get-ExactProperty $feedId 'schema'
[void](Test-IsPureSchemaRef $(if ($schemaProperty) { $schemaProperty.Value } else { $null }) '#/components/schemas/FamilyFeedId' "$($spec.Id) feedId")
}
}
if ($spec.CursorRef) {
$cursor = Get-Parameter $parameters 'query' 'cursor' $spec.Id
$limit = Get-Parameter $parameters 'query' 'limit' $spec.Id
Assert-ParameterRequired $cursor $false "$($spec.Id) cursor"
Assert-ParameterRequired $limit $false "$($spec.Id) limit"
if ($cursor) {
$schemaProperty = Get-ExactProperty $cursor 'schema'
[void](Test-IsPureSchemaRef $(if ($schemaProperty) { $schemaProperty.Value } else { $null }) $spec.CursorRef "$($spec.Id) cursor")
}
if ($limit) {
$limitSchemaProperty = Get-ExactProperty $limit 'schema'
$limitSchema = if ($limitSchemaProperty) { $limitSchemaProperty.Value } else { $null }
if ([string]$limitSchema.type -cne 'integer' -or
-not (Test-IsNonNullable $limitSchema) -or
[int]$limitSchema.minimum -ne 1 -or
[int]$limitSchema.maximum -ne 50 -or
[int]$limitSchema.default -ne 20) {
Add-Issue "$($spec.Id) limit must be an optional non-null integer default 20 in 1..50"
}
Assert-NoConflictingSchemaKeywords $limitSchema "$($spec.Id) limit"
Assert-AllowedSchemaKeywords $limitSchema "$($spec.Id) limit" @('type', 'minimum', 'maximum', 'default', 'nullable')
}
$cursorScope = Get-ExactProperty $operation 'x-cursor-scope'
$cursorOrder = Get-ExactProperty $operation 'x-cursor-order'
$readWindow = Get-ExactProperty $operation 'x-read-window'
$cursorNoTotal = Get-ExactProperty $operation 'x-cursor-no-total'
$refreshDiscards = Get-ExactProperty $operation 'x-refresh-discards-cursor'
$authorizeEveryPage = Get-ExactProperty $operation 'x-authorize-every-page'
$invalidCursor = Get-ExactProperty $operation 'x-invalid-or-expired-cursor'
$crossScopeCursor = Get-ExactProperty $operation 'x-cross-scope-cursor'
Assert-ExactArray $(if ($cursorScope) { $cursorScope.Value } else { $null }) $spec.CursorScope "$($spec.Id) x-cursor-scope"
Assert-ExactArray $(if ($cursorOrder) { $cursorOrder.Value } else { $null }) $spec.CursorOrder "$($spec.Id) x-cursor-order"
if (-not $readWindow -or [string]$readWindow.Value -cne 'UPPER_BOUND_KEYSET_LATEST_VISIBLE' -or
-not $cursorNoTotal -or -not (Test-IsJsonBoolean $cursorNoTotal.Value $true) -or
-not $refreshDiscards -or -not (Test-IsJsonBoolean $refreshDiscards.Value $true) -or
-not $authorizeEveryPage -or -not (Test-IsJsonBoolean $authorizeEveryPage.Value $true) -or
-not $invalidCursor -or [string]$invalidCursor.Value -cne '400_FAMILY_FEED_CURSOR_INVALID' -or
-not $crossScopeCursor -or [string]$crossScopeCursor.Value -cne '404_FAMILY_FEED_NOT_AVAILABLE') {
Add-Issue "$($spec.Id) cursor/refresh/per-page authorization semantics drifted"
}
}
$readOnly = Get-ExactProperty $operation 'x-read-only'
$nonDisclosing = Get-ExactProperty $operation 'x-non-disclosing-not-found'
$authorizeEveryRequest = Get-ExactProperty $operation 'x-authorize-every-request'
$corsOwner = Get-ExactProperty $operation 'x-cors-policy-owner'
if (-not $readOnly -or -not (Test-IsJsonBoolean $readOnly.Value $true) -or
-not $nonDisclosing -or -not (Test-IsJsonBoolean $nonDisclosing.Value $true) -or
-not $authorizeEveryRequest -or -not (Test-IsJsonBoolean $authorizeEveryRequest.Value $true) -or
-not $corsOwner -or [string]$corsOwner.Value -cne 'APP_GATEWAY_PREFLIGHT') {
Add-Issue "$($spec.Id) read-only/non-disclosing/auth/CORS extensions drifted"
}
$expectedAuthorization = if ($spec.Id -ceq 'appListFamilyFeeds') {
@('tenant', 'genealogy', 'membership')
} else {
@('tenant', 'genealogy', 'membership', 'feedBelongsToGenealogy', 'feedVisibility')
}
$authorizationScope = Get-ExactProperty $operation 'x-authorization-scope'
Assert-ExactArray $(if ($authorizationScope) { $authorizationScope.Value } else { $null }) $expectedAuthorization "$($spec.Id) x-authorization-scope"
foreach ($status in @('200', '400', '401', '404', '429', '500')) {
$response = Get-Response $operation $status $spec.Id
if (-not $response) { continue }
$actualRef = Get-JsonResponseSchemaRef $response "$($spec.Id) $status"
$expectedRef = if ($status -ceq '200') { $spec.SuccessRef } else { $responseRefs[$status] }
if ($actualRef -cne $expectedRef) {
Add-Issue "$($spec.Id) $status schema ref must be $expectedRef; actual: $actualRef"
}
Assert-ResponseHeaders $response $status "$($spec.Id) $status"
}
}
$visitedCallbacks = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::Ordinal
)
if ($pathsProperty) {
foreach ($pathProperty in @($pathsProperty.Value.PSObject.Properties)) {
if (-not $pathProperty.Name.StartsWith('/genealogy/app/', [System.StringComparison]::Ordinal)) {
continue
}
Scan-AppPathItem $pathProperty.Value $pathProperty.Name $pathProperty.Name $true $visitedCallbacks
}
}
$allOperationIds = @{}
foreach ($pathProperty in @($document.paths.PSObject.Properties)) {
foreach ($method in @('get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace')) {
$operationProperty = Get-ExactProperty $pathProperty.Value $method
if (-not $operationProperty) { continue }
$operationIdProperty = Get-ExactProperty $operationProperty.Value 'operationId'
if (-not $operationIdProperty) { continue }
$id = [string]$operationIdProperty.Value
if (-not $allOperationIds.ContainsKey($id)) { $allOperationIds[$id] = @() }
$allOperationIds[$id] += "$($method.ToUpperInvariant()) $($pathProperty.Name)"
}
}
foreach ($spec in $operations) {
$owners = @($allOperationIds[$spec.Id])
if ($owners.Count -ne 1 -or $owners[0] -cne "$($spec.Method.ToUpperInvariant()) $($spec.Path)") {
Add-Issue "$($spec.Id) must have exactly one global operation owner; actual: $($owners -join ' | ')"
}
}
Assert-StringOwner 'GenealogyId' 1 128 $identifierPattern
$feedIdOwner = Assert-StringOwner 'FamilyFeedId' 1 128 $identifierPattern @('x-opaque', 'x-client-semantics')
if ($feedIdOwner -and (-not (Test-IsJsonBoolean $feedIdOwner.'x-opaque' $true) -or [string]$feedIdOwner.'x-client-semantics' -cne 'COMPARE_ONLY')) {
Add-Issue 'FamilyFeedId must be opaque and used by clients only for exact comparison'
}
$commentIdOwner = Assert-StringOwner 'FamilyFeedCommentId' 1 128 $identifierPattern @('x-opaque', 'x-client-semantics')
if ($commentIdOwner -and (-not (Test-IsJsonBoolean $commentIdOwner.'x-opaque' $true) -or [string]$commentIdOwner.'x-client-semantics' -cne 'COMPARE_ONLY')) {
Add-Issue 'FamilyFeedCommentId must be opaque and used by clients only for exact comparison'
}
$feedCursorOwner = Assert-StringOwner 'FamilyFeedCursor' 1 512 $cursorPattern @('x-opaque', 'x-purpose')
if ($feedCursorOwner -and (-not (Test-IsJsonBoolean $feedCursorOwner.'x-opaque' $true) -or [string]$feedCursorOwner.'x-purpose' -cne 'FAMILY_FEED_PAGE')) {
Add-Issue 'FamilyFeedCursor purpose/opacity drifted'
}
$commentCursorOwner = Assert-StringOwner 'FamilyFeedRootCommentCursor' 1 512 $cursorPattern @('x-opaque', 'x-purpose')
if ($commentCursorOwner -and (-not (Test-IsJsonBoolean $commentCursorOwner.'x-opaque' $true) -or [string]$commentCursorOwner.'x-purpose' -cne 'FAMILY_FEED_ROOT_COMMENT_PAGE')) {
Add-Issue 'FamilyFeedRootCommentCursor purpose/opacity drifted'
}
$feedContentOwner = Assert-StringOwner 'FamilyFeedContent' 1 300 '' @('x-text-normalizer', 'x-length-unit')
if ($feedContentOwner -and ([string]$feedContentOwner.'x-text-normalizer' -cne 'FAMILY_FEED_TEXT_V1' -or [string]$feedContentOwner.'x-length-unit' -cne 'UNICODE_CODE_POINT')) {
Add-Issue 'FamilyFeedContent normalization/length unit drifted'
}
$commentContentOwner = Assert-StringOwner 'FamilyFeedCommentContent' 1 1000 '' @('x-text-normalizer', 'x-length-unit')
if ($commentContentOwner -and ([string]$commentContentOwner.'x-text-normalizer' -cne 'FAMILY_FEED_COMMENT_TEXT_V1' -or [string]$commentContentOwner.'x-length-unit' -cne 'UNICODE_CODE_POINT')) {
Add-Issue 'FamilyFeedCommentContent normalization/length unit drifted'
}
$displayNameOwner = Assert-StringOwner 'FamilyFeedAuthorDisplayName' 1 100 '' @('x-projection', 'x-missing-author-policy')
if ($displayNameOwner -and ([string]$displayNameOwner.'x-projection' -cne 'AUTHORIZED_DISPLAY_NAME_ONLY' -or [string]$displayNameOwner.'x-missing-author-policy' -cne 'NON_EMPTY_SERVER_FALLBACK')) {
Add-Issue 'FamilyFeedAuthorDisplayName projection/fallback drifted'
}
Assert-DateTimeOwner 'FamilyFeedPublishedAt'
$feedItem = Get-Schema 'AppFamilyFeedReadItem'
Assert-ClosedObject $feedItem 'AppFamilyFeedReadItem' @(
'authorDisplayName',
'feedContent',
'feedId',
'hasMedia',
'publishedAt'
) @(
'authorDisplayName',
'feedContent',
'feedId',
'hasMedia',
'publishedAt'
) @('x-projection', 'x-media-policy')
Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'feedId' '#/components/schemas/FamilyFeedId'
Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'feedContent' '#/components/schemas/FamilyFeedContent'
Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'authorDisplayName' '#/components/schemas/FamilyFeedAuthorDisplayName'
Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'publishedAt' '#/components/schemas/FamilyFeedPublishedAt'
if ($feedItem) {
$hasMedia = $feedItem.properties.hasMedia
if ([string]$hasMedia.type -cne 'boolean' -or -not (Test-IsNonNullable $hasMedia)) {
Add-Issue 'AppFamilyFeedReadItem.hasMedia must be a required non-null boolean'
}
Assert-NoConflictingSchemaKeywords $hasMedia 'AppFamilyFeedReadItem.hasMedia'
Assert-AllowedSchemaKeywords $hasMedia 'AppFamilyFeedReadItem.hasMedia' @('type', 'nullable')
if ([string]$feedItem.'x-projection' -cne 'VISIBLE_FEED_PRESENTATION_ONLY' -or
[string]$feedItem.'x-media-policy' -cne 'HAS_MEDIA_REQUIRES_HONEST_CLIENT_PLACEHOLDER_UNTIL_MEDIA_READ_CONTRACT') {
Add-Issue 'AppFamilyFeedReadItem projection/media policy drifted'
}
}
$commentItem = Get-Schema 'AppFamilyFeedRootCommentReadItem'
Assert-ClosedObject $commentItem 'AppFamilyFeedRootCommentReadItem' @(
'authorDisplayName',
'commentContent',
'commentId',
'publishedAt'
) @(
'authorDisplayName',
'commentContent',
'commentId',
'publishedAt'
) @('x-projection', 'x-comment-level', 'x-deleted-placeholder-policy')
Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'commentId' '#/components/schemas/FamilyFeedCommentId'
Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'commentContent' '#/components/schemas/FamilyFeedCommentContent'
Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'authorDisplayName' '#/components/schemas/FamilyFeedAuthorDisplayName'
Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'publishedAt' '#/components/schemas/FamilyFeedPublishedAt'
if ($commentItem -and (
[string]$commentItem.'x-projection' -cne 'VISIBLE_COMMENT_PRESENTATION_ONLY' -or
[string]$commentItem.'x-comment-level' -cne 'ROOT_ONLY' -or
[string]$commentItem.'x-deleted-placeholder-policy' -cne 'EXCLUDE')) {
Add-Issue 'AppFamilyFeedRootCommentReadItem visibility/root/deleted-placeholder policy drifted'
}
Assert-CursorPage 'AppFamilyFeedCursorPage' '#/components/schemas/AppFamilyFeedReadItem' '#/components/schemas/FamilyFeedCursor' @('publishedAt:DESC', 'feedId:DESC_ORDINAL')
Assert-CursorPage 'AppFamilyFeedRootCommentCursorPage' '#/components/schemas/AppFamilyFeedRootCommentReadItem' '#/components/schemas/FamilyFeedRootCommentCursor' @('publishedAt:ASC', 'commentId:ASC_ORDINAL')
Assert-SuccessEnvelope 'RAppFamilyFeedCursorPage' '#/components/schemas/AppFamilyFeedCursorPage'
Assert-SuccessEnvelope 'RAppFamilyFeedReadItem' '#/components/schemas/AppFamilyFeedReadItem'
Assert-SuccessEnvelope 'RAppFamilyFeedRootCommentCursorPage' '#/components/schemas/AppFamilyFeedRootCommentCursorPage'
Assert-FixedError 'RFamilyFeedReadBadRequest' 400 @('FAMILY_FEED_CURSOR_INVALID', 'FAMILY_FEED_QUERY_INVALID')
Assert-FixedError 'RFamilyFeedReadUnauthorized' 401 @('AUTH_REQUIRED')
Assert-FixedError 'RFamilyFeedReadNotFound' 404 @('FAMILY_FEED_NOT_AVAILABLE')
Assert-FixedError 'RFamilyFeedReadRateLimited' 429 @('RATE_LIMITED')
Assert-FixedError 'RFamilyFeedReadUnavailable' 500 @('FAMILY_FEED_READ_UNAVAILABLE')
Assert-NoForbiddenSuccessFields @(
'#/components/schemas/RAppFamilyFeedCursorPage',
'#/components/schemas/RAppFamilyFeedReadItem',
'#/components/schemas/RAppFamilyFeedRootCommentCursorPage'
)
if ($ReturnIssues) {
return [string[]]@($issues)
}
if ($issues.Count -gt 0) {
Write-Output 'FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED'
Write-Output "Issues: $($issues.Count)"
$issues | ForEach-Object { Write-Output "- $_" }
Write-Output '- Keep F01/F03 on the explicit genealogy-scoped fixture preview; do not connect the dormant broad getFeeds branch.'
Write-Output '- After one backend JSON/YAML/live export passes, start with executable production read normalizer/coordinator tests and migrate F01/F03 atomically.'
exit 1
}
Write-Output 'FAMILY-FEED-READ-OPENAPI-CONTRACT PASS'