完成40%
This commit is contained in:
@@ -0,0 +1,990 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$contractPath = Join-Path $PSScriptRoot 'family-feed-read-openapi-contract.ps1'
|
||||
$temporaryPath = [System.IO.Path]::GetTempFileName()
|
||||
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
|
||||
$cursorPattern = '^[A-Za-z0-9_-]{1,512}$'
|
||||
|
||||
function New-Ref([string]$Ref) {
|
||||
return [ordered]@{ '$ref' = $Ref }
|
||||
}
|
||||
|
||||
function New-StringOwner(
|
||||
[int]$MinLength,
|
||||
[int]$MaxLength,
|
||||
[string]$Pattern = ''
|
||||
) {
|
||||
$schema = [ordered]@{
|
||||
type = 'string'
|
||||
minLength = $MinLength
|
||||
maxLength = $MaxLength
|
||||
nullable = $false
|
||||
}
|
||||
if ($Pattern) { $schema.pattern = $Pattern }
|
||||
return $schema
|
||||
}
|
||||
|
||||
function New-ClosedObject(
|
||||
[System.Collections.Specialized.OrderedDictionary]$Properties,
|
||||
[string[]]$Required
|
||||
) {
|
||||
return [ordered]@{
|
||||
type = 'object'
|
||||
properties = $Properties
|
||||
required = $Required
|
||||
additionalProperties = $false
|
||||
nullable = $false
|
||||
}
|
||||
}
|
||||
|
||||
function New-SuccessEnvelope([string]$DataRef) {
|
||||
return New-ClosedObject ([ordered]@{
|
||||
code = [ordered]@{
|
||||
type = 'integer'
|
||||
enum = @(200)
|
||||
nullable = $false
|
||||
}
|
||||
data = New-Ref $DataRef
|
||||
}) @('code', 'data')
|
||||
}
|
||||
|
||||
function New-ErrorEnvelope([int]$Status, [string[]]$BusinessCodes) {
|
||||
return New-ClosedObject ([ordered]@{
|
||||
businessCode = [ordered]@{
|
||||
type = 'string'
|
||||
enum = $BusinessCodes
|
||||
nullable = $false
|
||||
}
|
||||
code = [ordered]@{
|
||||
type = 'integer'
|
||||
enum = @($Status)
|
||||
nullable = $false
|
||||
}
|
||||
message = [ordered]@{
|
||||
type = 'string'
|
||||
minLength = 1
|
||||
maxLength = 200
|
||||
nullable = $false
|
||||
}
|
||||
}) @('businessCode', 'code', 'message')
|
||||
}
|
||||
|
||||
function New-Response([string]$SchemaRef, [bool]$RateLimited = $false) {
|
||||
$headers = [ordered]@{
|
||||
'Cache-Control' = New-Ref '#/components/headers/PrivateNoStore'
|
||||
}
|
||||
if ($RateLimited) {
|
||||
$headers.'Retry-After' = New-Ref '#/components/headers/RetryAfter'
|
||||
}
|
||||
return [ordered]@{
|
||||
description = 'typed response'
|
||||
headers = $headers
|
||||
content = [ordered]@{
|
||||
'application/json' = [ordered]@{
|
||||
schema = New-Ref $SchemaRef
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-Responses([string]$SuccessRef) {
|
||||
return [ordered]@{
|
||||
'200' = New-Response $SuccessRef
|
||||
'400' = New-Response '#/components/schemas/RFamilyFeedReadBadRequest'
|
||||
'401' = New-Response '#/components/schemas/RFamilyFeedReadUnauthorized'
|
||||
'404' = New-Response '#/components/schemas/RFamilyFeedReadNotFound'
|
||||
'429' = New-Response '#/components/schemas/RFamilyFeedReadRateLimited' $true
|
||||
'500' = New-Response '#/components/schemas/RFamilyFeedReadUnavailable'
|
||||
}
|
||||
}
|
||||
|
||||
function New-ClientIdParameter {
|
||||
return [ordered]@{
|
||||
name = 'clientid'
|
||||
in = 'header'
|
||||
required = $true
|
||||
schema = [ordered]@{
|
||||
type = 'string'
|
||||
minLength = 1
|
||||
maxLength = 128
|
||||
nullable = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-IdParameter([string]$Name, [string]$SchemaRef) {
|
||||
return [ordered]@{
|
||||
name = $Name
|
||||
in = 'path'
|
||||
required = $true
|
||||
schema = New-Ref $SchemaRef
|
||||
}
|
||||
}
|
||||
|
||||
function New-CursorParameter([string]$SchemaRef) {
|
||||
return [ordered]@{
|
||||
name = 'cursor'
|
||||
in = 'query'
|
||||
required = $false
|
||||
schema = New-Ref $SchemaRef
|
||||
}
|
||||
}
|
||||
|
||||
function New-LimitParameter {
|
||||
return [ordered]@{
|
||||
name = 'limit'
|
||||
in = 'query'
|
||||
required = $false
|
||||
schema = [ordered]@{
|
||||
type = 'integer'
|
||||
minimum = 1
|
||||
maximum = 50
|
||||
default = 20
|
||||
nullable = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-ReadOperation(
|
||||
[string]$OperationId,
|
||||
[object[]]$Parameters,
|
||||
[string]$SuccessRef,
|
||||
[string[]]$AuthorizationScope
|
||||
) {
|
||||
return [ordered]@{
|
||||
operationId = $OperationId
|
||||
parameters = $Parameters
|
||||
responses = New-Responses $SuccessRef
|
||||
security = @([ordered]@{ SaToken = @() })
|
||||
'x-read-only' = $true
|
||||
'x-non-disclosing-not-found' = $true
|
||||
'x-authorize-every-request' = $true
|
||||
'x-cors-policy-owner' = 'APP_GATEWAY_PREFLIGHT'
|
||||
'x-authorization-scope' = $AuthorizationScope
|
||||
}
|
||||
}
|
||||
|
||||
function Add-CursorContract(
|
||||
[System.Collections.Specialized.OrderedDictionary]$Operation,
|
||||
[string[]]$Scope,
|
||||
[string[]]$Order
|
||||
) {
|
||||
$Operation.'x-cursor-scope' = $Scope
|
||||
$Operation.'x-cursor-order' = $Order
|
||||
$Operation.'x-read-window' = 'UPPER_BOUND_KEYSET_LATEST_VISIBLE'
|
||||
$Operation.'x-cursor-no-total' = $true
|
||||
$Operation.'x-refresh-discards-cursor' = $true
|
||||
$Operation.'x-authorize-every-page' = $true
|
||||
$Operation.'x-invalid-or-expired-cursor' = '400_FAMILY_FEED_CURSOR_INVALID'
|
||||
$Operation.'x-cross-scope-cursor' = '404_FAMILY_FEED_NOT_AVAILABLE'
|
||||
}
|
||||
|
||||
function New-CursorPage(
|
||||
[string]$ItemRef,
|
||||
[string]$CursorRef,
|
||||
[string[]]$Order
|
||||
) {
|
||||
$page = New-ClosedObject ([ordered]@{
|
||||
items = [ordered]@{
|
||||
type = 'array'
|
||||
items = New-Ref $ItemRef
|
||||
minItems = 0
|
||||
maxItems = 50
|
||||
nullable = $false
|
||||
}
|
||||
nextCursor = New-Ref $CursorRef
|
||||
}) @('items')
|
||||
$page.'x-no-total' = $true
|
||||
$page.'x-next-cursor-absent-at-end' = $true
|
||||
$page.'x-order' = $Order
|
||||
$page.'x-read-window' = 'UPPER_BOUND_KEYSET_LATEST_VISIBLE'
|
||||
$page.'x-new-items-after-window' = 'EXCLUDED_UNTIL_REFRESH'
|
||||
$page.'x-deletion-or-visibility-change' = 'OMIT_ON_LATER_PAGE'
|
||||
$page.'x-edit-policy' = 'LATEST_VISIBLE_AT_PAGE_READ'
|
||||
return $page
|
||||
}
|
||||
|
||||
function New-ValidDocument {
|
||||
$genealogyId = New-StringOwner 1 128 $identifierPattern
|
||||
$feedId = New-StringOwner 1 128 $identifierPattern
|
||||
$feedId.'x-opaque' = $true
|
||||
$feedId.'x-client-semantics' = 'COMPARE_ONLY'
|
||||
$commentId = New-StringOwner 1 128 $identifierPattern
|
||||
$commentId.'x-opaque' = $true
|
||||
$commentId.'x-client-semantics' = 'COMPARE_ONLY'
|
||||
|
||||
$feedCursor = New-StringOwner 1 512 $cursorPattern
|
||||
$feedCursor.'x-opaque' = $true
|
||||
$feedCursor.'x-purpose' = 'FAMILY_FEED_PAGE'
|
||||
$commentCursor = New-StringOwner 1 512 $cursorPattern
|
||||
$commentCursor.'x-opaque' = $true
|
||||
$commentCursor.'x-purpose' = 'FAMILY_FEED_ROOT_COMMENT_PAGE'
|
||||
|
||||
$feedContent = New-StringOwner 1 300
|
||||
$feedContent.'x-text-normalizer' = 'FAMILY_FEED_TEXT_V1'
|
||||
$feedContent.'x-length-unit' = 'UNICODE_CODE_POINT'
|
||||
$commentContent = New-StringOwner 1 1000
|
||||
$commentContent.'x-text-normalizer' = 'FAMILY_FEED_COMMENT_TEXT_V1'
|
||||
$commentContent.'x-length-unit' = 'UNICODE_CODE_POINT'
|
||||
$displayName = New-StringOwner 1 100
|
||||
$displayName.'x-projection' = 'AUTHORIZED_DISPLAY_NAME_ONLY'
|
||||
$displayName.'x-missing-author-policy' = 'NON_EMPTY_SERVER_FALLBACK'
|
||||
$publishedAt = [ordered]@{
|
||||
type = 'string'
|
||||
format = 'date-time'
|
||||
nullable = $false
|
||||
'x-server-generated' = $true
|
||||
'x-immutable' = $true
|
||||
}
|
||||
|
||||
$feedItem = New-ClosedObject ([ordered]@{
|
||||
authorDisplayName = New-Ref '#/components/schemas/FamilyFeedAuthorDisplayName'
|
||||
feedContent = New-Ref '#/components/schemas/FamilyFeedContent'
|
||||
feedId = New-Ref '#/components/schemas/FamilyFeedId'
|
||||
hasMedia = [ordered]@{
|
||||
type = 'boolean'
|
||||
nullable = $false
|
||||
}
|
||||
publishedAt = New-Ref '#/components/schemas/FamilyFeedPublishedAt'
|
||||
}) @('authorDisplayName', 'feedContent', 'feedId', 'hasMedia', 'publishedAt')
|
||||
$feedItem.description = 'Annotations may mention phone or audit examples without becoming response fields.'
|
||||
$feedItem.example = [ordered]@{ annotationOnly = 'appUserPhone is not a schema property' }
|
||||
$feedItem.'x-projection' = 'VISIBLE_FEED_PRESENTATION_ONLY'
|
||||
$feedItem.'x-media-policy' = 'HAS_MEDIA_REQUIRES_HONEST_CLIENT_PLACEHOLDER_UNTIL_MEDIA_READ_CONTRACT'
|
||||
|
||||
$commentItem = New-ClosedObject ([ordered]@{
|
||||
authorDisplayName = New-Ref '#/components/schemas/FamilyFeedAuthorDisplayName'
|
||||
commentContent = New-Ref '#/components/schemas/FamilyFeedCommentContent'
|
||||
commentId = New-Ref '#/components/schemas/FamilyFeedCommentId'
|
||||
publishedAt = New-Ref '#/components/schemas/FamilyFeedPublishedAt'
|
||||
}) @('authorDisplayName', 'commentContent', 'commentId', 'publishedAt')
|
||||
$commentItem.'x-projection' = 'VISIBLE_COMMENT_PRESENTATION_ONLY'
|
||||
$commentItem.'x-comment-level' = 'ROOT_ONLY'
|
||||
$commentItem.'x-deleted-placeholder-policy' = 'EXCLUDE'
|
||||
|
||||
$feedOrder = @('publishedAt:DESC', 'feedId:DESC_ORDINAL')
|
||||
$commentOrder = @('publishedAt:ASC', 'commentId:ASC_ORDINAL')
|
||||
|
||||
$feedList = New-ReadOperation 'appListFamilyFeeds' @(
|
||||
(New-ClientIdParameter),
|
||||
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
|
||||
(New-CursorParameter '#/components/schemas/FamilyFeedCursor'),
|
||||
(New-LimitParameter)
|
||||
) '#/components/schemas/RAppFamilyFeedCursorPage' @('tenant', 'genealogy', 'membership')
|
||||
Add-CursorContract $feedList @(
|
||||
'tenant', 'account', 'authSession', 'client', 'genealogyId', 'projection',
|
||||
'order', 'limit', 'windowUpperBound', 'lastTuple'
|
||||
) $feedOrder
|
||||
|
||||
$feedDetail = New-ReadOperation 'appGetFamilyFeed' @(
|
||||
(New-ClientIdParameter),
|
||||
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
|
||||
(New-IdParameter 'feedId' '#/components/schemas/FamilyFeedId')
|
||||
) '#/components/schemas/RAppFamilyFeedReadItem' @(
|
||||
'tenant', 'genealogy', 'membership', 'feedBelongsToGenealogy', 'feedVisibility'
|
||||
)
|
||||
|
||||
$comments = New-ReadOperation 'appListFamilyFeedRootComments' @(
|
||||
(New-ClientIdParameter),
|
||||
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
|
||||
(New-IdParameter 'feedId' '#/components/schemas/FamilyFeedId'),
|
||||
(New-CursorParameter '#/components/schemas/FamilyFeedRootCommentCursor'),
|
||||
(New-LimitParameter)
|
||||
) '#/components/schemas/RAppFamilyFeedRootCommentCursorPage' @(
|
||||
'tenant', 'genealogy', 'membership', 'feedBelongsToGenealogy', 'feedVisibility'
|
||||
)
|
||||
Add-CursorContract $comments @(
|
||||
'tenant', 'account', 'authSession', 'client', 'genealogyId', 'feedId',
|
||||
'projection', 'order', 'limit', 'windowUpperBound', 'lastTuple'
|
||||
) $commentOrder
|
||||
|
||||
$schemas = [ordered]@{
|
||||
GenealogyId = $genealogyId
|
||||
FamilyFeedId = $feedId
|
||||
FamilyFeedCommentId = $commentId
|
||||
FamilyFeedCursor = $feedCursor
|
||||
FamilyFeedRootCommentCursor = $commentCursor
|
||||
FamilyFeedContent = $feedContent
|
||||
FamilyFeedCommentContent = $commentContent
|
||||
FamilyFeedAuthorDisplayName = $displayName
|
||||
FamilyFeedPublishedAt = $publishedAt
|
||||
AppFamilyFeedReadItem = $feedItem
|
||||
AppFamilyFeedRootCommentReadItem = $commentItem
|
||||
AppFamilyFeedCursorPage = New-CursorPage '#/components/schemas/AppFamilyFeedReadItem' '#/components/schemas/FamilyFeedCursor' $feedOrder
|
||||
AppFamilyFeedRootCommentCursorPage = New-CursorPage '#/components/schemas/AppFamilyFeedRootCommentReadItem' '#/components/schemas/FamilyFeedRootCommentCursor' $commentOrder
|
||||
RAppFamilyFeedCursorPage = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedCursorPage'
|
||||
RAppFamilyFeedReadItem = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedReadItem'
|
||||
RAppFamilyFeedRootCommentCursorPage = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedRootCommentCursorPage'
|
||||
RFamilyFeedReadBadRequest = New-ErrorEnvelope 400 @('FAMILY_FEED_CURSOR_INVALID', 'FAMILY_FEED_QUERY_INVALID')
|
||||
RFamilyFeedReadUnauthorized = New-ErrorEnvelope 401 @('AUTH_REQUIRED')
|
||||
RFamilyFeedReadNotFound = New-ErrorEnvelope 404 @('FAMILY_FEED_NOT_AVAILABLE')
|
||||
RFamilyFeedReadRateLimited = New-ErrorEnvelope 429 @('RATE_LIMITED')
|
||||
RFamilyFeedReadUnavailable = New-ErrorEnvelope 500 @('FAMILY_FEED_READ_UNAVAILABLE')
|
||||
}
|
||||
|
||||
$document = [ordered]@{
|
||||
openapi = '3.0.1'
|
||||
info = [ordered]@{
|
||||
title = 'family feed read adversarial fixture'
|
||||
version = '1'
|
||||
}
|
||||
paths = [ordered]@{
|
||||
'/genealogy/app/genealogies/{genealogyId}/feeds' = [ordered]@{
|
||||
get = $feedList
|
||||
}
|
||||
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}' = [ordered]@{
|
||||
get = $feedDetail
|
||||
}
|
||||
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments' = [ordered]@{
|
||||
get = $comments
|
||||
}
|
||||
}
|
||||
components = [ordered]@{
|
||||
schemas = $schemas
|
||||
headers = [ordered]@{
|
||||
PrivateNoStore = [ordered]@{
|
||||
schema = [ordered]@{
|
||||
type = 'string'
|
||||
enum = @('private, no-store')
|
||||
nullable = $false
|
||||
}
|
||||
}
|
||||
RetryAfter = [ordered]@{
|
||||
schema = [ordered]@{
|
||||
type = 'integer'
|
||||
minimum = 1
|
||||
maximum = 300
|
||||
nullable = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
securitySchemes = [ordered]@{
|
||||
SaToken = [ordered]@{
|
||||
type = 'apiKey'
|
||||
in = 'header'
|
||||
name = 'Authorization'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ($document | ConvertTo-Json -Depth 100 | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function Copy-Document([object]$Document) {
|
||||
return ($Document | ConvertTo-Json -Depth 100 | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function Get-ContractIssues([object]$Document) {
|
||||
$json = $Document | ConvertTo-Json -Depth 100
|
||||
[System.IO.File]::WriteAllText($temporaryPath, $json, [System.Text.UTF8Encoding]::new($false))
|
||||
$output = @(
|
||||
& $contractPath -SkipProtectedParity -ReturnIssues -DocumentPath $temporaryPath
|
||||
)
|
||||
return @($output | Where-Object { $_ -is [string] -and $_.Length -gt 0 })
|
||||
}
|
||||
|
||||
function Rename-NoteProperty([object]$Owner, [string]$From, [string]$To) {
|
||||
$property = @($Owner.PSObject.Properties | Where-Object { $_.Name -ceq $From })[0]
|
||||
if (-not $property) { throw "mutation setup missing property: $From" }
|
||||
$value = $property.Value
|
||||
$Owner.PSObject.Properties.Remove($From)
|
||||
$Owner.PSObject.Properties.Add([System.Management.Automation.PSNoteProperty]::new($To, $value))
|
||||
}
|
||||
|
||||
function Add-NoteProperty([object]$Owner, [string]$Name, [object]$Value) {
|
||||
$Owner.PSObject.Properties.Add([System.Management.Automation.PSNoteProperty]::new($Name, $Value))
|
||||
}
|
||||
|
||||
function Assert-MutantRejected(
|
||||
[object]$Seed,
|
||||
[string]$Label,
|
||||
[scriptblock]$Mutate,
|
||||
[string]$ExpectedIssuePattern
|
||||
) {
|
||||
$mutant = Copy-Document $Seed
|
||||
& $Mutate $mutant
|
||||
$mutantIssues = @(Get-ContractIssues $mutant)
|
||||
if ($mutantIssues.Count -eq 0) {
|
||||
throw "adversarial mutant fake-greened: $Label"
|
||||
}
|
||||
if ($ExpectedIssuePattern -and -not (($mutantIssues -join "`n") -match $ExpectedIssuePattern)) {
|
||||
throw "adversarial mutant rejected for the wrong reason: $Label`n$($mutantIssues -join "`n")"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$seed = New-ValidDocument
|
||||
$seedIssues = @(Get-ContractIssues $seed)
|
||||
if ($seedIssues.Count -gt 0) {
|
||||
throw "valid zero-issue seed was rejected:`n$($seedIssues -join "`n")"
|
||||
}
|
||||
|
||||
$mutations = @(
|
||||
@{
|
||||
Label = 'legacy /feeds/page GET owner'
|
||||
Pattern = 'legacy duplicate GET owner'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/genealogies/{genealogyId}/feeds/page' ([pscustomobject]@{
|
||||
get = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'operationId drift'
|
||||
Pattern = 'operationId must be appListFamilyFeeds'
|
||||
Apply = { param($doc) $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.operationId = 'list_17' }
|
||||
},
|
||||
@{
|
||||
Label = 'Path Item Get keyword casing'
|
||||
Pattern = 'Path Item keyword casing is invalid'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Rename-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds' 'get' 'Get'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'security object instead of array'
|
||||
Pattern = 'security must be a JSON array'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security = [pscustomobject]@{ SaToken = @() }
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'SaToken key casing'
|
||||
Pattern = 'must require only exact SaToken'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$requirement = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security[0]
|
||||
Rename-NoteProperty $requirement 'SaToken' 'satoken'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'non-empty SaToken scopes'
|
||||
Pattern = 'SaToken scopes must be an empty JSON array'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security[0].SaToken = @('feed:read')
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'int64 feed identity'
|
||||
Pattern = 'sole exact local ref #/components/schemas/FamilyFeedId'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.properties.feedId = [pscustomobject]@{ type = 'integer'; format = 'int64' }
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'open feed projection'
|
||||
Pattern = 'AppFamilyFeedReadItem must be closed'
|
||||
Apply = { param($doc) $doc.components.schemas.AppFamilyFeedReadItem.additionalProperties = $true }
|
||||
},
|
||||
@{
|
||||
Label = 'phone field leak'
|
||||
Pattern = 'success graph leaks forbidden/internal field'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.schemas.AppFamilyFeedReadItem.properties 'appUserPhone' ([pscustomobject]@{
|
||||
type = 'string'; nullable = $false
|
||||
})
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.required += 'appUserPhone'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'moderation field leak'
|
||||
Pattern = 'success graph leaks forbidden/internal field'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.schemas.AppFamilyFeedRootCommentReadItem.properties 'moderationReason' ([pscustomobject]@{
|
||||
type = 'string'; nullable = $false
|
||||
})
|
||||
$doc.components.schemas.AppFamilyFeedRootCommentReadItem.required += 'moderationReason'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'offset total in cursor page'
|
||||
Pattern = 'AppFamilyFeedCursorPage must be closed'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.schemas.AppFamilyFeedCursorPage.properties 'total' ([pscustomobject]@{
|
||||
type = 'integer'; nullable = $false
|
||||
})
|
||||
$doc.components.schemas.AppFamilyFeedCursorPage.required += 'total'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'pageNum query bypass'
|
||||
Pattern = 'parameters must be exactly'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$operation = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$operation.parameters += [pscustomobject]@{
|
||||
name = 'pageNum'; in = 'query'; required = $false
|
||||
schema = [pscustomobject]@{ type = 'integer'; minimum = 1; nullable = $false }
|
||||
}
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'cursor scope comma string'
|
||||
Pattern = 'x-cursor-scope must be the exact JSON array'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-scope' = 'tenant,account,authSession'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'cross-scope cursor returns 400'
|
||||
Pattern = 'cursor/refresh/per-page authorization semantics drifted'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cross-scope-cursor' = '400_FAMILY_FEED_CURSOR_INVALID'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = '403 resource existence split'
|
||||
Pattern = 'responses must be exactly'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}'.get.responses '403' (
|
||||
New-Response '#/components/schemas/RFamilyFeedReadNotFound'
|
||||
)
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'wildcard response media type'
|
||||
Pattern = 'must expose only application/json'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$response = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'
|
||||
Rename-NoteProperty $response.content 'application/json' '*/*'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'missing private cache header'
|
||||
Pattern = 'must define Cache-Control'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.headers.PSObject.Properties.Remove('Cache-Control')
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'unbounded Retry-After'
|
||||
Pattern = 'Retry-After must be a non-null integer in 1..300'
|
||||
Apply = { param($doc) $doc.components.headers.RetryAfter.schema.maximum = 301 }
|
||||
},
|
||||
@{
|
||||
Label = 'generic list response'
|
||||
Pattern = '200 schema ref must be #/components/schemas/RAppFamilyFeedCursorPage'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.content.'application/json'.schema.'$ref' = '#/components/schemas/RObject'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'external feedId ref'
|
||||
Pattern = 'must be the sole exact local ref'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.properties.feedId.'$ref' = 'https://example.invalid/schemas.json#/FamilyFeedId'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'hasMedia removed'
|
||||
Pattern = 'AppFamilyFeedReadItem must be closed'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.properties.PSObject.Properties.Remove('hasMedia')
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.required = @(
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.required | Where-Object { $_ -cne 'hasMedia' }
|
||||
)
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'feed cursor order drift'
|
||||
Pattern = 'x-cursor-order must be the exact JSON array'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-order' = @('feedId:DESC_ORDINAL', 'publishedAt:DESC')
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'mutable publishedAt'
|
||||
Pattern = 'must be a non-null immutable server-generated RFC3339'
|
||||
Apply = { param($doc) $doc.components.schemas.FamilyFeedPublishedAt.'x-immutable' = $false }
|
||||
},
|
||||
@{
|
||||
Label = 'feed content length drift'
|
||||
Pattern = 'FamilyFeedContent must be a non-null string length 1..300'
|
||||
Apply = { param($doc) $doc.components.schemas.FamilyFeedContent.maxLength = 301 }
|
||||
},
|
||||
@{
|
||||
Label = 'comment level internal field'
|
||||
Pattern = 'success graph leaks forbidden/internal field'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.schemas.AppFamilyFeedRootCommentReadItem.properties 'commentLevel' ([pscustomobject]@{
|
||||
type = 'string'; enum = @('root'); nullable = $false
|
||||
})
|
||||
$doc.components.schemas.AppFamilyFeedRootCommentReadItem.required += 'commentLevel'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'duplicate operationId'
|
||||
Pattern = 'operationId must be appListFamilyFeedRootComments|must have exactly one global operation owner'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments'.get.operationId = 'appListFamilyFeeds'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'explicit HEAD read bypass'
|
||||
Pattern = 'must not expose an explicit HEAD read bypass'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds' 'head' ([pscustomobject]@{
|
||||
responses = [pscustomobject]@{}
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'callback side channel'
|
||||
Pattern = 'must not define callbacks'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'callbacks' ([pscustomobject]@{
|
||||
leak = [pscustomobject]@{}
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'schema Type keyword casing'
|
||||
Pattern = 'contains an unowned schema keyword: Type'
|
||||
Apply = { param($doc) Rename-NoteProperty $doc.components.schemas.FamilyFeedContent 'type' 'Type' }
|
||||
},
|
||||
@{
|
||||
Label = 'nextCursor made required'
|
||||
Pattern = 'AppFamilyFeedCursorPage must be closed'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.components.schemas.AppFamilyFeedCursorPage.required += 'nextCursor'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'alternate APP GET reuses feed read projection'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowFamilyFeedRead'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Response Object links side channel'
|
||||
Pattern = 'contains an unowned Response Object keyword: links'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200' 'links' ([pscustomobject]@{
|
||||
next = [pscustomobject]@{ operationId = 'shadowFamilyFeedRead' }
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Header Object content side channel'
|
||||
Pattern = 'contains an unowned Header Object keyword: content'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.headers.PrivateNoStore 'content' ([pscustomobject]@{
|
||||
'application/json' = [pscustomobject]@{ schema = [pscustomobject]@{ type = 'string' } }
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'OpenAPI 3.1 dialect drift'
|
||||
Pattern = 'OpenAPI version must be exact 3.0.1'
|
||||
Apply = { param($doc) $doc.openapi = '3.1.0' }
|
||||
},
|
||||
@{
|
||||
Label = 'OpenAPI 3.1 webhooks keyword'
|
||||
Pattern = 'OpenAPI root contains an unowned keyword: webhooks'
|
||||
Apply = { param($doc) Add-NoteProperty $doc 'webhooks' ([pscustomobject]@{}) }
|
||||
},
|
||||
@{
|
||||
Label = 'OpenAPI Paths keyword casing'
|
||||
Pattern = 'OpenAPI root keyword casing is invalid: Paths'
|
||||
Apply = { param($doc) Rename-NoteProperty $doc 'paths' 'Paths' }
|
||||
},
|
||||
@{
|
||||
Label = 'OpenAPI Components keyword casing'
|
||||
Pattern = 'OpenAPI root keyword casing is invalid: Components'
|
||||
Apply = { param($doc) Rename-NoteProperty $doc 'components' 'Components' }
|
||||
},
|
||||
@{
|
||||
Label = 'cursor extension keyword casing'
|
||||
Pattern = 'operation keyword casing is invalid: X-Cursor-Scope'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Rename-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'x-cursor-scope' 'X-Cursor-Scope'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'cursor scope comma join collision'
|
||||
Pattern = 'x-cursor-scope must be the exact JSON array'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-scope' = @(
|
||||
'tenant,account', 'authSession', 'client', 'genealogyId', 'projection',
|
||||
'order', 'limit', 'windowUpperBound', 'lastTuple'
|
||||
)
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Parameter schema keyword casing'
|
||||
Pattern = 'Parameter Object keyword casing is invalid: Schema'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$parameter = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1]
|
||||
Rename-NoteProperty $parameter 'schema' 'Schema'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'path parameter case-shadow'
|
||||
Pattern = 'parameters must be exactly'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$pathItem = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'
|
||||
Add-NoteProperty $pathItem 'parameters' @(
|
||||
[pscustomobject]@{
|
||||
name = 'GenealogyId'
|
||||
in = 'path'
|
||||
required = $true
|
||||
schema = New-Ref '#/components/schemas/GenealogyId'
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Parameter Reference Object sibling'
|
||||
Pattern = 'parameter ref must contain only its exact local'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components 'parameters' ([pscustomobject]@{
|
||||
GenealogyIdParameter = [pscustomobject]@{
|
||||
name = 'genealogyId'
|
||||
in = 'path'
|
||||
required = $true
|
||||
schema = New-Ref '#/components/schemas/GenealogyId'
|
||||
}
|
||||
})
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1] = [pscustomobject]@{
|
||||
'$ref' = '#/components/parameters/GenealogyIdParameter'
|
||||
description = 'forbidden sibling'
|
||||
}
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Schema Reference Object sibling'
|
||||
Pattern = 'must be the sole exact local ref'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1].schema 'description' 'forbidden sibling'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'SaToken security scheme drift'
|
||||
Pattern = 'SaToken must be the exact apiKey/header/Authorization security owner'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.components.securitySchemes.SaToken = [pscustomobject]@{
|
||||
type = 'oauth2'
|
||||
flows = [pscustomobject]@{}
|
||||
}
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'alternate 206 owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowFamilyFeed206'
|
||||
$shadow.responses = [pscustomobject]@{
|
||||
'206' = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
|
||||
}
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-206' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'replies path reuses canonical projection'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowRepliesFamilyFeed'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/genealogies/{genealogyId}/replies-shadow' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'alternate HEAD projection owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowHeadFamilyFeed'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-head' ([pscustomobject]@{ head = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'alternate OPTIONS projection owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowOptionsFamilyFeed'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-options' ([pscustomobject]@{ options = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'callback GET projection owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowCallbackFamilyFeed'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/callback-carrier' ([pscustomobject]@{
|
||||
post = [pscustomobject]@{
|
||||
operationId = 'callbackCarrier'
|
||||
responses = [pscustomobject]@{
|
||||
'204' = [pscustomobject]@{ description = 'accepted' }
|
||||
}
|
||||
callbacks = [pscustomobject]@{
|
||||
leak = [pscustomobject]@{
|
||||
'{$request.body#/callbackUrl}' = [pscustomobject]@{ get = $shadow }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'inline alternate projection owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowInlineFamilyFeed'
|
||||
$shadow.responses.'200'.content.'application/json'.schema = Copy-Document $doc.components.schemas.RAppFamilyFeedCursorPage
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-inline' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'two-hop schema alias owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.schemas 'FamilyFeedAliasTwo' (New-Ref '#/components/schemas/RAppFamilyFeedCursorPage')
|
||||
Add-NoteProperty $doc.components.schemas 'FamilyFeedAliasOne' (New-Ref '#/components/schemas/FamilyFeedAliasTwo')
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowSchemaAliasFamilyFeed'
|
||||
$shadow.responses.'200'.content.'application/json'.schema = New-Ref '#/components/schemas/FamilyFeedAliasOne'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-schema-alias' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'two-hop response alias owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components 'responses' ([pscustomobject]@{
|
||||
FamilyFeedAliasOne = New-Ref '#/components/responses/FamilyFeedAliasTwo'
|
||||
FamilyFeedAliasTwo = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
|
||||
})
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowResponseAliasFamilyFeed'
|
||||
$shadow.responses = [pscustomobject]@{
|
||||
'200' = New-Ref '#/components/responses/FamilyFeedAliasOne'
|
||||
}
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-response-alias' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'external alternate response schema'
|
||||
Pattern = 'schema ref is not an inspectable exact local component ref'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowExternalFamilyFeed'
|
||||
$shadow.responses.'200'.content.'application/json'.schema = New-Ref 'https://example.invalid/feed.json#/FeedPage'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-external' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'default response projection owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowDefaultFamilyFeed'
|
||||
$shadow.responses = [pscustomobject]@{
|
||||
default = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
|
||||
}
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-default' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Response Reference Object sibling'
|
||||
Pattern = 'response ref must contain only its exact local'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components 'responses' ([pscustomobject]@{
|
||||
FeedListSuccess = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'
|
||||
})
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200' = [pscustomobject]@{
|
||||
'$ref' = '#/components/responses/FeedListSuccess'
|
||||
description = 'forbidden sibling'
|
||||
}
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Header Reference Object sibling'
|
||||
Pattern = 'header ref must contain only its exact local'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.headers.'Cache-Control' 'description' 'forbidden sibling'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Parameter content side channel'
|
||||
Pattern = 'Parameter Object contains an unowned keyword: content'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1] 'content' ([pscustomobject]@{
|
||||
'application/json' = [pscustomobject]@{
|
||||
schema = New-Ref '#/components/schemas/GenealogyId'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'unknown operation extension'
|
||||
Pattern = 'operation contains an unowned keyword: x-shadow-owner'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'x-shadow-owner' 'legacy'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($mutation in $mutations) {
|
||||
Assert-MutantRejected $seed $mutation.Label $mutation.Apply $mutation.Pattern
|
||||
}
|
||||
|
||||
Write-Output "FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=$($mutations.Count)"
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) {
|
||||
Remove-Item -LiteralPath $temporaryPath -Force
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user