完成50%

This commit is contained in:
2026-07-23 08:23:59 +08:00
parent 9b0ad62df4
commit f1edc6b533
218 changed files with 24318 additions and 5514 deletions
+177
View File
@@ -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'