Tests/SessionLifecycle.Tests.ps1
|
#Requires -Version 5.1 # Pester v5 tests for the N2C.GuacAdmin public session lifecycle cmdlets. # These run against a local mock of the Guacamole 1.6.0 REST API # (Tests/GuacMockServer.ps1), so no real Guacamole instance is required. # Shared state for this test container. $script: prefix is required: in Pester # v5 a plain $var assigned in BeforeAll is scoped to that block and is NOT # visible from It blocks (only file-scope and $script:-prefixed variables are). $script:mock = $null $script:credential = $null $script:totpSecure = $null BeforeAll { $script:moduleRoot = Split-Path -Parent $PSScriptRoot Import-Module (Join-Path $script:moduleRoot 'Tests/GuacTestHelpers.psm1') -Force $script:mock = Start-GuacTestMock -WorkDir $PSScriptRoot # The helper module imported N2C.GuacAdmin inside its own scope; import it # here as well so the exported cmdlets resolve in Pester's test scope. # Safe to repeat: the module captures its class types in module-scope # variables at load time, so each import instance is self-consistent. Import-Module (Join-Path $script:moduleRoot 'N2C.GuacAdmin.psd1') -Force $script:credential = [PSCredential]::new( 'guacadmin', (ConvertTo-SecureString 'secret' -AsPlainText -Force) ) $script:totpSecure = ConvertTo-SecureString '123456' -AsPlainText -Force } AfterAll { if ($null -ne $script:mock) { Stop-GuacTestMock } } Describe 'New-GuacSession' { It 'creates a session from the token response' { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential # Compare the type by the instance's runtime name rather than a # [TypeName] literal: the module is imported by the test helper (a # separate function scope), so script-defined class names are not # resolvable by name inside Pester's It scope, but the instances the # module returns carry the real type. $session.GetType().Name | Should -Be 'N2C_GuacAdmin_GuacSession' $session.Server | Should -Be $mock.BaseUrl $session.Username | Should -Be 'guacadmin' $session.Token | Should -Not -BeNullOrEmpty $session.Token | Should -Not -Be 'secret' $session.DataSource | Should -Be 'mysql' $session.AvailableDataSources | Should -Be @('mysql', 'ldap') $session.TimeoutSec | Should -Be 30 } It 'sends the login as a form-urlencoded POST to /api/tokens' { New-GuacSession -Server $mock.BaseUrl -Credential $credential | Out-Null $loginRequests = Get-GuacMockLog -FilterPath '/api/tokens' $loginRequests.Count | Should -BeGreaterThan 0 $last = $loginRequests[-1] $last.method | Should -Be 'POST' $last.contentType | Should -Be 'application/x-www-form-urlencoded' $last.body | Should -BeLike 'username=guacadmin*' $last.body | Should -BeLike '*password=secret*' $last.body | Should -Not -BeLike '*guac-totp*' } It 'defaults the data source from the token response and validates an override' { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential -DataSource ldap $session.DataSource | Should -Be 'ldap' { New-GuacSession -Server $mock.BaseUrl -Credential $credential -DataSource postgresql } | Should -Throw } It 'throws a terminating error with the parsed API reason on bad credentials' { $badCredential = [PSCredential]::new( 'guacadmin', (ConvertTo-SecureString 'wrong' -AsPlainText -Force) ) $thrown = $null try { New-GuacSession -Server $mock.BaseUrl -Credential $badCredential -ErrorAction Stop } catch { $thrown = $_ } $thrown | Should -Not -BeNullOrEmpty $inner = Get-GuacTestInnerException $thrown # In PS 5.1 the custom exception may be wrapped in RuntimeException layers. # Verify the exception carries our custom exception's data (StatusCode, Type, # Reason, Endpoint) rather than checking the type name directly. $inner.StatusCode | Should -Be 401 $inner.Type | Should -Be 'INVALID_CREDENTIALS' $inner.Reason | Should -Be 'Invalid credentials.' $inner.Endpoint | Should -BeLike ('*{0}/api/tokens' -f $mock.BaseUrl) } It 'does not register a default session on failure' { $badCredential = [PSCredential]::new( 'guacadmin', (ConvertTo-SecureString 'wrong' -AsPlainText -Force) ) $serverBefore = 'https://nowhere.example.com' { New-GuacSession -Server $serverBefore -Credential $badCredential -ErrorAction Stop } | Should -Throw Get-GuacSession -Server $serverBefore | Should -BeNullOrEmpty } It 'sends the guac-totp form field when -TotpCode is supplied' { Invoke-GuacMockControl -Path '/_control/totp-enable' try { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential -TotpCode $totpSecure $session.Token | Should -Not -BeNullOrEmpty $loginRequests = Get-GuacMockLog -FilterPath '/api/tokens' $loginRequests[-1].body | Should -BeLike '*guac-totp=123456*' } finally { Invoke-GuacMockControl -Path '/_control/totp-disable' } } It 'fails when TOTP is required but not provided' { Invoke-GuacMockControl -Path '/_control/totp-enable' try { $thrown = $null try { New-GuacSession -Server $mock.BaseUrl -Credential $credential -ErrorAction Stop } catch { $thrown = $_ } $inner = Get-GuacTestInnerException $thrown # Verify exception carries our custom exception's data. $inner.StatusCode | Should -Be 401 $inner.Type | Should -Be 'INSUFFICIENT_CREDENTIALS' } finally { Invoke-GuacMockControl -Path '/_control/totp-disable' } } It 'masks the token in the session ToString()' { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential $text = $session.ToString() $text | Should -Not -BeLike "*$($session.Token)*" $text | Should -BeLike '*Token=mock*' -Because 'the masking keeps the first 4 characters' } } Describe 'Test-GuacSession' { It 'returns $true for a live session' { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential Test-GuacSession -Session $session | Should -BeTrue } It 'returns $false for a revoked token' { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential # Revoke directly on the server side, bypassing Remove-GuacSession. $revoke = Invoke-WebRequest -Method DELETE -Uri ($mock.BaseUrl + '/api/tokens/' + $session.Token) ` -Headers @{ 'Guacamole-Token' = $session.Token } -UseBasicParsing $revoke.StatusCode | Should -Be 204 Test-GuacSession -Session $session | Should -BeFalse } It 'resolves the session from -Server via module state' { New-GuacSession -Server $mock.BaseUrl -Credential $credential | Out-Null Test-GuacSession -Server $mock.BaseUrl | Should -BeTrue } It 'throws when there is no session to check' { { Test-GuacSession -Server 'https://nowhere.example.com' } | Should -Throw } } Describe 'Remove-GuacSession' { It 'revokes the token and clears the module state' { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential $sessionBefore = Get-GuacSession -Server $mock.BaseUrl $sessionBefore | Should -Not -BeNullOrEmpty Remove-GuacSession -Session $session -Confirm:$false Get-GuacSession -Server $mock.BaseUrl | Should -BeNullOrEmpty # The token must now be rejected by the server. Test-GuacSession -Session $session | Should -BeFalse # The DELETE must have been sent with the token in the URL path. $deleteRequests = Get-GuacMockLog -FilterPath '/api/tokens/*' $deleteRequests | Should -Not -BeNullOrEmpty $deleteRequests[-1].method | Should -Be 'DELETE' $deleteRequests[-1].path | Should -BeLike ('*/api/tokens/{0}' -f $session.Token) } It 'succeeds when the token is already gone (server returns 401)' { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential # Revoke server-side directly, leaving stale local state. Invoke-WebRequest -Method DELETE -Uri ($mock.BaseUrl + '/api/tokens/' + $session.Token) ` -Headers @{ 'Guacamole-Token' = $session.Token } -UseBasicParsing | Out-Null Remove-GuacSession -Session $session -Confirm:$false Get-GuacSession -Server $mock.BaseUrl | Should -BeNullOrEmpty } It 'honors -WhatIf (no server call, state retained)' { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential $deleteCountBefore = (Get-GuacMockLog -FilterPath '/api/tokens/*').Count Remove-GuacSession -Session $session -WhatIf -Confirm:$false $deleteCountAfter = (Get-GuacMockLog -FilterPath '/api/tokens/*').Count $deleteCountAfter | Should -Be $deleteCountBefore Get-GuacSession -Server $mock.BaseUrl | Should -Not -BeNullOrEmpty } } Describe 'Get-GuacSession' { It 'returns the registered default for the server' { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential $default = Get-GuacSession -Server $mock.BaseUrl $default | Should -Not -BeNullOrEmpty $default.Token | Should -Be $session.Token } It 'returns $null when no default is registered' { Get-GuacSession -Server 'https://unknown.example.com' | Should -BeNullOrEmpty } It 'matches the server case-insensitively and ignores a trailing slash' { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential $alt = $mock.BaseUrl.TrimEnd('/').ToUpperInvariant() + '/' $default = Get-GuacSession -Server $alt $default | Should -Not -BeNullOrEmpty $default.Token | Should -Be $session.Token } } Describe 'GuacRestException error contract (via mock server)' { It 'maps a 404 APIError to a terminating exception with parsed fields' { # Reach the private transport through Pester's InModuleScope (this # PowerShell build's PSModuleInfo has no GetCommand method). $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential $invoke = InModuleScope -ModuleName 'N2C.GuacAdmin' { Get-Command -Name 'Invoke-GuacRest' } $thrown = $null try { & $invoke -Server $mock.BaseUrl -Token $session.Token -Method GET -Path '/api/notfound' } catch { $thrown = $_ } $thrown | Should -Not -BeNullOrEmpty $inner = Get-GuacTestInnerException $thrown # Verify exception carries our custom exception's data. $inner.StatusCode | Should -Be 404 $inner.Type | Should -Be 'NOT_FOUND' $inner.Reason | Should -Be 'No such resource.' $inner.Endpoint | Should -Be ($mock.BaseUrl + '/api/notfound') } It 'maps a non-JSON 500 body to the raw text reason' { $session = New-GuacSession -Server $mock.BaseUrl -Credential $credential $invoke = InModuleScope -ModuleName 'N2C.GuacAdmin' { Get-Command -Name 'Invoke-GuacRest' } $thrown = $null try { & $invoke -Server $mock.BaseUrl -Token $session.Token -Method GET -Path '/api/plaintext-error' } catch { $thrown = $_ } $inner = Get-GuacTestInnerException $thrown # Verify exception carries our custom exception's data. $inner.StatusCode | Should -Be 500 $inner.Type | Should -BeNullOrEmpty $inner.Reason | Should -Be 'Internal server error (no JSON body).' } It 'masks the token in the endpoint of token-path errors' { New-GuacSession -Server $mock.BaseUrl -Credential $credential | Out-Null $invoke = InModuleScope -ModuleName 'N2C.GuacAdmin' { Get-Command -Name 'Invoke-GuacRest' } $thrown = $null try { & $invoke -Server $mock.BaseUrl -Token 'averylongtokenvalue1234567890' -Method DELETE -Path '/api/tokens/averylongtokenvalue1234567890' } catch { $thrown = $_ } $inner = $thrown.Exception $inner.Endpoint | Should -Not -BeLike '*averylongtokenvalue1234567890*' $inner.Message | Should -Not -BeLike '*averylongtokenvalue1234567890*' } } Describe 'Module manifest and export' { It 'exports exactly the phase 1 + phase 2 + phase 3 + phase 4 cmdlets' { $sm = Get-Module N2C.GuacAdmin # ExportedFunctions is a plain hashtable: compare as a set, not by order. $expected = @( 'New-GuacSession', 'Get-GuacSession', 'Remove-GuacSession', 'Test-GuacSession', 'Set-GuacDataSource', 'Get-GuacConnection', 'New-GuacConnection', 'Update-GuacConnection', 'Remove-GuacConnection', 'Get-GuacConnectionGroup', 'Get-GuacConnectionGroupTree', 'New-GuacConnectionGroup', 'Update-GuacConnectionGroup', 'Remove-GuacConnectionGroup', 'Get-GuacUser', 'New-GuacUser', 'Update-GuacUser', 'Remove-GuacUser', 'Set-GuacUserPassword', 'Get-GuacUserGroup', 'New-GuacUserGroup', 'Update-GuacUserGroup', 'Remove-GuacUserGroup', 'Get-GuacSharingProfile', 'New-GuacSharingProfile', 'Update-GuacSharingProfile', 'Remove-GuacSharingProfile', 'Add-GuacPermission', 'Remove-GuacPermission', 'Add-GuacUserGroupMember', 'Remove-GuacUserGroupMember', 'Add-GuacUserGroupChildGroup', 'Remove-GuacUserGroupChildGroup', 'Get-GuacHistoryConnections', 'Get-GuacHistoryUsers', 'Get-GuacSchema', 'Get-GuacProtocol', 'Get-GuacActiveConnection', 'Stop-GuacActiveConnection', 'Get-GuacSharingCredential', 'Get-GuacTunnel', 'Get-GuacLanguage', 'Get-GuacPatches', 'Get-GuacExtension', 'New-GuacActiveSession', 'Send-GuacInstruction', 'Receive-GuacInstruction', 'Remove-GuacActiveSession' ) $actual = @($sm.ExportedFunctions.Keys) $actual.Count | Should -Be $expected.Count foreach ($name in $expected) { $actual -contains $name | Should -BeTrue } } It 'has PSData metadata (Tags, ReleaseNotes, ProjectUri)' { $manifest = Import-PowerShellDataFile (Join-Path $moduleRoot 'N2C.GuacAdmin.psd1') $manifest.PrivateData.PSData.Tags | Should -Not -BeNullOrEmpty $manifest.PrivateData.PSData.ReleaseNotes | Should -Not -BeNullOrEmpty $manifest.PrivateData.PSData.ProjectUri | Should -BeLike 'https://*' $manifest.PowerShellVersion | Should -Be '5.1' } } |