1014 lines
50 KiB
PowerShell
1014 lines
50 KiB
PowerShell
$ErrorActionPreference = 'Stop'
|
|
|
|
$gatePath = Join-Path $PSScriptRoot 'g12-generation-poem-openapi-contract.ps1'
|
|
|
|
function New-SeedDocument {
|
|
return New-CompletePassingDocument
|
|
}
|
|
|
|
function Copy-Document([object]$Value) {
|
|
return $Value | ConvertTo-Json -Depth 100 -Compress | ConvertFrom-Json
|
|
}
|
|
|
|
function Set-Property([object]$Object, [string]$Name, [object]$Value) {
|
|
$Object | Add-Member -MemberType NoteProperty -Name $Name -Value $Value -Force
|
|
}
|
|
|
|
function Rename-Property([object]$Object, [string]$OldName, [string]$NewName) {
|
|
$property = @($Object.PSObject.Properties | Where-Object { $_.Name -ceq $OldName })
|
|
if ($property.Count -ne 1) { throw "cannot rename missing exact property: $OldName" }
|
|
$value = $property[0].Value
|
|
$Object.PSObject.Properties.Remove($OldName)
|
|
Set-Property $Object $NewName $value
|
|
}
|
|
|
|
function Get-Issues([object]$Document) {
|
|
return @(& $gatePath -InputDocument $Document -SkipParity -ReturnIssues)
|
|
}
|
|
|
|
function Assert-MutationRejected {
|
|
param(
|
|
[string]$Name,
|
|
[scriptblock]$Setup,
|
|
[scriptblock]$Mutate,
|
|
[string]$Pattern
|
|
)
|
|
$baselineDocument = New-SeedDocument
|
|
& $Setup $baselineDocument
|
|
$before = @(Get-Issues $baselineDocument)
|
|
if ($before.Count -ne 0) {
|
|
throw "$Name setup must have zero issues: $($before -join ' | ')"
|
|
}
|
|
$mutant = Copy-Document $baselineDocument
|
|
& $Mutate $mutant
|
|
$after = @(Get-Issues $mutant)
|
|
$newIssues = @($after | Where-Object { $_ -notin $before })
|
|
if ($after.Count -le $before.Count -or -not ($newIssues | Where-Object { $_ -match $Pattern })) {
|
|
throw "$Name fake-green. New issues: $($newIssues -join ' | ')"
|
|
}
|
|
}
|
|
|
|
function Assert-LocalBaselineAccepted {
|
|
param(
|
|
[string]$Name,
|
|
[scriptblock]$Setup,
|
|
[string]$ForbiddenPattern
|
|
)
|
|
$document = New-SeedDocument
|
|
& $Setup $document
|
|
$issues = @(Get-Issues $document)
|
|
if ($issues.Count -ne 0 -or $issues | Where-Object { $_ -match $ForbiddenPattern }) {
|
|
throw "$Name false-positive: $($issues -join ' | ')"
|
|
}
|
|
}
|
|
|
|
function New-SecurityRequirement {
|
|
return @([pscustomobject]@{ SaToken = @() })
|
|
}
|
|
|
|
function New-MinimalOperation([string]$OperationId, [string]$Description) {
|
|
return [pscustomobject]@{
|
|
description = $Description
|
|
operationId = $OperationId
|
|
parameters = @()
|
|
responses = [pscustomobject]@{}
|
|
security = @((New-SecurityRequirement))
|
|
}
|
|
}
|
|
|
|
function Add-ShadowMutation([object]$Document, [string]$Path, [object]$Schema) {
|
|
$operation = New-MinimalOperation 'shadowMutation' 'unrelated shadow mutation'
|
|
Set-Property $operation 'requestBody' ([pscustomobject]@{
|
|
required = $true
|
|
content = [pscustomobject]@{
|
|
'application/json' = [pscustomobject]@{ schema = $Schema }
|
|
}
|
|
})
|
|
Set-Property $Document.paths $Path ([pscustomobject]@{ put = $operation })
|
|
}
|
|
|
|
function Add-TargetGetWithValidExtensions([object]$Document) {
|
|
$operation = New-MinimalOperation 'appGetGenerationPoemSet' 'canView ACTIVE-only generationNo ascending non-disclosing 404'
|
|
Set-Property $operation 'x-projection' 'ACTIVE_ONLY'
|
|
Set-Property $operation 'x-sort-order' @('generationNo:asc')
|
|
Set-Property $operation 'x-cors-required-request-headers' @('Authorization', 'clientid')
|
|
Set-Property $operation 'x-cors-policy-owner' 'APP_GATEWAY_PREFLIGHT'
|
|
$pathName = '/genealogy/app/genealogies/{genealogyId}/generation-poems'
|
|
$pathProperty = $Document.paths.PSObject.Properties[$pathName]
|
|
if ($pathProperty) { Set-Property $pathProperty.Value 'get' $operation } else { Set-Property $Document.paths $pathName ([pscustomobject]@{ get = $operation }) }
|
|
}
|
|
|
|
function Add-TargetPutWithValidExtensions([object]$Document) {
|
|
$operation = New-MinimalOperation 'appUpdateGenerationPoemSet' 'canEditContent READY transaction If-Match poemSetVersion full candidate before write disableMissing=false preserve unrepresented ACTIVE disableMissing=true soft-disable unrepresented ACTIVE never physical delete swap no observable intermediate semantic no-op keep version unknown fresh GET target old current current target not prove request no automatic PUT'
|
|
Set-Property $operation 'requestBody' ([pscustomobject]@{ required = $true; content = [pscustomobject]@{} })
|
|
$values = [ordered]@{
|
|
'x-cas-owner' = 'GenerationPoemSetVersion'
|
|
'x-revalidates' = @('tenantScope', 'canEditContent', 'poemSetVersion', 'genealogyState')
|
|
'x-candidate-steps' = @('RESOLVE_BASELINE_ACTIVE_IDS', 'VALIDATE_DECLARED_STRICT_ORDER', 'BUILD_DECLARED_TARGET', 'APPLY_DISABLE_MISSING_POLICY', 'ORDER_MERGED_CANDIDATE_BY_GENERATION_NO', 'VALIDATE_ID_AND_GENERATION_UNIQUENESS', 'VALIDATE_GENERATION_SLOT_COLLISIONS', 'VALIDATE_CONTIGUITY', 'VALIDATE_FINAL_ACTIVE_CAPACITY', 'ALLOCATE_UNIQUE_NEW_IDS', 'VALIDATE_ALLOCATED_IDS_NONEMPTY_UNIQUE_NOT_IN_BASELINE', 'WRITE_ATOMICALLY')
|
|
'x-generation-slot-collision-policy' = 'REJECT_422_ZERO_DOMAIN_WRITE'
|
|
'x-swap-declaration-policy' = 'ALL_AFFECTED_ROWS_REQUIRED'
|
|
'x-swap-write-policy' = 'ATOMIC_NO_OBSERVABLE_INTERMEDIATE_STATE'
|
|
'x-transaction-failure-policy' = 'ZERO_DOMAIN_WRITE'
|
|
'x-transaction-effects' = @('APPLY_VALIDATED_CANDIDATE_ATOMICALLY', 'APPLY_DISABLE_MISSING_POLICY', 'UPDATE_VERSION_ON_SEMANTIC_CHANGE')
|
|
'x-semantic-noop' = 'RETURN_200_KEEP_VERSION'
|
|
'x-version-reuse' = 'FORBIDDEN'
|
|
'x-outcome-unknown-reconciliation' = 'FRESH_GET_THREE_WAY_NO_AUTO_PUT'
|
|
'x-unknown-precedence' = 'CURRENT_EQUALS_TARGET_THEN_CURRENT_EQUALS_OLD_THEN_DIVERGED'
|
|
'x-unknown-overlap-policy' = 'CURRENT_EQUALS_TARGET_FIRST_NO_ATTRIBUTION'
|
|
'x-unknown-existing-match-fields' = @('poemId', 'generationNo', 'generationText')
|
|
'x-unknown-new-match-fields' = @('generationNo', 'generationText')
|
|
'x-unknown-new-id-policy' = 'NON_EMPTY_UNIQUE_NOT_IN_BASELINE'
|
|
'x-unknown-set-equality' = 'SAME_CARDINALITY_NO_EXTRA_ACTIVE_ROWS'
|
|
'x-unknown-version-comparison' = 'IGNORE_FOR_SEMANTIC_EQUALITY'
|
|
'x-unknown-version-policy' = 'CHANGED_TARGET_REQUIRES_FRESH_NONREUSED_VERSION'
|
|
'x-unknown-target-construction-fields' = @('genealogyId', 'baselinePoemSetVersion', 'baselineOrderedItems', 'declaredItems', 'disableMissing')
|
|
'x-unknown-semantic-fields' = @('genealogyId', 'effectiveTargetOrderedItems')
|
|
'x-cors-required-request-headers' = @('Authorization', 'Content-Type', 'If-Match', 'clientid')
|
|
'x-cors-policy-owner' = 'APP_GATEWAY_PREFLIGHT'
|
|
}
|
|
foreach ($entry in $values.GetEnumerator()) { Set-Property $operation $entry.Key $entry.Value }
|
|
$pathName = '/genealogy/app/genealogies/{genealogyId}/generation-poems'
|
|
$pathProperty = $Document.paths.PSObject.Properties[$pathName]
|
|
if ($pathProperty) { Set-Property $pathProperty.Value 'put' $operation } else { Set-Property $Document.paths $pathName ([pscustomobject]@{ put = $operation }) }
|
|
}
|
|
|
|
function New-Ref([string]$Ref) {
|
|
return [pscustomobject]@{ '$ref' = $Ref }
|
|
}
|
|
|
|
function New-FixedErrorSchema([int]$Status, [string]$BusinessCode) {
|
|
return [pscustomobject]@{
|
|
type = 'object'
|
|
nullable = $false
|
|
additionalProperties = $false
|
|
required = @('code', 'businessCode', 'message')
|
|
properties = [pscustomobject]@{
|
|
code = [pscustomobject]@{ type = 'integer'; format = 'int32'; enum = @($Status); nullable = $false }
|
|
businessCode = [pscustomobject]@{ type = 'string'; enum = @($BusinessCode); nullable = $false }
|
|
message = [pscustomobject]@{ type = 'string'; minLength = 1; maxLength = 200; nullable = $false }
|
|
}
|
|
}
|
|
}
|
|
|
|
function New-ApiResponse([string]$SchemaRef, [bool]$RetryAfter) {
|
|
$headers = [pscustomobject]@{
|
|
'Cache-Control' = (New-Ref '#/components/headers/PrivateNoStore')
|
|
}
|
|
if ($RetryAfter) { Set-Property $headers 'Retry-After' (New-Ref '#/components/headers/RetryAfter') }
|
|
return [pscustomobject]@{
|
|
description = 'typed response'
|
|
headers = $headers
|
|
content = [pscustomobject]@{
|
|
'application/json' = [pscustomobject]@{ schema = (New-Ref $SchemaRef) }
|
|
}
|
|
}
|
|
}
|
|
|
|
function New-ClientIdParameter {
|
|
return [pscustomobject]@{
|
|
name = 'clientid'
|
|
in = 'header'
|
|
required = $true
|
|
description = 'client instance'
|
|
schema = [pscustomobject]@{ type = 'string'; minLength = 1; maxLength = 128; nullable = $false }
|
|
}
|
|
}
|
|
|
|
function New-GenealogyIdParameter {
|
|
return [pscustomobject]@{
|
|
name = 'genealogyId'
|
|
in = 'path'
|
|
required = $true
|
|
description = 'genealogy identity'
|
|
schema = (New-Ref '#/components/schemas/GenealogyId')
|
|
}
|
|
}
|
|
|
|
function New-IfMatchParameter {
|
|
return [pscustomobject]@{
|
|
name = 'If-Match'
|
|
in = 'header'
|
|
required = $true
|
|
description = 'poem set version'
|
|
schema = (New-Ref '#/components/schemas/GenerationPoemSetVersion')
|
|
}
|
|
}
|
|
|
|
function New-CompletePassingDocument {
|
|
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
|
|
$textPattern = '^(?!.*[\u0000-\u001F\u007F-\u009F\u061C\u200B-\u200F\u2028-\u202E\u2060\u2066-\u2069\uFEFF])\S(?:[\s\S]*\S)?$'
|
|
$fieldPathPattern = '^(items|items\[(?:[0-9]|[1-9][0-9]|[1-4][0-9]{2})\]\.(poemId|generationNo|generationText)|disableMissing)$'
|
|
|
|
$schemas = [pscustomobject]@{
|
|
GenealogyId = [pscustomobject]@{
|
|
type = 'string'; minLength = 1; maxLength = 128; pattern = $identifierPattern; nullable = $false
|
|
}
|
|
GenerationPoemId = [pscustomobject]@{
|
|
type = 'string'; minLength = 1; maxLength = 128; pattern = $identifierPattern; nullable = $false
|
|
'x-opaque' = $true
|
|
'x-client-semantics' = 'COMPARE_ONLY'
|
|
}
|
|
GenerationPoemSetVersion = [pscustomobject]@{
|
|
type = 'string'; minLength = 1; maxLength = 128; pattern = $identifierPattern; nullable = $false
|
|
'x-opaque' = $true
|
|
'x-version-scope-fields' = @('poemId', 'generationNo', 'generationText', 'activeState')
|
|
'x-version-change-policy' = 'ACTIVE_SEMANTIC_CHANGE_ONLY'
|
|
'x-version-reuse' = 'FORBIDDEN'
|
|
}
|
|
GenerationPoemGenerationNo = [pscustomobject]@{
|
|
type = 'integer'; format = 'int32'; minimum = 1; maximum = 2147483647; nullable = $false
|
|
}
|
|
GenerationPoemText = [pscustomobject]@{
|
|
type = 'string'; minLength = 1; maxLength = 50; pattern = $textPattern; nullable = $false
|
|
'x-normalization' = 'NFC'
|
|
'x-length-unit' = 'UNICODE_CODE_POINT'
|
|
'x-well-formed-unicode' = $true
|
|
'x-boundary-whitespace' = 'REJECT'
|
|
'x-forbidden-code-point-classes' = @('C0', 'C1', 'CR', 'LF', 'TAB', 'ARABIC_LETTER_MARK', 'LTR_RTL_MARK', 'BIDI_OVERRIDE_OR_ISOLATE', 'WORD_JOINER', 'ZERO_WIDTH_OR_BOM', 'LINE_PARAGRAPH_SEPARATOR', 'UNPAIRED_SURROGATE')
|
|
}
|
|
AppGenerationPoemSetItem = [pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('generationNo', 'generationText')
|
|
properties = [pscustomobject]@{
|
|
poemId = (New-Ref '#/components/schemas/GenerationPoemId')
|
|
generationNo = (New-Ref '#/components/schemas/GenerationPoemGenerationNo')
|
|
generationText = (New-Ref '#/components/schemas/GenerationPoemText')
|
|
}
|
|
'x-existing-id-policy' = 'CURRENT_BASELINE_ACTIVE_ONLY'
|
|
'x-new-id-policy' = 'OMIT_POEM_ID'
|
|
'x-id-move-policy' = 'ALLOW_EXPLICIT_TARGET_GENERATION'
|
|
'x-disabled-id-policy' = 'ROW_NOT_AVAILABLE'
|
|
}
|
|
GenerationPoemSetItem = [pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('poemId', 'generationNo', 'generationText')
|
|
properties = [pscustomobject]@{
|
|
poemId = (New-Ref '#/components/schemas/GenerationPoemId')
|
|
generationNo = (New-Ref '#/components/schemas/GenerationPoemGenerationNo')
|
|
generationText = (New-Ref '#/components/schemas/GenerationPoemText')
|
|
}
|
|
}
|
|
AppGenerationPoemSetUpdateBody = [pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('items', 'disableMissing')
|
|
properties = [pscustomobject]@{
|
|
items = [pscustomobject]@{
|
|
type = 'array'; minItems = 0; maxItems = 500; nullable = $false
|
|
items = (New-Ref '#/components/schemas/AppGenerationPoemSetItem')
|
|
}
|
|
disableMissing = [pscustomobject]@{ type = 'boolean'; nullable = $false }
|
|
}
|
|
'x-request-order' = 'STRICT_GENERATION_NO_ASC_NO_SERVER_SORT'
|
|
'x-false-policy' = 'PRESERVE_ALL_UNREPRESENTED_BASELINE_ACTIVE'
|
|
'x-true-policy' = 'SOFT_DISABLE_ALL_UNREPRESENTED_BASELINE_ACTIVE'
|
|
'x-empty-false-policy' = 'SEMANTIC_NOOP'
|
|
'x-empty-true-policy' = 'SOFT_DISABLE_ALL_ACTIVE'
|
|
'x-final-active-invariant' = 'EMPTY_OR_CONTIGUOUS_INTERVAL'
|
|
'x-candidate-validation' = 'BEFORE_ANY_DOMAIN_WRITE'
|
|
'x-duplicate-generation-text' = 'ALLOWED'
|
|
}
|
|
GenerationPoemSetSnapshot = [pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('genealogyId', 'poemSetVersion', 'items')
|
|
properties = [pscustomobject]@{
|
|
genealogyId = (New-Ref '#/components/schemas/GenealogyId')
|
|
poemSetVersion = (New-Ref '#/components/schemas/GenerationPoemSetVersion')
|
|
items = [pscustomobject]@{
|
|
type = 'array'; minItems = 0; maxItems = 500; nullable = $false
|
|
items = (New-Ref '#/components/schemas/GenerationPoemSetItem')
|
|
}
|
|
}
|
|
'x-projection' = 'ACTIVE_ONLY'
|
|
'x-order' = 'STRICT_GENERATION_NO_ASC'
|
|
'x-active-invariant' = 'EMPTY_OR_CONTIGUOUS_INTERVAL'
|
|
'x-identity-uniqueness' = 'POEM_ID_AND_GENERATION_NO'
|
|
}
|
|
RGenerationPoemSetSnapshot = [pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('code', 'data')
|
|
properties = [pscustomobject]@{
|
|
code = [pscustomobject]@{ type = 'integer'; format = 'int32'; enum = @(200); nullable = $false }
|
|
data = (New-Ref '#/components/schemas/GenerationPoemSetSnapshot')
|
|
}
|
|
}
|
|
RGenerationPoemBadRequest = (New-FixedErrorSchema 400 'GENERATION_POEM_REQUEST_INVALID')
|
|
RGenerationPoemUnauthorized = (New-FixedErrorSchema 401 'AUTH_REQUIRED')
|
|
RGenerationPoemForbidden = (New-FixedErrorSchema 403 'GENERATION_POEM_EDIT_FORBIDDEN')
|
|
RGenerationPoemNotFound = (New-FixedErrorSchema 404 'GENEALOGY_NOT_AVAILABLE')
|
|
RGenerationPoemRateLimited = (New-FixedErrorSchema 429 'RATE_LIMITED')
|
|
RGenerationPoemReadUnavailable = (New-FixedErrorSchema 500 'GENERATION_POEM_READ_UNAVAILABLE')
|
|
RGenerationPoemOutcomeUnknown = (New-FixedErrorSchema 500 'GENERATION_POEM_OUTCOME_UNKNOWN')
|
|
GenerationPoemSetVersionConflict = [pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('code', 'businessCode', 'message', 'current')
|
|
properties = [pscustomobject]@{
|
|
code = [pscustomobject]@{ type = 'integer'; format = 'int32'; enum = @(409); nullable = $false }
|
|
businessCode = [pscustomobject]@{ type = 'string'; enum = @('POEM_SET_VERSION_CONFLICT'); nullable = $false }
|
|
message = [pscustomobject]@{ type = 'string'; minLength = 1; maxLength = 200; nullable = $false }
|
|
current = (New-Ref '#/components/schemas/GenerationPoemSetSnapshot')
|
|
}
|
|
}
|
|
GenerationPoemGenealogyNotReadyConflict = [pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('code', 'businessCode', 'message')
|
|
properties = [pscustomobject]@{
|
|
code = [pscustomobject]@{ type = 'integer'; format = 'int32'; enum = @(409); nullable = $false }
|
|
businessCode = [pscustomobject]@{ type = 'string'; enum = @('GENEALOGY_NOT_READY'); nullable = $false }
|
|
message = [pscustomobject]@{ type = 'string'; minLength = 1; maxLength = 200; nullable = $false }
|
|
}
|
|
}
|
|
RGenerationPoemSetConflict = [pscustomobject]@{
|
|
oneOf = @(
|
|
(New-Ref '#/components/schemas/GenerationPoemSetVersionConflict'),
|
|
(New-Ref '#/components/schemas/GenerationPoemGenealogyNotReadyConflict')
|
|
)
|
|
discriminator = [pscustomobject]@{
|
|
propertyName = 'businessCode'
|
|
mapping = [pscustomobject]@{
|
|
POEM_SET_VERSION_CONFLICT = '#/components/schemas/GenerationPoemSetVersionConflict'
|
|
GENEALOGY_NOT_READY = '#/components/schemas/GenerationPoemGenealogyNotReadyConflict'
|
|
}
|
|
}
|
|
'x-conflict-precedence' = @('VERSION', 'READY')
|
|
}
|
|
GenerationPoemFieldError = [pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('path', 'message')
|
|
properties = [pscustomobject]@{
|
|
path = [pscustomobject]@{ type = 'string'; minLength = 1; maxLength = 128; pattern = $fieldPathPattern; nullable = $false }
|
|
message = [pscustomobject]@{ type = 'string'; minLength = 1; maxLength = 200; nullable = $false }
|
|
}
|
|
}
|
|
RGenerationPoemUnprocessable = [pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('code', 'businessCode', 'message', 'fieldErrors')
|
|
properties = [pscustomobject]@{
|
|
code = [pscustomobject]@{ type = 'integer'; format = 'int32'; enum = @(422); nullable = $false }
|
|
businessCode = [pscustomobject]@{
|
|
type = 'string'; nullable = $false
|
|
enum = @('DUPLICATE_GENERATION_NO', 'DUPLICATE_POEM_ID', 'GENERATION_POEM_CAPACITY_EXCEEDED', 'GENERATION_POEM_REQUEST_ORDER_INVALID', 'GENERATION_POEM_TEXT_INVALID', 'GENERATION_SLOT_CONFLICT', 'NON_CONTIGUOUS_GENERATIONS', 'POEM_ROW_NOT_AVAILABLE')
|
|
}
|
|
message = [pscustomobject]@{ type = 'string'; minLength = 1; maxLength = 200; nullable = $false }
|
|
fieldErrors = [pscustomobject]@{
|
|
type = 'array'; minItems = 0; maxItems = 500; nullable = $false
|
|
items = (New-Ref '#/components/schemas/GenerationPoemFieldError')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$document = [pscustomobject]@{
|
|
openapi = '3.0.1'
|
|
'x-app-gateway-policies' = [pscustomobject]@{
|
|
APP_GATEWAY_PREFLIGHT = [pscustomobject]@{
|
|
allowedRequestHeaders = @('Authorization', 'Content-Type', 'If-Match', 'clientid')
|
|
allowedMethods = @('GET', 'PUT', 'OPTIONS')
|
|
originPolicy = 'EXPLICIT_DEPLOYMENT_ALLOWLIST'
|
|
maxAgeSeconds = 600
|
|
}
|
|
}
|
|
paths = [pscustomobject]@{}
|
|
components = [pscustomobject]@{
|
|
schemas = $schemas
|
|
parameters = [pscustomobject]@{}
|
|
requestBodies = [pscustomobject]@{}
|
|
responses = [pscustomobject]@{}
|
|
callbacks = [pscustomobject]@{}
|
|
headers = [pscustomobject]@{
|
|
PrivateNoStore = [pscustomobject]@{
|
|
schema = [pscustomobject]@{ type = 'string'; enum = @('private, no-store'); nullable = $false }
|
|
}
|
|
RetryAfter = [pscustomobject]@{
|
|
schema = [pscustomobject]@{ type = 'integer'; minimum = 1; maximum = 120; nullable = $false }
|
|
}
|
|
}
|
|
securitySchemes = [pscustomobject]@{
|
|
SaToken = [pscustomobject]@{ type = 'apiKey'; in = 'header'; name = 'Authorization' }
|
|
}
|
|
}
|
|
}
|
|
|
|
Add-TargetGetWithValidExtensions $document
|
|
Add-TargetPutWithValidExtensions $document
|
|
$pathItem = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'
|
|
$get = $pathItem.get
|
|
$put = $pathItem.put
|
|
Set-Property $get 'deprecated' $false
|
|
Set-Property $put 'deprecated' $false
|
|
$get.parameters = @((New-ClientIdParameter), (New-GenealogyIdParameter))
|
|
$put.parameters = @((New-IfMatchParameter), (New-ClientIdParameter), (New-GenealogyIdParameter))
|
|
$put.requestBody = [pscustomobject]@{
|
|
required = $true
|
|
content = [pscustomobject]@{
|
|
'application/json' = [pscustomobject]@{ schema = (New-Ref '#/components/schemas/AppGenerationPoemSetUpdateBody') }
|
|
}
|
|
}
|
|
|
|
$getResponses = [ordered]@{
|
|
'200' = '#/components/schemas/RGenerationPoemSetSnapshot'
|
|
'400' = '#/components/schemas/RGenerationPoemBadRequest'
|
|
'401' = '#/components/schemas/RGenerationPoemUnauthorized'
|
|
'404' = '#/components/schemas/RGenerationPoemNotFound'
|
|
'429' = '#/components/schemas/RGenerationPoemRateLimited'
|
|
'500' = '#/components/schemas/RGenerationPoemReadUnavailable'
|
|
}
|
|
$putResponses = [ordered]@{
|
|
'200' = '#/components/schemas/RGenerationPoemSetSnapshot'
|
|
'400' = '#/components/schemas/RGenerationPoemBadRequest'
|
|
'401' = '#/components/schemas/RGenerationPoemUnauthorized'
|
|
'403' = '#/components/schemas/RGenerationPoemForbidden'
|
|
'404' = '#/components/schemas/RGenerationPoemNotFound'
|
|
'409' = '#/components/schemas/RGenerationPoemSetConflict'
|
|
'422' = '#/components/schemas/RGenerationPoemUnprocessable'
|
|
'429' = '#/components/schemas/RGenerationPoemRateLimited'
|
|
'500' = '#/components/schemas/RGenerationPoemOutcomeUnknown'
|
|
}
|
|
foreach ($entry in $getResponses.GetEnumerator()) { Set-Property $get.responses $entry.Key (New-ApiResponse $entry.Value ($entry.Key -eq '429')) }
|
|
foreach ($entry in $putResponses.GetEnumerator()) { Set-Property $put.responses $entry.Key (New-ApiResponse $entry.Value ($entry.Key -eq '429')) }
|
|
return $document
|
|
}
|
|
|
|
$passingSeedIssues = @(Get-Issues (New-SeedDocument))
|
|
if ($passingSeedIssues.Count -ne 0) {
|
|
throw "complete G12 passing seed must have zero issues: $($passingSeedIssues -join ' | ')"
|
|
}
|
|
|
|
$noSetup = { param($document) }
|
|
|
|
Assert-MutationRejected 'Path Item parameters keyword casing' {
|
|
param($document)
|
|
$pathItem = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'
|
|
$genealogyId = @($pathItem.get.parameters | Where-Object { $_.name -ceq 'genealogyId' })[0]
|
|
Set-Property $pathItem 'parameters' @($genealogyId)
|
|
$pathItem.get.parameters = @($pathItem.get.parameters | Where-Object { $_.name -cne 'genealogyId' })
|
|
$pathItem.put.parameters = @($pathItem.put.parameters | Where-Object { $_.name -cne 'genealogyId' })
|
|
} {
|
|
param($document)
|
|
$pathItem = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'
|
|
Rename-Property $pathItem 'parameters' 'Parameters'
|
|
} 'Path Item.*non-canonical object keyword casing: Parameters'
|
|
|
|
Assert-MutationRejected 'second write through allOf' $noSetup {
|
|
param($document)
|
|
Add-ShadowMutation $document '/genealogy/app/poems-shadow' ([pscustomobject]@{
|
|
allOf = @([pscustomobject]@{ '$ref' = '#/components/schemas/AppGenerationPoemSetUpdateBody' })
|
|
})
|
|
} 'generation-poem APP mutation.*poems-shadow'
|
|
|
|
Assert-MutationRejected 'second write through two-hop alias' $noSetup {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'ShadowAliasA' (New-Ref '#/components/schemas/ShadowAliasB')
|
|
Set-Property $document.components.schemas 'ShadowAliasB' ([pscustomobject]@{
|
|
allOf = @((New-Ref '#/components/schemas/AppGenerationPoemSetUpdateBody'))
|
|
})
|
|
Add-ShadowMutation $document '/genealogy/app/two-hop-shadow' (New-Ref '#/components/schemas/ShadowAliasA')
|
|
} 'generation-poem APP mutation.*two-hop-shadow'
|
|
|
|
Assert-MutationRejected 'two-hop external schema ref' $noSetup {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'ExternalAliasA' (New-Ref '#/components/schemas/ExternalAliasB')
|
|
Set-Property $document.components.schemas 'ExternalAliasB' (New-Ref 'https://evil.test/openapi.json#/components/schemas/Foo')
|
|
Add-ShadowMutation $document '/genealogy/app/two-hop-external' (New-Ref '#/components/schemas/ExternalAliasA')
|
|
} 'two-hop-external.*uninspectable external'
|
|
|
|
Assert-MutationRejected 'cycle plus target remains inspectable' $noSetup {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'CycleAlias' ([pscustomobject]@{
|
|
allOf = @(
|
|
(New-Ref '#/components/schemas/AppGenerationPoemSetUpdateBody'),
|
|
(New-Ref '#/components/schemas/CycleAlias')
|
|
)
|
|
})
|
|
Add-ShadowMutation $document '/genealogy/app/cycle-shadow' (New-Ref '#/components/schemas/CycleAlias')
|
|
} 'generation-poem APP mutation.*cycle-shadow'
|
|
|
|
Assert-MutationRejected 'external generic schema ref' $noSetup {
|
|
param($document)
|
|
Add-ShadowMutation $document '/genealogy/app/external-shadow' ([pscustomobject]@{
|
|
'$ref' = 'https://evil.test/openapi.json#/components/schemas/Foo'
|
|
})
|
|
} 'external-shadow.*uninspectable external'
|
|
|
|
Assert-MutationRejected 'wrong-section generic schema ref' $noSetup {
|
|
param($document)
|
|
Add-ShadowMutation $document '/genealogy/app/wrong-section-shadow' ([pscustomobject]@{
|
|
'$ref' = '#/components/responses/Foo'
|
|
})
|
|
} 'wrong-section-shadow.*wrong-section ref'
|
|
|
|
Assert-MutationRejected 'no-body lexical delete' $noSetup {
|
|
param($document)
|
|
$operation = New-MinimalOperation 'deleteGenerationPoemShadow' 'delete a generation poem shadow row'
|
|
Set-Property $document.paths '/genealogy/app/poems/{poemId}' ([pscustomobject]@{ delete = $operation })
|
|
} 'generation-poem APP mutation.*DELETE /genealogy/app/poems/\{poemId\}'
|
|
|
|
Assert-MutationRejected 'read owner hidden in 206' $noSetup {
|
|
param($document)
|
|
$operation = New-MinimalOperation 'shadowRead' 'unrelated read'
|
|
Set-Property $operation.responses '206' ([pscustomobject]@{
|
|
description = 'partial'
|
|
headers = [pscustomobject]@{}
|
|
content = [pscustomobject]@{
|
|
'application/json' = [pscustomobject]@{
|
|
schema = [pscustomobject]@{
|
|
type = 'object'
|
|
properties = [pscustomobject]@{ genealogyId = [pscustomobject]@{}; poemSetVersion = [pscustomobject]@{}; items = [pscustomobject]@{} }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
Set-Property $document.paths '/genealogy/app/shadow-read' ([pscustomobject]@{ get = $operation })
|
|
} 'canonical read.*shadow-read'
|
|
|
|
Assert-MutationRejected 'path item ref' $noSetup {
|
|
param($document)
|
|
Set-Property $document.paths '/genealogy/app/path-item-shadow' ([pscustomobject]@{ '$ref' = 'https://evil.test/path-item.yaml' })
|
|
} 'Path Item.*path-item-shadow.*must not use'
|
|
|
|
Assert-MutationRejected 'HEAD canonical read owner' $noSetup {
|
|
param($document)
|
|
$operation = New-MinimalOperation 'shadowHead' 'head response'
|
|
Set-Property $operation.responses '200' (New-ApiResponse '#/components/schemas/RGenerationPoemSetSnapshot' $false)
|
|
Set-Property $document.paths '/genealogy/app/head-shadow' ([pscustomobject]@{ head = $operation })
|
|
} 'canonical read.*HEAD /genealogy/app/head-shadow'
|
|
|
|
Assert-MutationRejected 'OPTIONS G12 surface' $noSetup {
|
|
param($document)
|
|
$operation = New-MinimalOperation 'shadowOptions' 'options response'
|
|
Set-Property $operation 'requestBody' ([pscustomobject]@{
|
|
required = $true
|
|
content = [pscustomobject]@{ 'application/json' = [pscustomobject]@{ schema = (New-Ref '#/components/schemas/AppGenerationPoemSetUpdateBody') } }
|
|
})
|
|
Set-Property $document.paths '/genealogy/app/options-shadow' ([pscustomobject]@{ options = $operation })
|
|
} 'OPTIONS /genealogy/app/options-shadow is a forbidden G12 contract surface'
|
|
|
|
Assert-MutationRejected 'TRACE G12 surface' $noSetup {
|
|
param($document)
|
|
$operation = New-MinimalOperation 'traceGenerationPoemShadow' 'trace response'
|
|
Set-Property $document.paths '/genealogy/app/trace-shadow' ([pscustomobject]@{ trace = $operation })
|
|
} 'TRACE /genealogy/app/trace-shadow is a forbidden G12 contract surface'
|
|
|
|
Assert-MutationRejected 'requestBody ref sibling' {
|
|
param($document)
|
|
Set-Property $document.components.requestBodies 'PoemBodyAlias' ([pscustomobject]@{
|
|
required = $true
|
|
content = [pscustomobject]@{
|
|
'application/json' = [pscustomobject]@{ schema = [pscustomobject]@{ '$ref' = '#/components/schemas/AppGenerationPoemSetUpdateBody' } }
|
|
}
|
|
})
|
|
} {
|
|
param($document)
|
|
$operation = New-MinimalOperation 'shadowRefSibling' 'unrelated write'
|
|
Set-Property $operation 'requestBody' ([pscustomobject]@{ '$ref' = '#/components/requestBodies/PoemBodyAlias'; description = 'illegal sibling' })
|
|
Set-Property $document.paths '/genealogy/app/ref-sibling-shadow' ([pscustomobject]@{ put = $operation })
|
|
} 'ref-sibling-shadow.*Reference Object must contain only'
|
|
|
|
Assert-MutationRejected 'parameter ref sibling' {
|
|
param($document)
|
|
Set-Property $document.components.parameters 'GenealogyIdPath' (New-GenealogyIdParameter)
|
|
$get = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get
|
|
$get.parameters[1] = New-Ref '#/components/parameters/GenealogyIdPath'
|
|
} {
|
|
param($document)
|
|
$ref = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get.parameters[1]
|
|
Set-Property $ref 'description' 'illegal sibling'
|
|
} 'parameter OpenAPI 3.0.1 Reference Object must contain only'
|
|
|
|
Assert-MutationRejected 'response ref sibling' {
|
|
param($document)
|
|
$get = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get
|
|
Set-Property $document.components.responses 'G12GetSuccessResponse' $get.responses.'200'
|
|
$get.responses.'200' = New-Ref '#/components/responses/G12GetSuccessResponse'
|
|
} {
|
|
param($document)
|
|
$ref = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get.responses.'200'
|
|
Set-Property $ref 'description' 'illegal sibling'
|
|
} '200 OpenAPI 3.0.1 Reference Object must contain only'
|
|
|
|
Assert-MutationRejected 'header ref sibling' $noSetup {
|
|
param($document)
|
|
$ref = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get.responses.'200'.headers.'Cache-Control'
|
|
Set-Property $ref 'description' 'illegal sibling'
|
|
} 'Cache-Control must be the sole exact local PrivateNoStore Reference Object'
|
|
|
|
Assert-MutationRejected 'MediaType example conflict' $noSetup {
|
|
param($document)
|
|
$media = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put.requestBody.content.'application/json'
|
|
Set-Property $media 'example' ([pscustomobject]@{})
|
|
Set-Property $media 'examples' ([pscustomobject]@{})
|
|
} 'MediaType Object must not define both example and examples'
|
|
|
|
Assert-MutationRejected 'SaToken drift' $noSetup {
|
|
param($document)
|
|
$document.components.securitySchemes.SaToken.type = 'oauth2'
|
|
} 'SaToken must be the header apiKey'
|
|
|
|
Assert-MutationRejected 'SaToken nonempty scopes' $noSetup {
|
|
param($document)
|
|
$document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get.security[0].SaToken = @('read')
|
|
} 'must require exactly one SaToken security alternative'
|
|
|
|
Assert-MutationRejected 'security object instead of array' $noSetup {
|
|
param($document)
|
|
$document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get.security = [pscustomobject]@{ SaToken = @() }
|
|
} 'must require exactly one SaToken security alternative'
|
|
|
|
Assert-MutationRejected 'SaToken requirement key casing' $noSetup {
|
|
param($document)
|
|
$requirement = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get.security[0]
|
|
Rename-Property $requirement 'SaToken' 'satoken'
|
|
} 'must require exactly one SaToken security alternative'
|
|
|
|
Assert-MutationRejected 'top-level gateway policy key casing' $noSetup {
|
|
param($document)
|
|
Rename-Property $document 'x-app-gateway-policies' 'X-App-Gateway-Policies'
|
|
} 'missing top-level gateway CORS policy owner'
|
|
|
|
Assert-MutationRejected 'gateway policy owner key casing' $noSetup {
|
|
param($document)
|
|
Rename-Property $document.'x-app-gateway-policies' 'APP_GATEWAY_PREFLIGHT' 'app_gateway_preflight'
|
|
} 'missing top-level gateway CORS policy owner'
|
|
|
|
Assert-MutationRejected 'gateway policy field key casing' $noSetup {
|
|
param($document)
|
|
Rename-Property $document.'x-app-gateway-policies'.APP_GATEWAY_PREFLIGHT 'allowedMethods' 'AllowedMethods'
|
|
} 'APP_GATEWAY_PREFLIGHT gateway policy.*(unowned object keyword|missing required object keyword)'
|
|
|
|
Assert-MutationRejected 'operation CORS extension key casing' $noSetup {
|
|
param($document)
|
|
$operation = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put
|
|
Rename-Property $operation 'x-cors-policy-owner' 'X-Cors-Policy-Owner'
|
|
} 'unowned object keyword: X-Cors-Policy-Owner|PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'operation CORS owner value casing' $noSetup {
|
|
param($document)
|
|
$document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put.'x-cors-policy-owner' = 'app_gateway_preflight'
|
|
} 'PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'component owner key casing' $noSetup {
|
|
param($document)
|
|
Rename-Property $document.components.schemas 'GenerationPoemText' 'generationPoemText'
|
|
} 'missing schema owner: GenerationPoemText|references missing schemas owner: GenerationPoemText'
|
|
|
|
Assert-MutationRejected 'canonical ref target casing' $noSetup {
|
|
param($document)
|
|
$document.components.schemas.GenerationPoemSetItem.properties.generationText.'$ref' = '#/components/schemas/generationPoemText'
|
|
} 'GenerationPoemSetItem.generationText must be the sole exact local schema ref|references missing schemas owner: generationPoemText'
|
|
|
|
Assert-MutationRejected 'reference keyword casing' $noSetup {
|
|
param($document)
|
|
$reference = $document.components.schemas.GenerationPoemSetItem.properties.generationText
|
|
Rename-Property $reference '$ref' '$Ref'
|
|
} 'GenerationPoemSetItem.generationText must be the sole exact local schema ref'
|
|
|
|
Assert-MutationRejected 'candidate extension key casing' $noSetup {
|
|
param($document)
|
|
$operation = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put
|
|
Rename-Property $operation 'x-candidate-steps' 'X-Candidate-Steps'
|
|
} 'unowned object keyword: X-Candidate-Steps|PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'candidate step value casing' $noSetup {
|
|
param($document)
|
|
$steps = @($document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put.'x-candidate-steps')
|
|
$steps[0] = $steps[0].ToLowerInvariant()
|
|
$document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put.'x-candidate-steps' = $steps
|
|
} 'PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'candidate adjacent step swap' $noSetup {
|
|
param($document)
|
|
$steps = @($document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put.'x-candidate-steps')
|
|
$first = $steps[0]
|
|
$steps[0] = $steps[1]
|
|
$steps[1] = $first
|
|
$document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put.'x-candidate-steps' = $steps
|
|
} 'PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'operationId keyword casing' $noSetup {
|
|
param($document)
|
|
$operation = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get
|
|
Rename-Property $operation 'operationId' 'OperationId'
|
|
} 'unowned object keyword: OperationId|missing required object keyword: operationId'
|
|
|
|
Assert-MutationRejected 'schema type keyword casing' $noSetup {
|
|
param($document)
|
|
Rename-Property $document.components.schemas.GenealogyId 'type' 'Type'
|
|
} 'GenealogyId contains an unowned schema keyword: Type'
|
|
|
|
Assert-MutationRejected 'schema type value casing' $noSetup {
|
|
param($document)
|
|
$document.components.schemas.GenealogyId.type = 'String'
|
|
} 'GenealogyId must be a non-null 1\.\.128 lexical opaque identifier'
|
|
|
|
Assert-MutationRejected 'top-level webhooks in 3.0.1' $noSetup {
|
|
param($document)
|
|
Set-Property $document 'webhooks' ([pscustomobject]@{})
|
|
} 'must not define top-level webhooks'
|
|
|
|
Assert-MutationRejected 'component callback owner' $noSetup {
|
|
param($document)
|
|
Set-Property $document.components.callbacks 'PoemCallback' ([pscustomobject]@{
|
|
'{$request.body#/callbackUrl}' = [pscustomobject]@{
|
|
post = [pscustomobject]@{
|
|
requestBody = [pscustomobject]@{
|
|
required = $true
|
|
content = [pscustomobject]@{
|
|
'application/json' = [pscustomobject]@{
|
|
schema = [pscustomobject]@{ '$ref' = '#/components/schemas/AppGenerationPoemSetUpdateBody' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
} 'unowned component callback exposes the G12 contract'
|
|
|
|
Assert-MutationRejected 'Header Object content conflict' $noSetup {
|
|
param($document)
|
|
Set-Property $document.components.headers.PrivateNoStore 'content' ([pscustomobject]@{})
|
|
} 'PrivateNoStore header owner contains an unowned object keyword: content'
|
|
|
|
Assert-MutationRejected 'gateway CORS policy drift' $noSetup {
|
|
param($document)
|
|
$document.'x-app-gateway-policies'.APP_GATEWAY_PREFLIGHT.allowedRequestHeaders = @('Authorization', 'Content-Type', 'clientid')
|
|
} 'APP_GATEWAY_PREFLIGHT must own the exact G12 browser preflight policy'
|
|
|
|
Assert-MutationRejected 'GET CORS header loss' $noSetup {
|
|
param($document)
|
|
$document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get.'x-cors-required-request-headers' = @('Authorization')
|
|
} 'GET .*gateway CORS headers'
|
|
|
|
Assert-MutationRejected 'PUT candidate order drift' $noSetup {
|
|
param($document)
|
|
$operation = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put
|
|
$operation.'x-candidate-steps' = @('WRITE_ATOMICALLY')
|
|
} 'PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'PUT final capacity step missing' $noSetup {
|
|
param($document)
|
|
$operation = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put
|
|
$operation.'x-candidate-steps' = @($operation.'x-candidate-steps' | Where-Object { $_ -ne 'VALIDATE_FINAL_ACTIVE_CAPACITY' })
|
|
} 'PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'PUT allocated ID validation missing' $noSetup {
|
|
param($document)
|
|
$operation = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put
|
|
$operation.'x-candidate-steps' = @($operation.'x-candidate-steps' | Where-Object { $_ -ne 'VALIDATE_ALLOCATED_IDS_NONEMPTY_UNIQUE_NOT_IN_BASELINE' })
|
|
} 'PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'PUT unknown target omits disableMissing' $noSetup {
|
|
param($document)
|
|
$operation = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put
|
|
$operation.'x-unknown-target-construction-fields' = @('genealogyId', 'declaredItems')
|
|
} 'PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'PUT unknown NEW ID policy drift' $noSetup {
|
|
param($document)
|
|
$document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put.'x-unknown-new-id-policy' = 'ALLOW_BASELINE_REUSE'
|
|
} 'PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'PUT unknown set equality drift' $noSetup {
|
|
param($document)
|
|
$document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put.'x-unknown-set-equality' = 'ALLOW_EXTRA_ROWS'
|
|
} 'PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'PUT unknown overlap precedence drift' $noSetup {
|
|
param($document)
|
|
$document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put.'x-unknown-overlap-policy' = 'ATTRIBUTE_TO_REQUEST'
|
|
} 'PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
|
|
Assert-MutationRejected 'unowned operation extension' $noSetup {
|
|
param($document)
|
|
$operation = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get
|
|
Set-Property $operation 'x-conflict-policy' 'LAST_WINS'
|
|
} 'unowned semantic extension: x-conflict-policy'
|
|
|
|
Assert-MutationRejected 'bad 409 scalar' {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'GenerationPoemSetVersionConflict' ([pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('code', 'businessCode', 'message', 'current')
|
|
properties = [pscustomobject]@{
|
|
code = [pscustomobject]@{ type = 'integer'; enum = @(409); nullable = $false }
|
|
businessCode = [pscustomobject]@{ type = 'string'; enum = @('POEM_SET_VERSION_CONFLICT'); nullable = $false }
|
|
message = [pscustomobject]@{ type = 'string'; minLength = 1; maxLength = 200; nullable = $false }
|
|
current = [pscustomobject]@{ '$ref' = '#/components/schemas/GenerationPoemSetSnapshot' }
|
|
}
|
|
})
|
|
} {
|
|
param($document)
|
|
$document.components.schemas.GenerationPoemSetVersionConflict.properties.code.type = 'string'
|
|
} 'GenerationPoemSetVersionConflict.code must be a non-null integer scalar'
|
|
|
|
Assert-MutationRejected 'nullable 409 current ref sibling' $noSetup {
|
|
param($document)
|
|
$current = $document.components.schemas.GenerationPoemSetVersionConflict.properties.current
|
|
Set-Property $current 'nullable' $true
|
|
} 'GenerationPoemSetVersionConflict.current must be the sole exact local schema ref'
|
|
|
|
Assert-MutationRejected 'extra discriminator mapping' {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'RGenerationPoemSetConflict' ([pscustomobject]@{
|
|
oneOf = @(
|
|
[pscustomobject]@{ '$ref' = '#/components/schemas/GenerationPoemSetVersionConflict' },
|
|
[pscustomobject]@{ '$ref' = '#/components/schemas/GenerationPoemGenealogyNotReadyConflict' }
|
|
)
|
|
discriminator = [pscustomobject]@{
|
|
propertyName = 'businessCode'
|
|
mapping = [pscustomobject]@{
|
|
POEM_SET_VERSION_CONFLICT = '#/components/schemas/GenerationPoemSetVersionConflict'
|
|
GENEALOGY_NOT_READY = '#/components/schemas/GenerationPoemGenealogyNotReadyConflict'
|
|
}
|
|
}
|
|
'x-conflict-precedence' = @('VERSION', 'READY')
|
|
})
|
|
} {
|
|
param($document)
|
|
Set-Property $document.components.schemas.RGenerationPoemSetConflict.discriminator.mapping 'EVIL' 'https://evil.test/Foo'
|
|
} 'discriminator mapping keys must be exactly'
|
|
|
|
Assert-MutationRejected 'unreachable 422 soft-disable confirmation code' {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'RGenerationPoemUnprocessable' ([pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('code', 'businessCode', 'message', 'fieldErrors')
|
|
properties = [pscustomobject]@{
|
|
code = [pscustomobject]@{ type = 'integer'; enum = @(422); nullable = $false }
|
|
businessCode = [pscustomobject]@{
|
|
type = 'string'; nullable = $false
|
|
enum = @('DUPLICATE_GENERATION_NO', 'DUPLICATE_POEM_ID', 'GENERATION_POEM_CAPACITY_EXCEEDED', 'GENERATION_POEM_REQUEST_ORDER_INVALID', 'GENERATION_POEM_TEXT_INVALID', 'GENERATION_SLOT_CONFLICT', 'NON_CONTIGUOUS_GENERATIONS', 'POEM_ROW_NOT_AVAILABLE')
|
|
}
|
|
message = [pscustomobject]@{ type = 'string'; minLength = 1; maxLength = 200; nullable = $false }
|
|
fieldErrors = [pscustomobject]@{
|
|
type = 'array'; minItems = 0; maxItems = 500; nullable = $false
|
|
items = [pscustomobject]@{ '$ref' = '#/components/schemas/GenerationPoemFieldError' }
|
|
}
|
|
}
|
|
})
|
|
} {
|
|
param($document)
|
|
$codes = @($document.components.schemas.RGenerationPoemUnprocessable.properties.businessCode.enum)
|
|
$document.components.schemas.RGenerationPoemUnprocessable.properties.businessCode.enum = @($codes + 'SOFT_DISABLE_CONFIRMATION_REQUIRED')
|
|
} 'RGenerationPoemUnprocessable.businessCode must be a non-null string enum exactly'
|
|
|
|
Assert-MutationRejected 'nullable 422 businessCode' $noSetup {
|
|
param($document)
|
|
$document.components.schemas.RGenerationPoemUnprocessable.properties.businessCode.nullable = $true
|
|
} 'RGenerationPoemUnprocessable.businessCode must be a non-null string enum exactly'
|
|
|
|
Assert-MutationRejected '422 fieldErrors union wrapper' $noSetup {
|
|
param($document)
|
|
Set-Property $document.components.schemas.RGenerationPoemUnprocessable.properties.fieldErrors 'oneOf' @([pscustomobject]@{})
|
|
} 'RGenerationPoemUnprocessable.fieldErrors must not define conflicting schema keyword: oneOf'
|
|
|
|
Assert-MutationRejected 'unowned body extension' {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'AppGenerationPoemSetUpdateBody' ([pscustomobject]@{
|
|
type = 'object'; nullable = $false; additionalProperties = $false
|
|
required = @('items', 'disableMissing')
|
|
properties = [pscustomobject]@{
|
|
items = [pscustomobject]@{ type = 'array'; minItems = 0; maxItems = 500; nullable = $false; items = [pscustomobject]@{ '$ref' = '#/components/schemas/AppGenerationPoemSetItem' } }
|
|
disableMissing = [pscustomobject]@{ type = 'boolean'; nullable = $false }
|
|
}
|
|
'x-request-order' = 'STRICT_GENERATION_NO_ASC_NO_SERVER_SORT'
|
|
'x-false-policy' = 'PRESERVE_ALL_UNREPRESENTED_BASELINE_ACTIVE'
|
|
'x-true-policy' = 'SOFT_DISABLE_ALL_UNREPRESENTED_BASELINE_ACTIVE'
|
|
'x-empty-false-policy' = 'SEMANTIC_NOOP'
|
|
'x-empty-true-policy' = 'SOFT_DISABLE_ALL_ACTIVE'
|
|
'x-final-active-invariant' = 'EMPTY_OR_CONTIGUOUS_INTERVAL'
|
|
'x-candidate-validation' = 'BEFORE_ANY_DOMAIN_WRITE'
|
|
'x-duplicate-generation-text' = 'ALLOWED'
|
|
})
|
|
} {
|
|
param($document)
|
|
Set-Property $document.components.schemas.AppGenerationPoemSetUpdateBody 'x-server-sort' $true
|
|
} 'AppGenerationPoemSetUpdateBody contains an unowned semantic extension: x-server-sort'
|
|
|
|
Assert-MutationRejected 'Unicode pattern drift' {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'GenerationPoemText' ([pscustomobject]@{
|
|
type = 'string'; minLength = 1; maxLength = 50; nullable = $false
|
|
pattern = '^(?!.*[\u0000-\u001F\u007F-\u009F\u061C\u200B-\u200F\u2028-\u202E\u2060\u2066-\u2069\uFEFF])\S(?:[\s\S]*\S)?$'
|
|
'x-normalization' = 'NFC'
|
|
'x-length-unit' = 'UNICODE_CODE_POINT'
|
|
'x-well-formed-unicode' = $true
|
|
'x-boundary-whitespace' = 'REJECT'
|
|
'x-forbidden-code-point-classes' = @('C0', 'C1', 'CR', 'LF', 'TAB', 'ARABIC_LETTER_MARK', 'LTR_RTL_MARK', 'BIDI_OVERRIDE_OR_ISOLATE', 'WORD_JOINER', 'ZERO_WIDTH_OR_BOM', 'LINE_PARAGRAPH_SEPARATOR', 'UNPAIRED_SURROGATE')
|
|
})
|
|
} {
|
|
param($document)
|
|
$document.components.schemas.GenerationPoemText.pattern = '^.*$'
|
|
} 'GenerationPoemText must be NFC'
|
|
|
|
Assert-MutationRejected 'int64 generation number' {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'GenerationPoemGenerationNo' ([pscustomobject]@{
|
|
type = 'integer'; format = 'int32'; minimum = 1; maximum = 2147483647; nullable = $false
|
|
})
|
|
} {
|
|
param($document)
|
|
$document.components.schemas.GenerationPoemGenerationNo.format = 'int64'
|
|
} 'GenerationPoemGenerationNo must be a non-null safe int32-range'
|
|
|
|
Assert-LocalBaselineAccepted 'legal int32 generation number' {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'GenerationPoemGenerationNo' ([pscustomobject]@{
|
|
type = 'integer'; format = 'int32'; minimum = 1; maximum = 2147483647; nullable = $false
|
|
})
|
|
} 'GenerationPoemGenerationNo must be a non-null safe int32-range|GenerationPoemGenerationNo contains'
|
|
|
|
Assert-LocalBaselineAccepted 'legal component requestBody ref' {
|
|
param($document)
|
|
Set-Property $document.components.requestBodies 'GenerationPoemUpdateRequest' ([pscustomobject]@{
|
|
required = $true
|
|
content = [pscustomobject]@{
|
|
'application/json' = [pscustomobject]@{ schema = [pscustomobject]@{ '$ref' = '#/components/schemas/AppGenerationPoemSetUpdateBody' } }
|
|
}
|
|
})
|
|
$document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.put.requestBody = [pscustomobject]@{
|
|
'$ref' = '#/components/requestBodies/GenerationPoemUpdateRequest'
|
|
}
|
|
} 'PUT generation-poem request body (must be required|must expose only|OpenAPI 3.0.1 Reference Object)'
|
|
|
|
Assert-LocalBaselineAccepted 'lineage generation projection is not a G12 owner' {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'LineagePersonView' ([pscustomobject]@{
|
|
type = 'object'
|
|
description = 'generation poem display text is a consumer annotation only'
|
|
properties = [pscustomobject]@{
|
|
personId = [pscustomobject]@{ type = 'string' }
|
|
generationNo = [pscustomobject]@{ type = 'integer' }
|
|
generationName = [pscustomobject]@{ type = 'string'; description = 'generation poem label' }
|
|
}
|
|
})
|
|
Set-Property $document.components.schemas 'LineagePersonBody' ([pscustomobject]@{
|
|
type = 'object'
|
|
properties = [pscustomobject]@{
|
|
generationNo = [pscustomobject]@{ type = 'integer' }
|
|
generationName = [pscustomobject]@{ type = 'string'; description = 'generation poem label' }
|
|
}
|
|
})
|
|
$get = New-MinimalOperation 'getLineagePersonShadow' 'lineage consumer'
|
|
Set-Property $get.responses '200' (New-ApiResponse '#/components/schemas/LineagePersonView' $false)
|
|
$put = New-MinimalOperation 'updateLineagePersonShadow' 'lineage consumer'
|
|
Set-Property $put 'requestBody' ([pscustomobject]@{
|
|
required = $true
|
|
content = [pscustomobject]@{ 'application/json' = [pscustomobject]@{ schema = (New-Ref '#/components/schemas/LineagePersonBody') } }
|
|
})
|
|
Set-Property $document.paths '/genealogy/app/genealogies/{genealogyId}/lineage/person-shadow' ([pscustomobject]@{ get = $get; put = $put })
|
|
} 'generation-poem APP (mutation|canonical read).*lineage/person-shadow'
|
|
|
|
Assert-LocalBaselineAccepted 'schema annotations are not G12 owners' {
|
|
param($document)
|
|
Set-Property $document.components.schemas 'UnrelatedAnnotatedBody' ([pscustomobject]@{
|
|
type = 'object'
|
|
description = 'unrelated write schema'
|
|
example = [pscustomobject]@{ generationNo = 1; generationText = '示例' }
|
|
default = [pscustomobject]@{ generationNo = 2; generationText = '默认' }
|
|
properties = [pscustomobject]@{
|
|
unrelatedValue = [pscustomobject]@{ type = 'string' }
|
|
}
|
|
})
|
|
$operation = New-MinimalOperation 'updateUnrelatedAnnotatedBody' 'unrelated write'
|
|
Set-Property $operation 'parameters' @([pscustomobject]@{
|
|
name = 'sample'; in = 'query'; required = $false
|
|
schema = [pscustomobject]@{ type = 'string' }
|
|
example = [pscustomobject]@{ generationNo = 3; generationText = '参数示例' }
|
|
})
|
|
Set-Property $operation 'requestBody' ([pscustomobject]@{
|
|
required = $true
|
|
content = [pscustomobject]@{
|
|
'application/json' = [pscustomobject]@{
|
|
schema = (New-Ref '#/components/schemas/UnrelatedAnnotatedBody')
|
|
examples = [pscustomobject]@{
|
|
sample = [pscustomobject]@{ value = [pscustomobject]@{ generationNo = 4; generationText = '媒体示例' } }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
Set-Property $document.paths '/genealogy/app/unrelated-annotated-write' ([pscustomobject]@{ put = $operation })
|
|
} 'generation-poem APP mutation owners.*unrelated-annotated-write'
|
|
|
|
$unicodePattern = '^(?!.*[\u0000-\u001F\u007F-\u009F\u061C\u200B-\u200F\u2028-\u202E\u2060\u2066-\u2069\uFEFF])\S(?:[\s\S]*\S)?$'
|
|
$hanDe = [string][char]0x5FB7
|
|
$hanCheng = [string][char]0x627F
|
|
$validPair = "$hanDe$hanCheng"
|
|
foreach ($sample in @(" $validPair", "$validPair ", "$hanDe`t$hanCheng", "$hanDe`r$hanCheng", "$hanDe`n$hanCheng", "$hanDe$([char]0x0085)$hanCheng", "$hanDe$([char]0x061C)$hanCheng", "$hanDe$([char]0x200B)$hanCheng", "$hanDe$([char]0x200E)$hanCheng", "$hanDe$([char]0x2028)$hanCheng", "$hanDe$([char]0x202E)$hanCheng", "$hanDe$([char]0x2060)$hanCheng", "$hanDe$([char]0x2066)$hanCheng", "$hanDe$([char]0xFEFF)$hanCheng")) {
|
|
if ([regex]::IsMatch($sample, $unicodePattern)) {
|
|
throw "Unicode regex accepted a forbidden sample: $([BitConverter]::ToString([Text.Encoding]::Unicode.GetBytes($sample)))"
|
|
}
|
|
}
|
|
$supplementary = [char]::ConvertFromUtf32(0x20000)
|
|
foreach ($sample in @($validPair, "$hanDe$hanDe", "$supplementary$hanCheng")) {
|
|
if (-not [regex]::IsMatch($sample, $unicodePattern)) {
|
|
throw "Unicode regex rejected a valid sample: $sample"
|
|
}
|
|
}
|
|
|
|
$unicodeRuntimePath = Join-Path $PSScriptRoot 'g12-generation-poem-unicode-contract-runtime-smoke.js'
|
|
$unicodeRuntimeOutput = @(& node $unicodeRuntimePath 2>&1)
|
|
if ($LASTEXITCODE -ne 0 -or 'G12-GENERATION-POEM-UNICODE-RUNTIME-SMOKE PASS' -notin $unicodeRuntimeOutput) {
|
|
throw "G12 ECMAScript Unicode contract smoke failed: $($unicodeRuntimeOutput -join ' | ')"
|
|
}
|
|
|
|
Write-Output 'G12-GENERATION-POEM-OPENAPI-ADVERSARIAL-CONTRACT PASS'
|