Private/Document/Tests/ConvertTo-OpenApiDocumentModel.Tests.ps1
|
BeforeAll { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../../tcs.openapi.psd1') -Force $script:fixtures = Join-Path -Path $PSScriptRoot -ChildPath '../../../../../tests/Fixtures' $script:load = InModuleScope tcs.openapi { { param([string]$Path, [uri]$BaseUri) ConvertTo-OpenApiDocumentModel -Root (ConvertFrom-OpenApiJson -Text ([System.IO.File]::ReadAllText($Path))) -BaseUri $BaseUri } } $script:fromText = InModuleScope tcs.openapi { { param([string]$Json) ConvertTo-OpenApiDocumentModel -Root (ConvertFrom-OpenApiJson -Text $Json) } } } Describe 'ConvertTo-OpenApiDocumentModel' { Context 'Model shape' { It 'has the documented properties in order and PSTypeName' { $model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-petstore-3.0.json') $model.PSObject.TypeNames | Should -Contain 'Tcs.OpenApi.Document' @($model.PSObject.Properties.Name) | Should -Be @('SourceVersion', 'Title', 'Version', 'Description', 'ContactUrl', 'LicenseName', 'LicenseUrl', 'ExternalDocsUrl', 'Servers', 'SecuritySchemes', 'Security', 'Schemas', 'Operations', 'Findings') } It 'reads the contact, licence and externalDocs URLs' { $model = & $script:fromText '{"openapi":"3.0.3","info":{"title":"T","version":"1","contact":{"url":"https://example.com/support"},"license":{"name":"MIT","url":"https://example.com/license"}},"externalDocs":{"url":"https://docs.example.com"},"paths":{}}' $model.ContactUrl | Should -Be 'https://example.com/support' $model.LicenseName | Should -Be 'MIT' $model.LicenseUrl | Should -Be 'https://example.com/license' $model.ExternalDocsUrl | Should -Be 'https://docs.example.com' $model = & $script:fromText '{"openapi":"3.0.3","info":{"title":"T","version":"1"},"paths":{}}' $model.ContactUrl | Should -BeNullOrEmpty $model.ExternalDocsUrl | Should -BeNullOrEmpty } It 'round-trips through ConvertTo-Json' { $model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-petstore-3.0.json') $json = $model | ConvertTo-Json -Depth 100 -Compress $back = $json | ConvertFrom-Json $back.Title | Should -Be 'Petstore' $back.Operations.Count | Should -Be $model.Operations.Count $back.Schemas.Pet.Properties.category.RefName | Should -Be 'Category' } It 'uses full schemas at operations and reference stubs inside named schemas' { $model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-petstore-3.0.json') $body = $model.Operations[1].RequestBody.Content[0].Schema [object]::ReferenceEquals($body, $model.Schemas['Pet']) | Should -BeTrue $body.Properties['category'].RefName | Should -Be 'Category' $body.Properties['category'].Type | Should -Be 'object' $body.Properties['category'].Properties | Should -BeNullOrEmpty $model.Schemas['Category'].Properties.Count | Should -Be 2 $model.Schemas['PetList'].Properties['value'].Items.RefName | Should -Be 'Pet' } It 'serialises circular schemas without recursing forever' { $model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-circular.json') { $model | ConvertTo-Json -Depth 100 -Compress } | Should -Not -Throw } } Context 'Petstore 3.0' { BeforeAll { $script:model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-petstore-3.0.json') } It 'reads info' { $script:model.SourceVersion | Should -Be '3.0.3' $script:model.Title | Should -Be 'Petstore' $script:model.Version | Should -Be '1.2.0' $script:model.Description | Should -Not -BeNullOrEmpty } It 'reads servers and variables' { $script:model.Servers.Count | Should -Be 2 $script:model.Servers[0].Variables['region'].Enum | Should -Be @('eu', 'us') $script:model.Servers[1].Url | Should -Be '/v1' } It 'reads security schemes and the default security' { @($script:model.SecuritySchemes.Keys) | Should -Be @('api_key', 'basic', 'bearer', 'oauth') $script:model.SecuritySchemes['oauth'].Flows['clientCredentials'].TokenUrl | Should -Be 'https://auth.example.com/token' $script:model.Security.Count | Should -Be 1 $script:model.Security[0].Contains('api_key') | Should -BeTrue } It 'reads component schemas in order with RefName' { @($script:model.Schemas.Keys) | Should -Be @('Pet', 'Category', 'PetList', 'Store', 'Error') $script:model.Schemas['Pet'].RefName | Should -Be 'Pet' $script:model.Schemas['Pet'].Properties['tag'].Nullable | Should -BeTrue $script:model.Schemas['Pet'].Properties['born'].Example | Should -BeExactly '2020-01-01T00:00:00Z' } It 'builds the operations in document order' { @($script:model.Operations.OperationId) | Should -Be @('listPets', 'createPet', 'showPetById', 'deletePet', 'uploadPetPhoto', 'getStores') } It 'resolves parameter, header, request body and response refs' { $list = $script:model.Operations[0] $list.Parameters[0].Name | Should -Be 'limit' $list.Parameters[0].Schema.Maximum | Should -Be 100 $list.Responses[0].Headers['X-Rate-Limit'].Schema.Type | Should -Be 'integer' $list.Responses[1].StatusCode | Should -Be 'default' $list.Responses[1].Content[0].ContentType | Should -Be 'application/problem+json' $create = $script:model.Operations[1] $create.RequestBody.Required | Should -BeTrue $create.RequestBody.Content[0].ContentType | Should -Be 'application/json' $create.RequestBody.Content[0].Schema.RefName | Should -Be 'Pet' } It 'detects nextLink and Link-header paging' { $script:model.Operations[0].Paging.Kind | Should -Be 'nextLink' $script:model.Operations[0].Paging.ItemsProperty | Should -Be 'value' $script:model.Operations[5].Paging.Kind | Should -Be 'linkHeader' $script:model.Operations[2].Paging | Should -BeNullOrEmpty } It 'merges the path-level petId parameter' { $script:model.Operations[2].Parameters[0].Name | Should -Be 'petId' $script:model.Operations[2].Parameters[0].Schema.Format | Should -Be 'int64' $script:model.Operations[3].Parameters[0].Name | Should -Be 'petId' } It 'distinguishes default, none and explicit security' { $script:model.Operations[0].Security | Should -BeNullOrEmpty $null -eq $script:model.Operations[1].Security | Should -BeFalse $script:model.Operations[1].Security.Count | Should -Be 0 $script:model.Operations[3].Security.Count | Should -Be 2 $script:model.Operations[3].Security[0]['oauth'] | Should -Be @('pets:write') } It 'reads Deprecated, ExternalDocsUrl, Tags and extensions' { $script:model.Operations[3].Deprecated | Should -BeTrue $script:model.Operations[3].Extensions['x-ps-verb'] | Should -Be 'Remove' $script:model.Operations[3].Extensions['x-ps-noun'] | Should -Be 'PetRecord' $script:model.Operations[4].Extensions['x-ps-name'] | Should -Be 'Set-PetPhoto' $script:model.Operations[2].ExternalDocsUrl | Should -Be 'https://docs.example.com/pets#get' $script:model.Operations[2].Tags | Should -Be @('pets') } It 'orders media types JSON first' { @($script:model.Operations[2].Responses[0].Content.ContentType) | Should -Be @('application/json', 'application/xml') } It 'reports OA010, OA051 and OA060' { @($script:model.Findings.Code) | Should -Be @('OA010', 'OA051', 'OA060') ($script:model.Findings | Where-Object Code -EQ 'OA010').Operation | Should -Be 'getStores' ($script:model.Findings | Where-Object Code -EQ 'OA051').Operation | Should -Be 'createPet' } } Context 'OpenAPI 3.1' { BeforeAll { $script:model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-openapi-3.1.json') } It 'normalises 3.1 schemas' { $item = $script:model.Schemas['Item'] $item.Properties['id'].Const | Should -Be 'fixed-id' $item.Properties['note'].Type | Should -Be 'string' $item.Properties['note'].Nullable | Should -BeTrue $item.Properties['value'].Type | Should -BeNullOrEmpty $item.Properties['nothing'].Nullable | Should -BeTrue $item.Properties['sample'].Example | Should -Be 'first' $item.Properties['free'].Type | Should -BeNullOrEmpty } It 'uses x-ms-pageable' { $script:model.Operations[0].Paging.ItemsProperty | Should -Be 'items' $script:model.Operations[0].Paging.NextLinkProperty | Should -Be '@odata.nextLink' $script:model.Operations[2].Paging | Should -BeNullOrEmpty } It 'reports OA030 for each unsupported scheme and OA031' { @($script:model.Findings | Where-Object Code -EQ 'OA030').Count | Should -Be 4 ($script:model.Findings | Where-Object Code -EQ 'OA031').Pointer | Should -BeExactly '/components/schemas/Item/properties/value/type' } It 'ignores webhooks' { $script:model.Operations.Count | Should -Be 3 } } Context 'Swagger 2.0' { BeforeAll { $script:model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-swagger-2.0.json') $script:operations = @{} foreach ($operation in $script:model.Operations) { $script:operations[$operation.OperationId] = $operation } } It 'reports SourceVersion 2.0 and converts servers' { $script:model.SourceVersion | Should -Be '2.0' @($script:model.Servers.Url) | Should -Be @('https://petstore.example.com/v2', 'http://petstore.example.com/v2') } It 'converts definitions to schemas and resolves #/definitions refs' { @($script:model.Schemas.Keys) | Should -Be @('Pet', 'Category', 'Error') $script:model.Schemas['Pet'].Properties['category'].RefName | Should -Be 'Category' $script:operations['findPets'].Responses[0].Content[0].Schema.Items.RefName | Should -Be 'Pet' } It 'converts security definitions' { $script:model.SecuritySchemes['basic_auth'].Type | Should -Be 'http' $script:model.SecuritySchemes['basic_auth'].Scheme | Should -Be 'basic' $script:model.SecuritySchemes['api_key'].ParameterName | Should -Be 'api_key' $script:model.SecuritySchemes['petstore_auth'].Flows['clientCredentials'].Scopes['write:pets'] | Should -Be 'modify pets' $finding = $script:model.Findings | Where-Object Code -EQ 'OA030' $finding.Pointer | Should -BeExactly '/securityDefinitions/implicit_auth' } It 'maps collectionFormat to style/explode' { $styles = @($script:operations['findPets'].Parameters | ForEach-Object { '{0}={1}/{2}' -f $_.Name, $_.Style, $_.Explode }) $styles | Should -Be @('tags=form/False', 'status=form/True', 'ids=pipeDelimited/False', 'names=spaceDelimited/False', 'X-Trace=simple/False', 'limit=form/True') $script:operations['findPets'].Parameters[5].Schema.Default | Should -Be 20 $script:operations['findPets'].Parameters[1].Schema.Items.Enum | Should -Be @('available', 'sold') } It 'converts body, urlencoded formData and multipart formData' { $script:operations['addPet'].RequestBody.Content[0].Schema.RefName | Should -Be 'Pet' $script:operations['updatePetWithForm'].RequestBody.Content[0].ContentType | Should -Be 'application/x-www-form-urlencoded' $script:operations['updatePetWithForm'].RequestBody.Content[0].Schema.Required | Should -Be @('name') $upload = $script:operations['uploadImage'].RequestBody.Content[0] $upload.ContentType | Should -Be 'multipart/form-data' $upload.Schema.Properties['file'].Format | Should -Be 'binary' } It 'converts responses with produces and headers' { @($script:operations['findPets'].Responses[0].Content.ContentType) | Should -Be @('application/json', 'application/xml') $script:operations['findPets'].Responses[0].Headers['X-Total'].Schema.Type | Should -Be 'integer' $script:operations['findPets'].Responses[1].Description | Should -Be 'Unexpected error' $script:operations['getImage'].Responses[0].Content[0].ContentType | Should -Be 'image/png' $script:operations['getImage'].Responses[0].Content[0].Schema.Format | Should -Be 'binary' } It 'merges path-level parameters with operation-level override' { $script:operations['deletePet'].Parameters[0].Schema.Type | Should -Be 'string' $script:operations['updatePetWithForm'].Parameters[0].Schema.Type | Should -Be 'integer' } It 'keeps security and extensions' { $script:model.Security[0].Contains('api_key') | Should -BeTrue $script:operations['deletePet'].Security.Count | Should -Be 0 $script:operations['addPet'].Security[0]['petstore_auth'] | Should -Be @('write:pets') $script:operations['findPets'].Extensions['x-ps-name'] | Should -Be 'Find-Pet' } } Context 'Circular schemas' { It 'marks recursion points and reports OA022 once per cycle entry' { $model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-circular.json') $model.Schemas['Node'].Properties['children'].Items.Recursive | Should -BeTrue $model.Schemas['Node'].Properties['parent'].Recursive | Should -BeTrue $model.Schemas['Person'].Properties['employer'].RefName | Should -Be 'Company' $model.Schemas['Person'].Properties['employer'].Recursive | Should -BeFalse $model.Schemas['Person'].Properties['employer'].Properties | Should -BeNullOrEmpty $model.Schemas['Company'].Properties['employees'].Items.RefName | Should -Be 'Person' $model.Schemas['Company'].Properties['employees'].Items.Recursive | Should -BeTrue $model.Schemas['Alias'].RefName | Should -Be 'Alias' @($model.Schemas['Alias'].Properties.Keys) | Should -Be @('name', 'children', 'parent') $model.Schemas['Node'].RefName | Should -Be 'Node' @($model.Findings | Where-Object Code -EQ 'OA022').Count | Should -Be 2 @($model.Findings | Where-Object Severity -NE 'Information').Count | Should -Be 0 $model.Operations[0].Responses[0].Content[0].Schema.RefName | Should -Be 'Node' $model.Operations[1].RequestBody.Content[0].Schema.Properties['employer'].RefName | Should -Be 'Company' } } Context 'External references' { BeforeAll { $script:model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-external-ref.json') } It 'flags the operations that use external refs as Unsupported' { @($script:model.Operations | Where-Object Unsupported | ForEach-Object OperationId) | Should -Be @('getRemote', 'getWrapped', 'getShared') } It 'reports OA020 with pointers and OA021 for unresolved local refs' { $external = @($script:model.Findings | Where-Object Code -EQ 'OA020') @($external.Pointer) | Should -Be @('/components/schemas/Wrapper/properties/inner', '/paths/~1remote/get/parameters/0/schema', '/paths/~1wrapped/get', '/paths/~1shared/get/parameters/0') @($external.Severity | Select-Object -Unique) | Should -Be @('Error') ($script:model.Findings | Where-Object Code -EQ 'OA021').Operation | Should -Be 'getMissing' } It 'drops a parameter whose $ref is external' { ($script:model.Operations | Where-Object OperationId -EQ 'getShared').Parameters.Count | Should -Be 0 } } Context 'Path-level parameters and operationIds' { BeforeAll { $script:model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-path-parameters.json') } It 'merges path-level parameters and resolves parameter ref chains' { $list = $script:model.Operations[0] @($list.Parameters | ForEach-Object { '{0}:{1}' -f $_.In, $_.Name }) | Should -Be @('path:userId', 'header:x-request-id', 'query:page', 'query:filter', 'cookie:session', 'query:path', 'query:coords', 'path:ids') $list.Parameters[1].Description | Should -Be 'Operation-level header wins' $list.Parameters[3].Style | Should -Be 'deepObject' $list.Parameters[3].Schema.AdditionalProperties.Type | Should -Be 'string' $list.Parameters[4].Style | Should -Be 'form' $list.Parameters[5].AllowReserved | Should -BeTrue $list.Parameters[6].Schema.Properties['lat'].Type | Should -Be 'number' $list.Parameters[7].Style | Should -Be 'label' $list.Parameters[7].Explode | Should -BeTrue $list.Parameters[7].Required | Should -BeTrue } It 'renames duplicate operationIds (case-insensitively) with OA011' { @($script:model.Operations.OperationId) | Should -Be @('listOrders', 'listOrders_2', 'LISTORDERS_3', 'getUsersUserIdOrdersOrderId', 'patchUsersUserIdOrdersOrderId', 'getRoot_2', 'getRoot') $duplicates = @($script:model.Findings | Where-Object Code -EQ 'OA011') $duplicates.Count | Should -Be 2 $duplicates[0].Pointer | Should -BeExactly '/paths/~1users~1{userId}~1orders/post/operationId' } It 'generates missing operationIds with OA010, avoiding explicit ids' { $generated = @($script:model.Findings | Where-Object Code -EQ 'OA010') @($generated.Operation) | Should -Be @('getUsersUserIdOrdersOrderId', 'patchUsersUserIdOrdersOrderId', 'getRoot_2') $generated[0].Pointer | Should -BeExactly '/paths/~1users~1{userId}~1orders~1{order-id}/get' } It 'resolves escaped and percent-encoded schema refs' { $script:model.Operations[3].Parameters[1].Schema.RefName | Should -BeExactly 'a/b~c' $script:model.Operations[4].Parameters[1].Schema.RefName | Should -BeExactly 'with space' $script:model.Operations[4].Parameters[1].Schema.Format | Should -Be 'uuid' } } Context 'Composition' { BeforeAll { $script:model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-composition.json') } It 'merges allOf into Properties and Required' { $dog = $script:model.Schemas['Dog'] $dog.Type | Should -Be 'object' @($dog.Properties.Keys) | Should -Be @('name', 'kind', 'id', 'secret', 'bark') $dog.Properties['name'].Description | Should -Be 'Dog name overrides Pet name' $dog.Required | Should -Be @('name', 'bark') $dog.AllOf.Count | Should -Be 2 $dog.Properties['id'].ReadOnly | Should -BeTrue $dog.Properties['secret'].WriteOnly | Should -BeTrue } It 'keeps oneOf and the discriminator' { $animal = $script:model.Schemas['Animal'] @($animal.OneOf.RefName) | Should -Be @('Dog', 'Cat') $animal.Discriminator.PropertyName | Should -Be 'kind' $script:model.Schemas['Pet'].Discriminator.Mapping['dog'] | Should -Be '#/components/schemas/Dog' } It 'handles nullable allOf wrappers, additionalProperties and inferred types' { $script:model.Schemas['NullablePet'].Nullable | Should -BeTrue $script:model.Schemas['NullablePet'].Description | Should -Be 'A pet or null' $script:model.Schemas['NullablePet'].Properties.Count | Should -Be 4 $script:model.Schemas['Tags'].AdditionalProperties.Type | Should -Be 'string' $script:model.Schemas['Closed'].AdditionalProperties | Should -BeFalse $script:model.Schemas['Closed'].Type | Should -Be 'object' $script:model.Schemas['Matrix'].Type | Should -Be 'array' $script:model.Schemas['Matrix'].Items.Items.Type | Should -Be 'number' $script:model.Schemas['PetWithNote'].Description | Should -Be 'Sibling description is applied to a copy' $script:model.Schemas['Pet'].Description | Should -BeNullOrEmpty } It 'orders JSON media types first and reports OA050 for oneOf/anyOf bodies' { @($script:model.Operations[1].RequestBody.Content.ContentType) | Should -Be @('application/json', 'application/vnd.api+json', 'text/plain') @($script:model.Findings | Where-Object Code -EQ 'OA050' | ForEach-Object Operation) | Should -Be @('createAnimal', 'replaceAnimal') } It 'keeps multipart encodings' { $form = $script:model.Operations[3].RequestBody.Content $form[0].ContentType | Should -Be 'multipart/form-data' $form[0].Encoding['meta'].ContentType | Should -Be 'application/json' $form[1].ContentType | Should -Be 'application/x-www-form-urlencoded' } } Context 'Invalid documents' { It 'returns OA001 and no operations for an unsupported version' { $model = & $script:fromText '{"openapi":"4.0.0","paths":{"/a":{"get":{}}}}' $model.Findings.Count | Should -Be 1 $model.Findings[0].Code | Should -Be 'OA001' $model.Findings[0].Severity | Should -Be 'Error' $model.Findings[0].Pointer | Should -BeExactly '/openapi' $model.Operations.Count | Should -Be 0 $model.SourceVersion | Should -Be '4.0.0' } It 'reports missing paths as OA002 (<Severity> for <Version>)' -TestCases @( @{ Json = '{"openapi":"3.0.0","info":{}}'; Severity = 'Error'; Version = '3.0' } @{ Json = '{"swagger":"2.0","info":{}}'; Severity = 'Error'; Version = '2.0' } @{ Json = '{"openapi":"3.1.0","info":{},"webhooks":{}}'; Severity = 'Warning'; Version = '3.1' } ) { $model = & $script:fromText $Json $model.Findings[0].Code | Should -Be 'OA002' $model.Findings[0].Severity | Should -Be $Severity $model.Findings[0].Pointer | Should -BeExactly '/paths' } It 'reports an unresolved Swagger 2.0 parameter ref as OA021' { $model = & $script:fromText '{"swagger":"2.0","paths":{"/a":{"get":{"operationId":"a","parameters":[{"$ref":"#/parameters/nope"}],"responses":{}}}}}' $model.Findings[0].Code | Should -Be 'OA021' $model.Findings[0].Operation | Should -Be 'a' } It 'handles an external path item ref' { $model = & $script:fromText '{"openapi":"3.0.0","paths":{"/a":{"$ref":"other.json#/paths/a"}}}' $model.Operations.Count | Should -Be 0 $model.Findings[0].Code | Should -Be 'OA020' } It 'resolves relative servers against the document URL' { $model = & $script:load (Join-Path -Path $script:fixtures -ChildPath 'document-petstore-3.0.json') 'https://petstore.example.com/docs/openapi.json' $model.Servers[1].Url | Should -Be 'https://petstore.example.com/v1' } } } |