完成70%

This commit is contained in:
2026-07-24 07:56:22 +08:00
parent bb6431b319
commit c7f278fe79
92 changed files with 4741 additions and 14440 deletions
+23 -245
View File
@@ -1,259 +1,37 @@
$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')
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/api.js')
$page = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/profile/m04-change-password.vue')
$issues = New-Object System.Collections.Generic.List[string]
$passwordPath = '/genealogy/app/auth/password'
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
function Require-Text {
param([string]$Source, [string]$Text, [string]$Description)
if (-not $Source.Contains($Text)) { $script:issues.Add($Description) }
}
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
Require-Text $api 'async changePassword({ oldPasswordHash, newPasswordHash }, requestOptions = {}) {' 'api owner must expose password change'
Require-Text $api "url: '/genealogy/app/auth/password'" 'password change must use the declared APP password owner'
Require-Text $api "method: 'PUT'" 'password change must use PUT'
Require-Text $api 'oldPassword: assertPasswordHash(oldPasswordHash)' 'old password must be the declared 32-character digest'
Require-Text $api 'newPassword: assertPasswordHash(newPasswordHash)' 'new password must be the declared 32-character digest'
Require-Text $api 'requireData: false' 'password change must accept the declared VoidResult envelope'
Require-Text $api 'requestController: requestOptions.requestController ?? null' 'password change must accept page cancellation ownership'
Require-Text $page 'appApi.changePassword({' 'M04 must call the shared password owner'
Require-Text $page 'oldPasswordHash: calcMD5(passwordForm.current)' 'M04 must hash the current password before transport'
Require-Text $page 'newPasswordHash: calcMD5(passwordForm.next)' 'M04 must hash the new password before transport'
Require-Text $page 'passwordRequestController.abort()' 'M04 must cancel an in-flight request when unloading'
Require-Text $page 'isRequestCancelled(error)' 'M04 must not report a cancelled request as a password-change failure'
if ($api -match "url: '/genealogy/app/auth/password'[\s\S]{0,260}passwordForm\.") {
$issues.Add('password API owner must not depend on page form state')
}
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 PUT $passwordPath 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]$Status)
if (-not $Response) { return '' }
$media = $Response.content.PSObject.Properties['application/json']
if (-not $media) {
Add-Issue "JSON PUT $passwordPath $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 PUT $passwordPath $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 PUT $passwordPath $Status Cache-Control must specify private, no-store"
}
}
function Assert-SecretSchema {
param(
[object]$Schema,
[string]$Name,
[int]$Minimum,
[int]$Maximum
)
if (-not $Schema) { return }
if ($Schema.type -ne 'string' -or $Schema.format -ne 'password' -or $Schema.writeOnly -ne $true -or
[int]$Schema.minLength -ne $Minimum -or [int]$Schema.maxLength -ne $Maximum) {
Add-Issue "JSON $Name must be a writeOnly password string of $Minimum..$Maximum Unicode code points"
}
if ($Schema.pattern -or $Schema.example -or ([string]$Schema.description) -match '(?i)MD5|hex|字母.*数字|数字.*字母') {
Add-Issue "JSON $Name must not retain a static digest, composition rule, pattern, or password example"
}
if (([string]$Schema.description) -notmatch '(?i)Unicode code point' -or
([string]$Schema.description) -notmatch '(?i)NFC' -or
([string]$Schema.description) -notmatch '(?i)(space|空格)') {
Add-Issue "JSON $Name must define Unicode code-point length, NFC normalization, and space handling"
}
}
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"
}
}
$pathProperty = $document.paths.PSObject.Properties[$passwordPath]
$operation = if ($pathProperty) { $pathProperty.Value.put } else { $null }
if (-not $operation) { Add-Issue "JSON missing PUT $passwordPath" }
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 $passwordPath 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 PUT $passwordPath must require one non-empty string clientid header"
}
$requestMedia = $operation.requestBody.content.PSObject.Properties['application/json']
if ($operation.requestBody.required -ne $true -or -not $requestMedia) {
Add-Issue "JSON PUT $passwordPath must require an application/json body"
} elseif ($requestMedia.Value.schema.'$ref' -ne '#/components/schemas/PasswordChangeBody') {
Add-Issue 'JSON password change request must use PasswordChangeBody'
}
$semantics = [string]$operation.description
foreach ($semanticPattern in @(
'(?i)current password.*re-authentication',
'(?i)atomic.*password.*credential epoch',
'(?i)all.*access.*refresh.*sessions.*including.*current',
'(?i)200.*sessions.*invalidated',
'(?i)new password.*different.*current password',
'(?i)(common|breached) password.*blocklist',
'(?i)rate limit'
)) {
if ($semantics -notmatch $semanticPattern) {
Add-Issue "JSON PUT $passwordPath description is missing security/session semantics: $semanticPattern"
}
}
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
if (-not $operation.responses.PSObject.Properties[$status]) {
Add-Issue "JSON PUT $passwordPath missing documented response: $status"
}
}
}
$changeBody = Get-Schema 'PasswordChangeBody'
$currentSecret = Get-Schema 'CurrentPasswordSecret'
$newSecret = Get-Schema 'NewPasswordSecret'
Assert-SecretSchema $currentSecret 'CurrentPasswordSecret' 1 64
Assert-SecretSchema $newSecret 'NewPasswordSecret' 15 64
if ($changeBody) {
$properties = @($changeBody.properties.PSObject.Properties.Name | Sort-Object)
$required = @($changeBody.required | Sort-Object)
if ($changeBody.type -ne 'object' -or $changeBody.additionalProperties -ne $false -or
($properties -join ',') -ne 'newPassword,oldPassword' -or
($required -join ',') -ne 'newPassword,oldPassword') {
Add-Issue 'JSON PasswordChangeBody must be a closed object requiring only oldPassword/newPassword'
}
}
# 密码传输是跨登录、注册、找回和登录态改密的单一合同。禁止只让 M04 改成明文,
# 其余入口继续接受可重放摘要;新合同落地时必须一次删除全部 MD5 wire fallback。
Assert-FieldRef 'PasswordLoginBody' 'password' '#/components/schemas/CurrentPasswordSecret'
Assert-FieldRef 'PasswordRegisterBody' 'password' '#/components/schemas/NewPasswordSecret'
Assert-FieldRef 'PasswordResetBody' 'newPassword' '#/components/schemas/NewPasswordSecret'
Assert-FieldRef 'PasswordChangeBody' 'oldPassword' '#/components/schemas/CurrentPasswordSecret'
Assert-FieldRef 'PasswordChangeBody' 'newPassword' '#/components/schemas/NewPasswordSecret'
$responses = @{}
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
$responses[$status] = Get-Response $operation $status
[void](Get-JsonSchemaRef $responses[$status] $status)
Assert-PrivateNoStore $responses[$status] $status
}
if ((Get-JsonSchemaRef $responses['200'] '200') -ne '#/components/schemas/RVoid') {
Add-Issue 'JSON PUT password 200 must return RVoid after all sessions are invalidated'
}
foreach ($status in @('409', '422')) {
if ((Get-JsonSchemaRef $responses[$status] $status) -ne '#/components/schemas/RPasswordChangeRejected') {
Add-Issue "JSON PUT password $status must return RPasswordChangeRejected"
}
}
$retryAfterProperty = if ($responses['429'] -and $responses['429'].headers) {
$responses['429'].headers.PSObject.Properties['Retry-After']
} else { $null }
if (-not $retryAfterProperty) {
Add-Issue 'JSON PUT password 429 must document Retry-After'
}
$void = Get-Schema 'RVoid'
$rejected = Get-Schema 'RPasswordChangeRejected'
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 RPasswordChangeRejected.required missing: $field" }
}
$codes = @($rejected.properties.businessCode.enum | Sort-Object)
$expectedCodes = @(
'CREDENTIAL_VERSION_CONFLICT',
'CURRENT_PASSWORD_INCORRECT',
'NEW_PASSWORD_SAME_AS_CURRENT',
'PASSWORD_POLICY_VIOLATION'
) | 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 RPasswordChangeRejected must expose the four stable conflict/validation business codes'
}
}
foreach ($yamlFact in @(
' /genealogy/app/auth/password:',
' name: clientid',
'#/components/schemas/PasswordChangeBody',
'#/components/schemas/CurrentPasswordSecret',
'#/components/schemas/NewPasswordSecret',
'#/components/schemas/RPasswordChangeRejected',
' CurrentPasswordSecret:',
' NewPasswordSecret:',
' writeOnly: true',
' minLength: 15',
' maxLength: 64',
' RPasswordChangeRejected:',
' - CREDENTIAL_VERSION_CONFLICT',
' - CURRENT_PASSWORD_INCORRECT',
' - NEW_PASSWORD_SAME_AS_CURRENT',
' - PASSWORD_POLICY_VIOLATION',
' Cache-Control:',
' Retry-After:'
)) {
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
if ($page -notmatch 'oldPasswordHash:\s*calcMD5\(passwordForm\.current\)[\s\S]{0,160}newPasswordHash:\s*calcMD5\(passwordForm\.next\)') {
$issues.Add('M04 must pass only the MD5 digests to the API owner')
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Remove static MD5 from login/register/reset/change in one contract migration; accept raw writeOnly passwords only over authenticated HTTPS and store a salted adaptive server-side hash.')
$lines.Add('- The target new-password policy is 15..64 Unicode code points, NFC, spaces allowed, no composition rule, plus server-side common/breached-password blocklist and rate limiting.')
$lines.Add('- A strict 200 means the password is durable and every pre-existing access/refresh session, including the caller, is invalidated; the client clears locally and returns to A01.')
$lines.Add('- Network, timeout, malformed response, or 5xx after dispatch is outcome-unknown: clear secrets/session, return to A01, and never retry automatically or claim success.')
$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)
throw ("PASSWORD-CHANGE-OPENAPI-CONTRACT FAIL`n- " + ($issues -join "`n- "))
}
Write-Output 'PASSWORD-CHANGE-OPENAPI-CONTRACT PASS'