完成50%
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
$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]
|
||||
$logoutPath = '/genealogy/app/auth/logout'
|
||||
|
||||
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-Response {
|
||||
param([object]$Operation, [string]$Status)
|
||||
if (-not $Operation) { return $null }
|
||||
$property = $Operation.responses.PSObject.Properties[$Status]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON DELETE $logoutPath 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-ResponseSchemaRef {
|
||||
param([object]$Operation, [string]$Status)
|
||||
$response = Get-Response $Operation $Status
|
||||
if (-not $response) { return '' }
|
||||
$media = $response.content.PSObject.Properties['application/json']
|
||||
if (-not $media) {
|
||||
Add-Issue "JSON DELETE $logoutPath $Status must use application/json"
|
||||
return ''
|
||||
}
|
||||
return [string]$media.Value.schema.'$ref'
|
||||
}
|
||||
|
||||
function Assert-PrivateNoStore {
|
||||
param([object]$Response, [string]$Status)
|
||||
if (-not $Response) { return }
|
||||
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON DELETE $logoutPath $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 DELETE $logoutPath $Status Cache-Control must specify private, no-store"
|
||||
}
|
||||
}
|
||||
|
||||
$pathProperty = $document.paths.PSObject.Properties[$logoutPath]
|
||||
$operation = if ($pathProperty) { $pathProperty.Value.delete } else { $null }
|
||||
if (-not $operation) { Add-Issue "JSON missing DELETE $logoutPath" }
|
||||
|
||||
if ($operation) {
|
||||
$hasSaToken = $false
|
||||
foreach ($requirement in @($operation.security)) {
|
||||
if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
|
||||
}
|
||||
if (-not $hasSaToken) { Add-Issue "JSON DELETE $logoutPath 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' -or [int]$clientHeaders[0].schema.minLength -lt 1) {
|
||||
Add-Issue "JSON DELETE $logoutPath must require one non-empty string clientid header"
|
||||
}
|
||||
if ($operation.requestBody) { Add-Issue "JSON DELETE $logoutPath must not accept a request body" }
|
||||
|
||||
$semantics = [string]$operation.description
|
||||
foreach ($semanticPattern in @(
|
||||
'(?i)presented (access token|credential family)',
|
||||
'(?i)other device sessions remain valid',
|
||||
'(?i)repeated.*(idempotent|no additional side effects)',
|
||||
'(?i)active.*revoked.*expired.*same 200',
|
||||
'(?i)successful revocation.*token.*rejected'
|
||||
)) {
|
||||
if ($semantics -notmatch $semanticPattern) {
|
||||
Add-Issue "JSON DELETE $logoutPath description is missing scope/idempotency semantics: $semanticPattern"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($status in @('200', '400', '401', '429', '500')) {
|
||||
if (-not $operation.responses.PSObject.Properties[$status]) {
|
||||
Add-Issue "JSON DELETE $logoutPath missing documented response: $status"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$successResponse = Get-Response $operation '200'
|
||||
$terminalResponse = Get-Response $operation '401'
|
||||
$successRef = Get-ResponseSchemaRef $operation '200'
|
||||
$terminalRef = Get-ResponseSchemaRef $operation '401'
|
||||
if ($successRef -ne '#/components/schemas/RVoid') {
|
||||
Add-Issue "JSON DELETE $logoutPath 200 must return RVoid; actual: $successRef"
|
||||
}
|
||||
if ($terminalRef -ne '#/components/schemas/RLogoutRejected') {
|
||||
Add-Issue "JSON DELETE $logoutPath 401 must return RLogoutRejected; actual: $terminalRef"
|
||||
}
|
||||
Assert-PrivateNoStore $successResponse '200'
|
||||
Assert-PrivateNoStore $terminalResponse '401'
|
||||
|
||||
$void = Get-Schema 'RVoid'
|
||||
$terminal = Get-Schema 'RLogoutRejected'
|
||||
if ($void) {
|
||||
if ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer') {
|
||||
Add-Issue 'JSON RVoid must require integer code'
|
||||
}
|
||||
}
|
||||
if ($terminal) {
|
||||
foreach ($field in @('code', 'businessCode')) {
|
||||
if ($field -notin @($terminal.required)) {
|
||||
Add-Issue "JSON RLogoutRejected.required missing: $field"
|
||||
}
|
||||
}
|
||||
$codes = @($terminal.properties.businessCode.enum | Sort-Object)
|
||||
if ($terminal.properties.code.type -ne 'integer' -or
|
||||
$terminal.properties.businessCode.type -ne 'string' -or
|
||||
($codes -join ',') -ne 'TOKEN_CLIENT_MISMATCH,TOKEN_INVALID') {
|
||||
Add-Issue 'JSON RLogoutRejected must expose only TOKEN_CLIENT_MISMATCH/TOKEN_INVALID rejection codes'
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($yamlFact in @(
|
||||
' /genealogy/app/auth/logout:',
|
||||
' delete:',
|
||||
' name: clientid',
|
||||
'#/components/schemas/RVoid',
|
||||
'#/components/schemas/RLogoutRejected',
|
||||
' RVoid:',
|
||||
' RLogoutRejected:',
|
||||
' - TOKEN_CLIENT_MISMATCH',
|
||||
' - TOKEN_INVALID',
|
||||
' 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('LOGOUT-OPENAPI-CONTRACT BLOCKED')
|
||||
foreach ($issue in $issues) { $lines.Add("- $issue") }
|
||||
$lines.Add('- Logout revokes only the presented current-device credential family; other device sessions remain valid.')
|
||||
$lines.Add('- Runtime proof must show that a token cannot access a protected endpoint after 200, while a second-device token still can.')
|
||||
$lines.Add('- The client starts DELETE with an in-memory token snapshot, immediately clears local session once, never restores it, and never persists a retry token.')
|
||||
$lines.Add('- Active, already-revoked, and expired credentials issued for this client all converge to the same 200 RVoid; 401 is only TOKEN_INVALID/TOKEN_CLIENT_MISMATCH and is not success.')
|
||||
$lines.Add('- Generic 401, network, timeout, malformed responses, and 5xx mean remote revocation is unconfirmed; only a valid 200 response confirms server-side termination.')
|
||||
$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 'LOGOUT-OPENAPI-CONTRACT PASS'
|
||||
Reference in New Issue
Block a user