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'
    }
}