Public/Tests/New-FunctionsFromSwagger.Tests.ps1
|
BeforeAll { $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config' $env:TCS_SKIP_UPDATE_CHECK = '1' $env:TCS_TELEMETRY_OPTOUT = '1' $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath 'tcs.utils.psd1') -Force } AfterAll { Remove-Module -Name tcs.utils -Force -ErrorAction SilentlyContinue } Describe 'New-FunctionsFromSwagger' { BeforeAll { function Get-ParseErrorCount { param([string]$Path) $tokens = $null $errors = $null $null = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) @($errors).Count } $SwaggerPath = Join-Path -Path $TestDrive -ChildPath 'swagger.json' @' { "openapi": "3.0.1", "info": { "title": "Pet API", "version": "1.0" }, "paths": { "/api/pets": { "get": { "operationId": "ListPets", "summary": "List pets", "parameters": [ { "name": "limit", "in": "query", "schema": { "type": "integer" } }, { "name": "kind", "in": "query", "schema": { "type": "string", "enum": ["cat", "dog"] } } ], "responses": { "200": { "description": "ok" } } }, "post": { "operationId": "CreatePet", "summary": "Create a pet", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Pet" } } } }, "responses": { "200": { "description": "ok" } } } }, "/api/pets/{petId}": { "get": { "operationId": "GetPet", "summary": "Get a pet", "parameters": [ { "name": "petId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "ok" } } }, "delete": { "operationId": "DeletePet", "summary": "Delete a pet", "parameters": [ { "name": "petId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "ok" } } } } }, "components": { "schemas": { "Pet": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string", "description": "Pet name" }, "age": { "type": "integer" }, "kind": { "type": "string", "enum": ["cat", "dog"] } } } } } } '@ | Set-Content -Path $SwaggerPath # Target module to generate into, and a snapshot of tcs.utils to prove nothing is written there $TargetModule = Join-Path -Path $TestDrive -ChildPath 'pet.api' $null = New-Item -Path (Join-Path -Path $TargetModule -ChildPath 'Public') -ItemType Directory -Force $null = New-Item -Path (Join-Path -Path $TargetModule -ChildPath 'Private') -ItemType Directory -Force Set-Content -Path (Join-Path -Path $TargetModule -ChildPath 'pet.api.psd1') -Value "@{`n RootModule = 'pet.api.psm1'`n ModuleVersion = '0.1.0'`n FunctionsToExport = @()`n}`n" $ModuleSnapshot = @(Get-ChildItem -Path $ModuleRoot -Recurse -Force | ForEach-Object FullName | Sort-Object) $GeneratorOutput = New-FunctionsFromSwagger -SwaggerPath $SwaggerPath -NounPrefix 'Pet' -StripPathPrefix '/api' -ModuleRoot $TargetModule -WarningAction SilentlyContinue $FunctionFolder = Join-Path -Path $TargetModule -ChildPath 'Public/Generated/Functions' $GeneratedFiles = @(Get-ChildItem -Path $TargetModule -Recurse -Filter '*.ps1') } It 'Generates wrapper and helper functions under the target module' { $names = @(Get-ChildItem -Path $FunctionFolder -Filter '*.ps1' | ForEach-Object BaseName) $names | Should -Contain 'Invoke-PetApi' $names | Should -Contain 'Get-PetPetsPetId' $names | Should -Contain 'Remove-PetPetsPetId' Test-Path -Path (Join-Path -Path $TargetModule -ChildPath 'Private/Generated/SwaggerParamValidation.json') | Should -BeTrue $GeneratorOutput | Should -Not -BeNullOrEmpty } It 'Generates files that parse without errors' { $GeneratedFiles.Count | Should -BeGreaterThan 0 foreach ($file in $GeneratedFiles) { Get-ParseErrorCount -Path $file.FullName | Should -Be 0 -Because "$($file.Name) should parse" } } It 'Generates a case-insensitive ValidateSet for string enums' { Get-Content -Path (Join-Path -Path $FunctionFolder -ChildPath 'Find-PetPets.ps1') -Raw | Should -Match ([regex]::Escape("[ValidateSet('cat', 'dog', IgnoreCase=`$true)]")) } It 'Refreshes FunctionsToExport in the target module manifest' { $manifest = Import-PowerShellDataFile -Path (Join-Path -Path $TargetModule -ChildPath 'pet.api.psd1') $manifest.FunctionsToExport | Should -Contain 'Invoke-PetApi' } It 'Does not write into the tcs.utils module folder' { @(Get-ChildItem -Path $ModuleRoot -Recurse -Force | ForEach-Object FullName | Sort-Object) | Should -Be $ModuleSnapshot } It 'Refuses to overwrite existing generated files unless -UpdateExistingOnly or -PreviewOnly is used' { { New-FunctionsFromSwagger -SwaggerPath $SwaggerPath -NounPrefix 'Pet' -StripPathPrefix '/api' -ModuleRoot $TargetModule -WarningAction SilentlyContinue } | Should -Throw '*already contains*' } It 'Throws when the swagger file does not exist' { { New-FunctionsFromSwagger -SwaggerPath (Join-Path -Path $TestDrive -ChildPath 'missing.json') -NounPrefix 'Pet' -ModuleRoot $TargetModule } | Should -Throw '*not found*' } } Describe 'New-FunctionsFromSwagger generated module at runtime' { BeforeAll { $Hostile = 'x'']=1;Write-Output PWNED;$a[''' $ZooSpecPath = Join-Path -Path $TestDrive -ChildPath 'zoo.json' $ZooSpec = @' { "openapi": "3.0.1", "info": { "title": "Zoo API", "version": "1.0" }, "security": [ { "bearer": [] } ], "components": { "securitySchemes": { "bearer": { "type": "http", "scheme": "bearer" }, "key": { "type": "apiKey", "in": "header", "name": "X-Api-Key" } }, "schemas": { "Animal": { "type": "object", "properties": { "name": { "type": "string", "description": "Animal name #> Write-Output PWNED <#" }, "age": { "type": "integer" }, "__HOSTILE__": { "type": "string" } } } } }, "paths": { "/animals/{animalId}": { "summary": "An animal", "description": "Path-level description", "parameters": [ { "name": "animalId", "in": "path", "required": true, "schema": { "type": "string" } } ], "get": { "operationId": "GetAnimal", "summary": "Get an animal", "parameters": [ { "name": "animalId", "in": "path", "required": true, "description": "Operation-level animal id", "schema": { "type": "string" } } ], "responses": { "200": { "description": "ok" } } }, "put": { "operationId": "ReplaceAnimal", "summary": "Replace an animal", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Animal" } } } }, "responses": { "200": { "description": "ok" } } }, "patch": { "operationId": "PatchAnimal", "summary": "Patch an animal", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Animal" } } } }, "responses": { "200": { "description": "ok" } } }, "delete": { "operationId": "DeleteAnimal", "summary": "Delete an animal", "responses": { "200": { "description": "ok" } } } }, "/animals": { "get": { "operationId": "ListAnimals", "summary": "List animals #> Write-Output PWNED <#", "description": "Lists animals. {\"__HOSTILE__\": \"a=1|b=2\"}", "parameters": [ { "name": "__HOSTILE__", "in": "query", "schema": { "type": "string", "enum": [ "a", "b" ] } }, { "name": "h');Write-Output PWNED;('", "in": "header", "schema": { "type": "string" } } ], "responses": { "200": { "description": "ok" } } } }, "/odd/{x'y}": { "get": { "operationId": "GetOdd", "summary": "Odd path", "parameters": [ { "name": "x'y", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "c__CURLY__;Write-Output PWNED;__CURLY__", "in": "query", "schema": { "type": "string" } } ], "responses": { "200": { "description": "ok" } } } } } } '@ # JSON-escape the hostile name (it contains no characters that need escaping in JSON) # __CURLY__ is a right single quotation mark, which PowerShell also accepts as a single quote $ZooSpec.Replace('__HOSTILE__', $Hostile).Replace('__CURLY__', [string][char]0x2019) | Set-Content -Path $ZooSpecPath -Encoding UTF8 $ZooParent = Join-Path -Path $TestDrive -ChildPath 'zoo' $null = New-Item -Path $ZooParent -ItemType Directory -Force New-ModuleCustomTemplate -Names 'zoo.api' -Path $ZooParent -Author 'Tester' -WarningAction SilentlyContinue $ZooRoot = Join-Path -Path $ZooParent -ChildPath 'zoo.api' $GenerationOutput = New-FunctionsFromSwagger -SwaggerPath $ZooSpecPath -NounPrefix 'Zoo' -ModuleRoot $ZooRoot -WarningAction SilentlyContinue *>&1 $ImportOutput = Import-Module -Name (Join-Path -Path $ZooRoot -ChildPath 'zoo.api.psd1') -Force -PassThru *>&1 $ZooModule = Get-Module -Name 'zoo.api' $ZooFiles = @(Get-ChildItem -Path $ZooRoot -Recurse -File) $ZooScripts = @($ZooFiles | Where-Object { $_.Extension -eq '.ps1' }) } AfterAll { Remove-Module -Name 'zoo.api' -Force -ErrorAction SilentlyContinue } Context 'Spec-derived text is escaped' { It 'Does not run code from hostile parameter names, descriptions or paths' { ($GenerationOutput | Out-String) | Should -Not -Match 'PWNED' ($ImportOutput | Out-String) | Should -Not -Match 'PWNED' } It 'Generates files that parse without errors' { foreach ($file in $ZooScripts) { $tokens = $null $errors = $null $null = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$errors) @($errors).Count | Should -Be 0 -Because "$($file.Name) should parse" } } It 'Writes the parameter validation data as JSON instead of a script' { Test-Path -Path (Join-Path -Path $ZooRoot -ChildPath 'Private/Generated/SwaggerParamValidation.ps1') | Should -BeFalse $json = Get-Content -Path (Join-Path -Path $ZooRoot -ChildPath 'Private/Generated/SwaggerParamValidation.json') -Raw | ConvertFrom-Json $json.PSObject.Properties.Name | Should -Contain 'Validation' } It 'Passes a hostile query parameter name through as data' { $command = Get-Command -Name 'Find-ZooAnimals' -Module 'zoo.api' $command.Parameters.Keys | Should -Contain 'X_1_Write_Output_PWNED_a_' } } Context 'Module loading and exports' { It 'Imports the generated functions from Public/Generated/Functions' { $ZooModule | Should -Not -BeNullOrEmpty $ZooModule.ExportedFunctions.Keys | Should -Contain 'Get-ZooAnimalsAnimalId' $ZooModule.ExportedFunctions.Keys | Should -Contain 'Set-ZooContext' $ZooModule.ExportedFunctions.Keys | Should -Contain 'Get-ZooContext' $ZooModule.ExportedFunctions.Keys | Should -Contain 'Invoke-ZooApi' } It 'Loads the private helpers from Private/Generated' { & $ZooModule { Get-Command -Name 'Convert-SwaggerParamValue' -ErrorAction SilentlyContinue } | Should -Not -BeNullOrEmpty & $ZooModule { Get-Command -Name 'Convert-ZooGeneratedObject' -ErrorAction SilentlyContinue } | Should -Not -BeNullOrEmpty } It 'Lists the exported functions in the manifest' { $manifest = Import-PowerShellDataFile -Path (Join-Path -Path $ZooRoot -ChildPath 'zoo.api.psd1') ($manifest.FunctionsToExport | Sort-Object) | Should -Be ($ZooModule.ExportedFunctions.Keys | Sort-Object) } It 'Does not treat path-level keys (parameters, summary, description) as HTTP methods' { @(Get-Command -Module 'zoo.api' -Name 'Invoke-ZooAnimalsAnimalId*').Count | Should -Be 0 @(Get-Command -Module 'zoo.api' -Name '*-ZooAnimalsAnimalId*').Count | Should -Be 4 } It 'Gives generated functions help that Get-Help finds' { (Get-Help -Name 'Get-ZooAnimalsAnimalId').Synopsis | Should -Be 'Get an animal' (Get-Help -Name 'Set-ZooContext').Synopsis | Should -Match 'Configure' } It 'Uses a distinct hash suffix to disambiguate colliding names' { $names = @(Get-Command -Module 'zoo.api' -Name 'Update-ZooAnimalsAnimalId*' | ForEach-Object Name) $names.Count | Should -Be 2 $suffixed = $names | Where-Object { $_ -match '_[0-9a-f]{8}$' } $suffixed | Should -Not -BeNullOrEmpty $suffixed | Should -Not -Match 'da39a3ee' } } Context 'Requests' { BeforeAll { Mock -ModuleName 'zoo.api' -CommandName Invoke-RestMethod -MockWith { [pscustomobject]@{ ok = $true } } $null = Set-ZooContext -BaseUri 'api.example.com' -Authorization 'SECRET-TOKEN' -XApiKey 'SECRET-KEY' } It 'Merges path-level parameters into each operation' { (Get-Command -Name 'Remove-ZooAnimalsAnimalId').Parameters.Keys | Should -Contain 'AnimalId' $help = Get-Help -Name 'Get-ZooAnimalsAnimalId' -Parameter 'AnimalId' ($help.Description | Out-String) | Should -Match 'Operation-level animal id' } It 'Substitutes and escapes path parameters' { $null = Get-ZooAnimalsAnimalId -AnimalId 'a b/c' Should -Invoke -ModuleName 'zoo.api' -CommandName Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Method -eq 'GET' -and $Uri -eq 'https://api.example.com/animals/a%20b%2Fc' } } It 'Substitutes a path parameter whose name contains a quote' { $null = Get-ZooOddXY -X_y 'v' Should -Invoke -ModuleName 'zoo.api' -CommandName Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Uri -eq 'https://api.example.com/odd/v' } } It 'Keeps a parameter name with curly quotes as data' { $null = Get-ZooOddXY -X_y 'v' -C_Write_Output_PWNED_ 'w' $name = 'c' + [char]0x2019 + ';Write-Output PWNED;' + [char]0x2019 $expected = 'https://api.example.com/odd/v?' + [uri]::EscapeDataString($name) + '=w' Should -Invoke -ModuleName 'zoo.api' -CommandName Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Uri -eq $expected } } It 'Sends the body and content type for <_>' -ForEach @('PUT', 'PATCH') { $method = $_ $command = Get-Command -Module 'zoo.api' -Name 'Update-ZooAnimalsAnimalId*' | Where-Object { (Get-Help -Name $_.Name).Description[0].Text -match "Method: $method" } & $command.Name -AnimalId 'rex1' -Name 'Rex' -Confirm:$false Should -Invoke -ModuleName 'zoo.api' -CommandName Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Method -eq $method -and $Uri -eq 'https://api.example.com/animals/rex1' -and $ContentType -eq 'application/json' -and ($Body | ConvertFrom-Json).name -eq 'Rex' } } It 'Sends a body with DELETE only when one is supplied' { $null = Invoke-ZooApi -Path '/animals/1' -Method DELETE Should -Invoke -ModuleName 'zoo.api' -CommandName Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Method -eq 'DELETE' -and $null -eq $Body } $null = Invoke-ZooApi -Path '/animals/1' -Method DELETE -Body @{ reason = 'x' } Should -Invoke -ModuleName 'zoo.api' -CommandName Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Method -eq 'DELETE' -and $Body -match 'reason' } } It 'Sends query parameters with hostile names as data (and maps labels to ids from the JSON data)' { # The operation description maps the label 'a' to the id 1 for this parameter $null = Find-ZooAnimals -X_1_Write_Output_PWNED_a_ 'a' $expected = 'https://api.example.com/animals?' + [uri]::EscapeDataString($Hostile) + '=1' Should -Invoke -ModuleName 'zoo.api' -CommandName Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Uri -eq $expected } } It 'Sends the security headers' { $null = Get-ZooAnimalsAnimalId -AnimalId '1' Should -Invoke -ModuleName 'zoo.api' -CommandName Invoke-RestMethod -Times 1 -Exactly -Scope It -ParameterFilter { $Headers['Authorization'] -eq 'Bearer SECRET-TOKEN' -and $Headers['X-Api-Key'] -eq 'SECRET-KEY' } } } Context 'Context secrets' { BeforeAll { Mock -ModuleName 'zoo.api' -CommandName Invoke-RestMethod -MockWith { [pscustomobject]@{ ok = $true } } $SetOutput = Set-ZooContext -BaseUri 'https://api.example.com' -Authorization 'SECRET-TOKEN' -XApiKey 'SECRET-KEY' -DebugApi $VerboseOutput = Get-ZooAnimalsAnimalId -AnimalId '1' -Verbose 4>&1 | Out-String $Context = Get-ZooContext } It 'Keeps the context out of the global scope' { Get-Variable -Name 'ZooContext' -Scope Global -ErrorAction SilentlyContinue | Should -BeNullOrEmpty } It 'Redacts secrets in Get-<Api>Context and Set-<Api>Context output' { ($Context | ConvertTo-Json -Depth 8) | Should -Not -Match 'SECRET' ($SetOutput | ConvertTo-Json -Depth 8) | Should -Not -Match 'SECRET' $Context.SecurityHeaderValues['Authorization'] | Should -Be '***' $Context.RequestHeaders['X-Api-Key'] | Should -Be '***' } It 'Does not write secrets to the verbose stream with -DebugApi' { $VerboseOutput | Should -Not -Match 'SECRET' } } Context 'Generated code' { It 'Contains no leftovers from the service it was first written for' { $ZooScripts | Select-String -Pattern 'TLGenerated|TLFunction|ManagedOrganizationId|SiteKey|Portalapi|-TL' | Should -BeNullOrEmpty @($ZooScripts | Select-String -Pattern 'ApiError_' | Where-Object { $_.Line -notmatch 'ZooApiError_' }) | Should -BeNullOrEmpty } It 'Guards PowerShell 7-only ConvertFrom-Json parameters with a version check' { $lines = $ZooScripts | Select-String -Pattern 'ConvertFrom-Json[^|]*-(Depth|AsHashtable)' | Where-Object { -not $_.Line.Trim().StartsWith('#') } foreach ($line in $lines) { $line.Line | Should -Match 'PSVersionTable' -Because "$($line.Filename):$($line.LineNumber) runs on Windows PowerShell 5.1" } $ZooScripts | Select-String -Pattern '\[Microsoft\.PowerShell\.Commands\.HttpResponseException\]' | Should -BeNullOrEmpty } } } Describe 'New-FunctionsFromSwagger -PreviewOnly and -WhatIf' { BeforeAll { $SpecPath = Join-Path -Path $TestDrive -ChildPath 'preview.json' '{"openapi":"3.0.1","paths":{"/pets":{"get":{"operationId":"ListPets","summary":"List","responses":{"200":{"description":"ok"}}}}}}' | Set-Content -Path $SpecPath } It 'Writes nothing and returns what it would generate with -PreviewOnly' { $root = Join-Path -Path $TestDrive -ChildPath 'preview1' $null = New-Item -Path $root -ItemType Directory $result = New-FunctionsFromSwagger -SwaggerPath $SpecPath -NounPrefix 'Pet' -ModuleRoot $root -PreviewOnly -WarningAction SilentlyContinue @(Get-ChildItem -Path $root -Recurse -Force).Count | Should -Be 0 $result.Preview | Should -BeTrue $result.Files.Path | Should -Contain (Join-Path -Path $root -ChildPath 'Public/Generated/Functions/Find-PetPets.ps1') ($result.Files | Select-Object -ExpandProperty Action -Unique) | Should -Be 'Preview' } It 'Writes nothing and does not fail with -WhatIf' { $root = Join-Path -Path $TestDrive -ChildPath 'preview2' $null = New-Item -Path $root -ItemType Directory { New-FunctionsFromSwagger -SwaggerPath $SpecPath -NounPrefix 'Pet' -ModuleRoot $root -WhatIf -WarningAction SilentlyContinue -ErrorAction Stop } | Should -Not -Throw @(Get-ChildItem -Path $root -Recurse -Force).Count | Should -Be 0 } It 'Returns a result object describing the written files' { $root = Join-Path -Path $TestDrive -ChildPath 'preview3' $null = New-Item -Path $root -ItemType Directory $result = New-FunctionsFromSwagger -SwaggerPath $SpecPath -NounPrefix 'Pet' -ModuleRoot $root -WarningAction SilentlyContinue @($result).Count | Should -Be 1 $result.FunctionCount | Should -Be 1 $result.Preview | Should -BeFalse foreach ($file in $result.Files) { Test-Path -LiteralPath $file.Path | Should -BeTrue } } It 'No longer has the -StripPortalApiPrefix parameter' { (Get-Command -Name New-FunctionsFromSwagger).Parameters.Keys | Should -Not -Contain 'StripPortalApiPrefix' } } |