294 lines
13 KiB
PowerShell
294 lines
13 KiB
PowerShell
$ErrorActionPreference = 'Stop'
|
|
|
|
$root = Split-Path -Parent $PSScriptRoot
|
|
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
|
|
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
|
|
$issues = New-Object System.Collections.Generic.List[string]
|
|
$profilePath = '/genealogy/app/auth/profile'
|
|
$versionPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
|
|
$trimmedValuePattern = '^\S(?:.*\S)?$'
|
|
|
|
function Add-Issue {
|
|
param([string]$Message)
|
|
$script:issues.Add($Message)
|
|
}
|
|
|
|
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-ResponseSchemaRef {
|
|
param([object]$Operation, [string]$Status)
|
|
if (-not $Operation) { return '' }
|
|
$responseProperty = $Operation.responses.PSObject.Properties[$Status]
|
|
if (-not $responseProperty) {
|
|
Add-Issue "JSON PUT $profilePath missing $Status response"
|
|
return ''
|
|
}
|
|
$response = $responseProperty.Value
|
|
if ($response.'$ref') {
|
|
$responseName = ([string]$response.'$ref').Split('/')[-1]
|
|
$owner = $document.components.responses.PSObject.Properties[$responseName]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON missing response owner: $responseName"
|
|
return ''
|
|
}
|
|
$response = $owner.Value
|
|
}
|
|
$media = $response.content.PSObject.Properties['application/json']
|
|
if (-not $media) {
|
|
Add-Issue "JSON PUT $profilePath $Status must use application/json"
|
|
return ''
|
|
}
|
|
return [string]$media.Value.schema.'$ref'
|
|
}
|
|
|
|
function Assert-PrivateNoStore {
|
|
param([object]$Operation, [string]$Label)
|
|
if (-not $Operation) { return }
|
|
$responseProperty = $Operation.responses.PSObject.Properties['200']
|
|
if (-not $responseProperty) { return }
|
|
$response = $responseProperty.Value
|
|
if ($response.'$ref') {
|
|
$responseName = ([string]$response.'$ref').Split('/')[-1]
|
|
$owner = $document.components.responses.PSObject.Properties[$responseName]
|
|
if (-not $owner) { return }
|
|
$response = $owner.Value
|
|
}
|
|
$headerProperty = if ($response.headers) { $response.headers.PSObject.Properties['Cache-Control'] } else { $null }
|
|
if (-not $headerProperty) {
|
|
Add-Issue "JSON $Label 200 must document Cache-Control: private, no-store"
|
|
return
|
|
}
|
|
$header = $headerProperty.Value
|
|
if ($header.'$ref') {
|
|
$headerName = ([string]$header.'$ref').Split('/')[-1]
|
|
$owner = $document.components.headers.PSObject.Properties[$headerName]
|
|
if ($owner) { $header = $owner.Value }
|
|
}
|
|
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
|
|
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
|
|
Add-Issue "JSON $Label Cache-Control header must specify private, no-store"
|
|
}
|
|
}
|
|
|
|
function Assert-Required {
|
|
param([object]$Schema, [string]$SchemaName, [string[]]$Fields)
|
|
if (-not $Schema) { return }
|
|
foreach ($field in $Fields) {
|
|
if ($field -notin @($Schema.required)) {
|
|
Add-Issue "JSON $SchemaName.required missing: $field"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-ClearableString {
|
|
param(
|
|
[object]$Property,
|
|
[string]$Field,
|
|
[int]$Maximum,
|
|
[switch]$Email
|
|
)
|
|
if (-not $Property) {
|
|
Add-Issue "JSON AppProfileMergeUpdateBody missing property: $Field"
|
|
return
|
|
}
|
|
$branches = @($Property.oneOf)
|
|
if ($branches.Count -ne 2) {
|
|
Add-Issue "JSON AppProfileMergeUpdateBody.$Field must use exactly two oneOf branches"
|
|
return
|
|
}
|
|
$clearBranch = @($branches | Where-Object { @($_.enum).Count -eq 1 -and [string]$_.enum[0] -eq '' })
|
|
$valueBranch = @($branches | Where-Object { $_.type -eq 'string' -and [int]$_.minLength -eq 1 })
|
|
if ($clearBranch.Count -ne 1 -or $clearBranch[0].type -ne 'string') {
|
|
Add-Issue "JSON AppProfileMergeUpdateBody.$Field must use exact empty string as its only clear command"
|
|
}
|
|
if ($valueBranch.Count -ne 1 -or [int]$valueBranch[0].maxLength -ne $Maximum -or
|
|
[string]$valueBranch[0].pattern -ne $trimmedValuePattern) {
|
|
Add-Issue "JSON AppProfileMergeUpdateBody.$Field non-empty branch must be trimmed length 1..$Maximum"
|
|
}
|
|
if ($Email -and $valueBranch.Count -eq 1 -and $valueBranch[0].format -ne 'email') {
|
|
Add-Issue 'JSON AppProfileMergeUpdateBody.email non-empty branch must use email format'
|
|
}
|
|
}
|
|
|
|
$pathProperty = $document.paths.PSObject.Properties[$profilePath]
|
|
$pathItem = if ($pathProperty) { $pathProperty.Value } else { $null }
|
|
$operation = if ($pathItem) { $pathItem.put } else { $null }
|
|
if (-not $operation) { Add-Issue "JSON missing PUT $profilePath" }
|
|
if ($pathItem -and $pathItem.patch) {
|
|
Add-Issue "JSON $profilePath must not publish a second PATCH update owner"
|
|
}
|
|
|
|
if ($operation) {
|
|
$hasSaToken = $false
|
|
foreach ($requirement in @($operation.security)) {
|
|
if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
|
|
}
|
|
if (-not $hasSaToken) { Add-Issue "JSON PUT $profilePath must require SaToken" }
|
|
|
|
$clientHeaders = @($operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
|
|
if ($clientHeaders.Count -ne 1 -or $clientHeaders[0].required -ne $true -or $clientHeaders[0].schema.type -ne 'string') {
|
|
Add-Issue "JSON PUT $profilePath must require one string clientid header"
|
|
}
|
|
|
|
$versionHeaders = @($operation.parameters | Where-Object { $_.name -eq 'If-Match' -and $_.in -eq 'header' })
|
|
if ($versionHeaders.Count -ne 1 -or $versionHeaders[0].required -ne $true -or
|
|
$versionHeaders[0].schema.type -ne 'string' -or $versionHeaders[0].schema.minLength -ne 1 -or
|
|
$versionHeaders[0].schema.maxLength -ne 128 -or $versionHeaders[0].schema.pattern -ne $versionPattern) {
|
|
Add-Issue "JSON PUT $profilePath must require If-Match with the opaque profileVersion shape"
|
|
}
|
|
|
|
$requestMedia = $operation.requestBody.content.PSObject.Properties['application/json']
|
|
if ($operation.requestBody.required -ne $true -or -not $requestMedia) {
|
|
Add-Issue "JSON PUT $profilePath must require an application/json body"
|
|
} elseif ($requestMedia.Value.schema.'$ref' -ne '#/components/schemas/AppProfileMergeUpdateBody') {
|
|
Add-Issue "JSON PUT $profilePath must use AppProfileMergeUpdateBody"
|
|
}
|
|
|
|
$semantics = [string]$operation.description
|
|
foreach ($semanticPattern in @(
|
|
'(?i)merge update',
|
|
'(?i)present editable propert(?:y|ies).*update',
|
|
'(?i)omitted editable propert(?:y|ies).*unchanged',
|
|
'(?i)exact empty string.*clear.*realName.*email',
|
|
'(?i)repeat(?:ed|ing).*no additional side effects'
|
|
)) {
|
|
if ($semantics -notmatch $semanticPattern) {
|
|
Add-Issue "JSON PUT $profilePath description is missing merge/idempotency semantics: $semanticPattern"
|
|
}
|
|
}
|
|
|
|
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
|
|
if (-not $operation.responses.PSObject.Properties[$status]) {
|
|
Add-Issue "JSON PUT $profilePath missing documented response: $status"
|
|
}
|
|
}
|
|
}
|
|
|
|
$body = Get-Schema 'AppProfileMergeUpdateBody'
|
|
if ($body) {
|
|
$propertyNames = @($body.properties.PSObject.Properties.Name | Sort-Object)
|
|
if (($propertyNames -join ',') -ne 'email,nickName,realName') {
|
|
Add-Issue "JSON AppProfileMergeUpdateBody must own only email,nickName,realName; actual: $($propertyNames -join ',')"
|
|
}
|
|
if ($body.type -ne 'object' -or $body.additionalProperties -ne $false -or
|
|
[int]$body.minProperties -ne 1 -or [int]$body.maxProperties -ne 3) {
|
|
Add-Issue 'JSON AppProfileMergeUpdateBody must be a closed object with 1..3 dirty properties'
|
|
}
|
|
if (@($body.required).Count -ne 0) {
|
|
Add-Issue 'JSON AppProfileMergeUpdateBody editable properties must be optional for dirty-only merge'
|
|
}
|
|
$nickName = $body.properties.nickName
|
|
if ($nickName.type -ne 'string' -or [int]$nickName.minLength -ne 1 -or
|
|
[int]$nickName.maxLength -ne 30 -or [string]$nickName.pattern -ne $trimmedValuePattern) {
|
|
Add-Issue 'JSON AppProfileMergeUpdateBody.nickName must be a trimmed non-empty string of length 1..30'
|
|
}
|
|
Assert-ClearableString $body.properties.realName 'realName' 30
|
|
Assert-ClearableString $body.properties.email 'email' 100 -Email
|
|
}
|
|
|
|
$successRef = Get-ResponseSchemaRef $operation '200'
|
|
if ($successRef -ne '#/components/schemas/RAppProfileVo') {
|
|
Add-Issue "JSON PUT $profilePath 200 must return RAppProfileVo; actual: $successRef"
|
|
}
|
|
$conflictRef = Get-ResponseSchemaRef $operation '409'
|
|
if ($conflictRef -ne '#/components/schemas/RProfileVersionChanged') {
|
|
Add-Issue "JSON PUT $profilePath 409 must return RProfileVersionChanged; actual: $conflictRef"
|
|
}
|
|
$validationRef = Get-ResponseSchemaRef $operation '422'
|
|
if ($validationRef -ne '#/components/schemas/RProfileValidationError') {
|
|
Add-Issue "JSON PUT $profilePath 422 must return RProfileValidationError; actual: $validationRef"
|
|
}
|
|
|
|
Assert-PrivateNoStore $operation "PUT $profilePath"
|
|
Assert-PrivateNoStore $(if ($pathItem) { $pathItem.get } else { $null }) "GET $profilePath"
|
|
|
|
$profileEnvelope = Get-Schema 'RAppProfileVo'
|
|
$profile = Get-Schema 'AppProfileVo'
|
|
$conflict = Get-Schema 'RProfileVersionChanged'
|
|
$validation = Get-Schema 'RProfileValidationError'
|
|
Assert-Required $profileEnvelope 'RAppProfileVo' @('code', 'data')
|
|
Assert-Required $profile 'AppProfileVo' @('phone', 'profileVersion')
|
|
Assert-Required $conflict 'RProfileVersionChanged' @('code', 'businessCode')
|
|
Assert-Required $validation 'RProfileValidationError' @('code', 'businessCode', 'fieldErrors')
|
|
|
|
if ($profileEnvelope) {
|
|
if ($profileEnvelope.properties.code.type -ne 'integer' -or
|
|
$profileEnvelope.properties.data.'$ref' -ne '#/components/schemas/AppProfileVo') {
|
|
Add-Issue 'JSON RAppProfileVo must contain integer code and AppProfileVo data'
|
|
}
|
|
}
|
|
if ($profile) {
|
|
$version = $profile.properties.profileVersion
|
|
if ($version.type -ne 'string' -or [int]$version.minLength -ne 1 -or
|
|
[int]$version.maxLength -ne 128 -or $version.pattern -ne $versionPattern) {
|
|
Add-Issue 'JSON AppProfileVo.profileVersion must be a 1..128 URL-safe opaque string'
|
|
}
|
|
}
|
|
if ($conflict) {
|
|
if ($conflict.properties.code.type -ne 'integer' -or $conflict.properties.businessCode.type -ne 'string' -or
|
|
(@($conflict.properties.businessCode.enum) -join ',') -ne 'PROFILE_VERSION_CHANGED') {
|
|
Add-Issue 'JSON RProfileVersionChanged must expose the single PROFILE_VERSION_CHANGED business code'
|
|
}
|
|
}
|
|
if ($validation) {
|
|
$fieldErrors = $validation.properties.fieldErrors
|
|
$fieldNames = @($fieldErrors.properties.PSObject.Properties.Name | Sort-Object)
|
|
if ($validation.properties.code.type -ne 'integer' -or
|
|
$validation.properties.businessCode.type -ne 'string' -or
|
|
(@($validation.properties.businessCode.enum) -join ',') -ne 'PROFILE_VALIDATION_FAILED' -or
|
|
$fieldErrors.type -ne 'object' -or $fieldErrors.additionalProperties -ne $false -or
|
|
($fieldNames -join ',') -ne 'email,nickName,realName') {
|
|
Add-Issue 'JSON RProfileValidationError must expose only nickName/realName/email field errors with PROFILE_VALIDATION_FAILED'
|
|
} else {
|
|
foreach ($field in $fieldNames) {
|
|
if ($fieldErrors.properties.$field.type -ne 'string' -or
|
|
[int]$fieldErrors.properties.$field.minLength -lt 1 -or
|
|
[int]$fieldErrors.properties.$field.maxLength -gt 200) {
|
|
Add-Issue "JSON RProfileValidationError.fieldErrors.$field must be a bounded non-empty string"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($yamlFact in @(
|
|
' /genealogy/app/auth/profile:',
|
|
' operationId:',
|
|
' name: If-Match',
|
|
'#/components/schemas/AppProfileMergeUpdateBody',
|
|
'#/components/schemas/RAppProfileVo',
|
|
'#/components/schemas/RProfileVersionChanged',
|
|
'#/components/schemas/RProfileValidationError',
|
|
' AppProfileMergeUpdateBody:',
|
|
' AppProfileVo:',
|
|
' profileVersion:',
|
|
' RProfileVersionChanged:',
|
|
' - PROFILE_VERSION_CHANGED',
|
|
' RProfileValidationError:',
|
|
' - PROFILE_VALIDATION_FAILED',
|
|
' fieldErrors:',
|
|
' Cache-Control:'
|
|
)) {
|
|
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
|
|
}
|
|
|
|
if ($issues.Count -gt 0) {
|
|
$lines = New-Object System.Collections.Generic.List[string]
|
|
$lines.Add('PROFILE-UPDATE-OPENAPI-CONTRACT BLOCKED')
|
|
foreach ($issue in $issues) { $lines.Add("- $issue") }
|
|
$lines.Add('- Keep PUT as the only App owner, but define it as an atomic dirty-only merge: omitted fields stay unchanged; exact empty string clears only realName/email.')
|
|
$lines.Add('- profileVersion is returned in the canonical profile; If-Match carries it on write, CORS must allow If-Match, and stale versions return 409 PROFILE_VERSION_CHANGED.')
|
|
$lines.Add('- The response omits cleared optional fields. Timeout is outcome-unknown and must be reconciled with GET before retrying.')
|
|
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
|
|
throw ($lines -join [Environment]::NewLine)
|
|
}
|
|
|
|
Write-Output 'PROFILE-UPDATE-OPENAPI-CONTRACT PASS'
|