351 lines
14 KiB
PowerShell
351 lines
14 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]
|
|
$phonePath = '/genealogy/app/auth/phone'
|
|
$sendPath = '/genealogy/app/auth/phone/sms/code'
|
|
$publicSendPath = '/genealogy/app/auth/sms/code'
|
|
|
|
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-Operation {
|
|
param([string]$Path, [string]$Method)
|
|
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
|
$operation = if ($pathProperty) { $pathProperty.Value.PSObject.Properties[$Method] } else { $null }
|
|
if (-not $operation) {
|
|
Add-Issue "JSON missing $($Method.ToUpperInvariant()) $Path"
|
|
return $null
|
|
}
|
|
return $operation.Value
|
|
}
|
|
|
|
function Assert-SaTokenOnly {
|
|
param([object]$Operation, [string]$Label)
|
|
if (-not $Operation) { return }
|
|
$requirements = @($Operation.security)
|
|
$valid = $requirements.Count -eq 1 -and
|
|
$requirements[0].PSObject.Properties.Count -eq 1 -and
|
|
$requirements[0].PSObject.Properties.Name -contains 'SaToken'
|
|
if (-not $valid) { Add-Issue "JSON $Label must require SaToken without an anonymous alternative" }
|
|
}
|
|
|
|
function Assert-ClientIdHeader {
|
|
param([object]$Operation, [string]$Label)
|
|
if (-not $Operation) { return }
|
|
$headers = @($Operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
|
|
if ($headers.Count -ne 1 -or $headers[0].required -ne $true -or
|
|
$headers[0].schema.type -ne 'string' -or [int]$headers[0].schema.minLength -lt 1) {
|
|
Add-Issue "JSON $Label must require one non-empty string clientid header"
|
|
}
|
|
}
|
|
|
|
function Assert-RequestBody {
|
|
param([object]$Operation, [string]$Label, [string]$ExpectedRef)
|
|
if (-not $Operation) { return }
|
|
$media = $Operation.requestBody.content.PSObject.Properties['application/json']
|
|
if ($Operation.requestBody.required -ne $true -or -not $media) {
|
|
Add-Issue "JSON $Label must require an application/json body"
|
|
} elseif ([string]$media.Value.schema.'$ref' -ne $ExpectedRef) {
|
|
Add-Issue "JSON $Label request body must use $ExpectedRef"
|
|
}
|
|
}
|
|
|
|
function Get-Response {
|
|
param([object]$Operation, [string]$Label, [string]$Status)
|
|
if (-not $Operation) { return $null }
|
|
$property = $Operation.responses.PSObject.Properties[$Status]
|
|
if (-not $property) {
|
|
Add-Issue "JSON $Label missing $Status response"
|
|
return $null
|
|
}
|
|
$response = $property.Value
|
|
if ($response.'$ref') {
|
|
$name = ([string]$response.'$ref').Split('/')[-1]
|
|
$owner = $document.components.responses.PSObject.Properties[$name]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON missing response owner: $name"
|
|
return $null
|
|
}
|
|
$response = $owner.Value
|
|
}
|
|
return $response
|
|
}
|
|
|
|
function Get-JsonSchemaRef {
|
|
param([object]$Response, [string]$Label, [string]$Status)
|
|
if (-not $Response) { return '' }
|
|
$media = $Response.content.PSObject.Properties['application/json']
|
|
if (-not $media) {
|
|
Add-Issue "JSON $Label $Status must use application/json"
|
|
return ''
|
|
}
|
|
return [string]$media.Value.schema.'$ref'
|
|
}
|
|
|
|
function Assert-PrivateNoStore {
|
|
param([object]$Response, [string]$Label, [string]$Status)
|
|
if (-not $Response) { return }
|
|
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
|
|
if (-not $property) {
|
|
Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store"
|
|
return
|
|
}
|
|
$header = $property.Value
|
|
if ($header.'$ref') {
|
|
$name = ([string]$header.'$ref').Split('/')[-1]
|
|
$owner = $document.components.headers.PSObject.Properties[$name]
|
|
if ($owner) { $header = $owner.Value }
|
|
}
|
|
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
|
|
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
|
|
Add-Issue "JSON $Label $Status Cache-Control must specify private, no-store"
|
|
}
|
|
}
|
|
|
|
function Assert-FieldRef {
|
|
param([string]$SchemaName, [string]$Field, [string]$ExpectedRef)
|
|
$schema = Get-Schema $SchemaName
|
|
if (-not $schema) { return }
|
|
$property = $schema.properties.PSObject.Properties[$Field]
|
|
$actual = if ($property) { [string]$property.Value.'$ref' } else { '' }
|
|
if ($actual -ne $ExpectedRef) {
|
|
Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual"
|
|
}
|
|
}
|
|
|
|
$phoneOperation = Get-Operation $phonePath 'put'
|
|
$sendOperation = Get-Operation $sendPath 'post'
|
|
$publicSendOperation = Get-Operation $publicSendPath 'post'
|
|
|
|
Assert-SaTokenOnly $phoneOperation "PUT $phonePath"
|
|
Assert-SaTokenOnly $sendOperation "POST $sendPath"
|
|
Assert-ClientIdHeader $phoneOperation "PUT $phonePath"
|
|
Assert-ClientIdHeader $sendOperation "POST $sendPath"
|
|
Assert-RequestBody $phoneOperation "PUT $phonePath" '#/components/schemas/PhoneChangeBody'
|
|
Assert-RequestBody $sendOperation "POST $sendPath" '#/components/schemas/PhoneChangeSmsCodeBody'
|
|
|
|
if ($publicSendOperation) {
|
|
$publicBodyRef = [string]$publicSendOperation.requestBody.content.'application/json'.schema.'$ref'
|
|
if ($publicBodyRef -ne '#/components/schemas/SmsCodeBody') {
|
|
Add-Issue "JSON POST $publicSendPath must keep SmsCodeBody as the public-scene owner"
|
|
}
|
|
}
|
|
|
|
$phoneBody = Get-Schema 'PhoneChangeBody'
|
|
$sendBody = Get-Schema 'PhoneChangeSmsCodeBody'
|
|
$newPhone = Get-Schema 'NewBoundPhone'
|
|
$smsSecret = Get-Schema 'SmsCodeSecret'
|
|
|
|
if ($phoneBody) {
|
|
$properties = @($phoneBody.properties.PSObject.Properties.Name | Sort-Object)
|
|
$required = @($phoneBody.required | Sort-Object)
|
|
if ($phoneBody.type -ne 'object' -or $phoneBody.additionalProperties -ne $false -or
|
|
($properties -join ',') -ne 'currentPassword,phone,smsCode' -or
|
|
($required -join ',') -ne 'currentPassword,phone,smsCode') {
|
|
Add-Issue 'JSON PhoneChangeBody must be a closed object requiring only currentPassword/phone/smsCode'
|
|
}
|
|
}
|
|
|
|
if ($sendBody) {
|
|
$properties = @($sendBody.properties.PSObject.Properties.Name | Sort-Object)
|
|
$required = @($sendBody.required | Sort-Object)
|
|
if ($sendBody.type -ne 'object' -or $sendBody.additionalProperties -ne $false -or
|
|
($properties -join ',') -ne 'phone,validToken' -or
|
|
($required -join ',') -ne 'phone,validToken') {
|
|
Add-Issue 'JSON PhoneChangeSmsCodeBody must be a closed object requiring only phone/validToken; scene, client and tenant come from the protected operation context'
|
|
}
|
|
if (-not $sendBody.properties.validToken -or $sendBody.properties.validToken.type -ne 'string' -or
|
|
$sendBody.properties.validToken.writeOnly -ne $true -or [int]$sendBody.properties.validToken.minLength -lt 1) {
|
|
Add-Issue 'JSON PhoneChangeSmsCodeBody.validToken must be a non-empty writeOnly string'
|
|
}
|
|
}
|
|
|
|
if ($newPhone) {
|
|
if ($newPhone.type -ne 'string' -or $newPhone.writeOnly -ne $true -or
|
|
[int]$newPhone.minLength -ne 11 -or [int]$newPhone.maxLength -ne 11 -or
|
|
[string]$newPhone.pattern -ne '^1[3-9][0-9]{9}$' -or $newPhone.example) {
|
|
Add-Issue 'JSON NewBoundPhone must be a writeOnly canonical 11-digit mainland mobile number without an example'
|
|
}
|
|
}
|
|
|
|
if ($smsSecret) {
|
|
if ($smsSecret.type -ne 'string' -or $smsSecret.writeOnly -ne $true -or
|
|
[int]$smsSecret.minLength -ne 6 -or [int]$smsSecret.maxLength -ne 6 -or
|
|
[string]$smsSecret.pattern -ne '^[0-9]{6}$' -or $smsSecret.example) {
|
|
Add-Issue 'JSON SmsCodeSecret must be exactly six ASCII decimal digits, preserve leading zero, be writeOnly, and expose no example'
|
|
}
|
|
$smsDescription = [string]$smsSecret.description
|
|
foreach ($pattern in @('(?i)CSPRNG', '(?i)leading zero', '(?i)single-use', '(?i)5 minutes', '(?i)5 failed')) {
|
|
if ($smsDescription -notmatch $pattern) {
|
|
Add-Issue "JSON SmsCodeSecret description is missing OTP security semantics: $pattern"
|
|
}
|
|
}
|
|
}
|
|
|
|
Assert-FieldRef 'PhoneChangeBody' 'currentPassword' '#/components/schemas/CurrentPasswordSecret'
|
|
Assert-FieldRef 'PhoneChangeBody' 'phone' '#/components/schemas/NewBoundPhone'
|
|
Assert-FieldRef 'PhoneChangeSmsCodeBody' 'phone' '#/components/schemas/NewBoundPhone'
|
|
|
|
# 短信码是跨登录、注册、找回、换绑和注销的单一 wire 合同。升级为六位时必须一次.
|
|
# 删除所有四位入口,不能让同一服务端生成器长期同时接受 4/6 位密码等价物..
|
|
$otpSchemaNames = @(
|
|
'SmsLoginBody'
|
|
'PasswordRegisterBody'
|
|
'PasswordResetBody'
|
|
'PhoneChangeBody'
|
|
'AccountDeactivateBody'
|
|
)
|
|
foreach ($schemaName in $otpSchemaNames) {
|
|
Assert-FieldRef $schemaName 'smsCode' '#/components/schemas/SmsCodeSecret'
|
|
}
|
|
|
|
$publicSmsBody = Get-Schema 'SmsCodeBody'
|
|
if ($publicSmsBody) {
|
|
$scenes = @($publicSmsBody.properties.sceneCode.enum | Sort-Object)
|
|
$expectedScenes = @('APP_ACCOUNT_DEACTIVATE', 'APP_FORGOT_PASSWORD', 'APP_REGISTER', 'APP_SMS_LOGIN') | Sort-Object
|
|
if (($scenes -join ',') -ne ($expectedScenes -join ',')) {
|
|
Add-Issue 'JSON public SmsCodeBody.sceneCode must exclude APP_PHONE_CHANGE and retain only the four public/shared scenes'
|
|
}
|
|
}
|
|
|
|
if ($sendOperation) {
|
|
$semantics = [string]$sendOperation.description
|
|
foreach ($pattern in @(
|
|
'(?i)scene.*APP_PHONE_CHANGE.*server'
|
|
'(?i)validToken.*tenant.*client.*phone'
|
|
'(?i)account.*session.*credentialEpoch'
|
|
'(?i)one active.*generation'
|
|
'(?i)resend.*invalidates.*previous'
|
|
'(?i)60 seconds'
|
|
'(?i)failed attempt.*not reset'
|
|
'(?i)rate limit'
|
|
)) {
|
|
if ($semantics -notmatch $pattern) {
|
|
Add-Issue "JSON POST $sendPath description is missing protected send semantics: $pattern"
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($phoneOperation) {
|
|
$semantics = [string]$phoneOperation.description
|
|
foreach ($pattern in @(
|
|
'(?i)current password.*re-authentication'
|
|
'(?i)single transaction.*phone.*sms.*credentialEpoch'
|
|
'(?i)unique.*phone'
|
|
'(?i)all.*access.*refresh.*including.*current'
|
|
'(?i)old phone.*outbox'
|
|
'(?i)200.*sessions.*invalidated'
|
|
'(?i)no password.*STEP_UP_UNAVAILABLE'
|
|
)) {
|
|
if ($semantics -notmatch $pattern) {
|
|
Add-Issue "JSON PUT $phonePath description is missing identity/session semantics: $pattern"
|
|
}
|
|
}
|
|
}
|
|
|
|
$operations = @(
|
|
[pscustomobject]@{ Operation = $sendOperation; Label = "POST $sendPath" },
|
|
[pscustomobject]@{ Operation = $phoneOperation; Label = "PUT $phonePath" }
|
|
)
|
|
foreach ($entry in $operations) {
|
|
if (-not $entry.Operation) { continue }
|
|
$responses = @{}
|
|
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
|
|
$responses[$status] = Get-Response $entry.Operation $entry.Label $status
|
|
[void](Get-JsonSchemaRef $responses[$status] $entry.Label $status)
|
|
Assert-PrivateNoStore $responses[$status] $entry.Label $status
|
|
}
|
|
if ((Get-JsonSchemaRef $responses['200'] $entry.Label '200') -ne '#/components/schemas/RVoid') {
|
|
Add-Issue "JSON $($entry.Label) 200 must return RVoid"
|
|
}
|
|
foreach ($status in @('409', '422', '429')) {
|
|
if ((Get-JsonSchemaRef $responses[$status] $entry.Label $status) -ne '#/components/schemas/RPhoneChangeRejected') {
|
|
Add-Issue "JSON $($entry.Label) $status must return RPhoneChangeRejected"
|
|
}
|
|
}
|
|
$retryAfter = if ($responses['429'] -and $responses['429'].headers) {
|
|
$responses['429'].headers.PSObject.Properties['Retry-After']
|
|
} else { $null }
|
|
if (-not $retryAfter) { Add-Issue "JSON $($entry.Label) 429 must document Retry-After" }
|
|
}
|
|
|
|
$void = Get-Schema 'RVoid'
|
|
$rejected = Get-Schema 'RPhoneChangeRejected'
|
|
if ($void -and ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer')) {
|
|
Add-Issue 'JSON RVoid must require integer code'
|
|
}
|
|
if ($rejected) {
|
|
foreach ($field in @('code', 'businessCode')) {
|
|
if ($field -notin @($rejected.required)) { Add-Issue "JSON RPhoneChangeRejected.required missing: $field" }
|
|
}
|
|
$codes = @($rejected.properties.businessCode.enum | Sort-Object)
|
|
$expectedCodes = @(
|
|
'CREDENTIAL_VERSION_CONFLICT'
|
|
'CURRENT_PASSWORD_INCORRECT'
|
|
'NEW_PHONE_SAME_AS_CURRENT'
|
|
'PHONE_ALREADY_BOUND'
|
|
'SEND_RATE_LIMITED'
|
|
'SMS_CODE_ATTEMPTS_EXCEEDED'
|
|
'SMS_CODE_EXPIRED'
|
|
'SMS_CODE_INVALID'
|
|
'STEP_UP_UNAVAILABLE'
|
|
'VERIFICATION_REQUIRED'
|
|
) | Sort-Object
|
|
if ($rejected.properties.code.type -ne 'integer' -or
|
|
$rejected.properties.businessCode.type -ne 'string' -or
|
|
($codes -join ',') -ne ($expectedCodes -join ',')) {
|
|
Add-Issue 'JSON RPhoneChangeRejected must expose the ten stable send/change business codes'
|
|
}
|
|
}
|
|
|
|
foreach ($yamlFact in @(
|
|
' /genealogy/app/auth/phone/sms/code:'
|
|
' /genealogy/app/auth/phone:'
|
|
'#/components/schemas/PhoneChangeSmsCodeBody'
|
|
'#/components/schemas/PhoneChangeBody'
|
|
'#/components/schemas/CurrentPasswordSecret'
|
|
'#/components/schemas/NewBoundPhone'
|
|
'#/components/schemas/SmsCodeSecret'
|
|
'#/components/schemas/RPhoneChangeRejected'
|
|
' PhoneChangeSmsCodeBody:'
|
|
' NewBoundPhone:'
|
|
' SmsCodeSecret:'
|
|
' pattern: ^[0-9]{6}$'
|
|
' RPhoneChangeRejected:'
|
|
' - STEP_UP_UNAVAILABLE'
|
|
' - VERIFICATION_REQUIRED'
|
|
' Cache-Control:'
|
|
' Retry-After:'
|
|
)) {
|
|
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('PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED')
|
|
foreach ($issue in $issues) { $lines.Add("- $issue") }
|
|
$lines.Add('- Split APP_PHONE_CHANGE sending into the authenticated POST /genealogy/app/auth/phone/sms/code operation; keep one shared backend OTP service, and remove this scene from the public sender.')
|
|
$lines.Add('- Upgrade every active SMS-code consumer atomically from four digits to one six-ASCII-digit SmsCodeSecret; no 4/6 compatibility window is allowed.')
|
|
$lines.Add('- PUT requires the current raw password plus the new-phone OTP; a strict 200 means the phone, OTP consumption, credential epoch, all-session revocation, and old-phone notification outbox are durable.')
|
|
$lines.Add('- Timeout, malformed response, 5xx, or process death after final PUT dispatch is outcome-unknown: clear the same-epoch local session, return to A01, and never retry automatically.')
|
|
$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 'PHONE-CHANGE-OPENAPI-CONTRACT PASS'
|