Tests/GuacMockServer.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS In-process mock of the Apache Guacamole 1.6.0 REST API for Pester tests. .DESCRIPTION Emulates the endpoints the module uses, with the same response shapes as the 1.6.0 source: - POST /api/tokens (TokenRESTService.createToken) - DELETE /api/tokens/{t} (TokenRESTService.invalidateToken) - HEAD /api/session (SessionResource.checkValidity) - per-DataSource UserContext surface: connections, connectionGroups (incl. {id}/tree), users (incl. {username}/password, {username}/permissions, {username}/effectivePermissions, {username}/userGroups, {username}/history), userGroups (incl. memberUsers, memberUserGroups, permissions), sharingProfiles, history/connections, history/users, schema/{set}, schema/protocols - GET /api/notfound (always 404 with an APIError JSON body) - GET /api/plaintext-error (always 500 with a non-JSON body) Valid credentials: username "guacadmin", password "secret". The 'mysql' data source is seeded with entities; 'ldap' starts empty. Every request is appended as a JSON line to the log file (see -LogFile) so tests can assert what the client actually sent. Control endpoints (/_control/*) let tests toggle behavior without shared state. Reference (Apache Guacamole 1.6.0): the per-endpoint Java sources cited in the module cmdlets (DirectoryResource, DirectoryObjectResource, PermissionSetResource, RelatedObjectSetResource, ActivityRecordSetResource, SchemaResource, UserResource, ConnectionGroupResource). #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [int] $Port, [Parameter(Mandatory = $true)] [string] $LogFile ) $utf8 = [System.Text.Encoding]::UTF8 $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add(('http://127.0.0.1:{0}/guacamole/' -f $Port)) $listener.Start() $validTokens = @{} $requireTotp = $false Write-Output 'READY' Write-Output ('PORT {0}' -f $Port) function Send-Response { param ( [System.Net.HttpListenerContext] $Context, [int] $Code, [string] $ContentType, [string] $Body ) $response = $Context.Response $response.StatusCode = $Code if ($ContentType) { $response.ContentType = $ContentType } if ($Body -and $Context.Request.HttpMethod -ne 'HEAD' -and $Code -ne 204) { $bytes = $utf8.GetBytes($Body) $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.OutputStream.Flush() } $response.OutputStream.Close() } function Send-Json { param ( [System.Net.HttpListenerContext] $Context, [int] $Code, [object] $Object ) Send-Response -Context $Context -Code $Code -ContentType 'application/json' ` -Body ($Object | ConvertTo-Json -Compress -Depth 12) } function Send-ApiError { param ( [System.Net.HttpListenerContext] $Context, [int] $Code, [string] $Type, [string] $Message ) $safeMessage = $Message.Replace('"', "'") $safeType = [string]$Type $body = ('{{"message":"{0}","type":"{1}"}}' -f ($safeMessage, $safeType)) Send-Response -Context $Context -Code $Code -ContentType 'application/json' -Body $body } # --------------------------------------------------------------------------- # Per-DataSource UserContext store # --------------------------------------------------------------------------- function New-GuacMockUserContext { return [ordered]@{ Seeded = $false connections = @{} connectionGroups = @{} users = @{} userGroups = @{} sharingProfiles = @{} activeConnections = @{} permissions = @{} memberships = @{} history = [ordered]@{ connections = @(); users = @() } tunnels = @{} } } function New-GuacMockEmptyPermissionSet { return [ordered]@{ connectionPermissions = @{} connectionGroupPermissions = @{} sharingProfilePermissions = @{} activeConnectionPermissions = @{} userPermissions = @{} userGroupPermissions = @{} systemPermissions = @() } } function New-GuacMockSeededContext { # NOTE: $Ctx must be left untyped (NOT [hashtable]) — an [ordered] # @{} converted to [hashtable] at the parameter boundary becomes a COPY, # so any mutation would be lost. PowerShell passes the OrderedDictionary # by reference when no type conversion is forced. param ($Ctx) if ($Ctx['Seeded']) { return } $Ctx['connections'] = @{} $Ctx['connections']['conn-1'] = [ordered]@{ name = 'test-connection'; protocol = 'rdp' parameters = @{ hostname = 'host.example.com' } parentIdentifier = 'ROOT'; attributes = @{} maximumConnections = -1; lastActive = 1767261600000 } $Ctx['connections']['conn-2'] = [ordered]@{ name = 'ssh-server'; protocol = 'ssh' parameters = @{ hostname = 'ssh.example.com'; port = '22' } parentIdentifier = 'ROOT'; attributes = @{} maximumConnections = -1; lastActive = 1767263400000 } $Ctx['connectionGroups'] = @{} $Ctx['connectionGroups']['group-1'] = [ordered]@{ name = 'prod'; type = 'ORGANIZATIONAL'; parentIdentifier = 'ROOT'; attributes = @{} } $Ctx['connectionGroups']['group-2'] = [ordered]@{ name = 'staging'; type = 'ORGANIZATIONAL'; parentIdentifier = 'group-1'; attributes = @{} } # NOTE: seeded users carry the "username" field (APIUser.externalUser has # it; the single-object endpoint returns the stored record as-is). $Ctx['users'] = @{} $Ctx['users']['guacadmin'] = [ordered]@{ username = 'guacadmin'; password = 'secret'; disabled = $false attributes = @{ 'guac-full-name' = 'Guac Admin' }; lastActive = 1767261600000 } $Ctx['users']['jdoe'] = [ordered]@{ username = 'jdoe'; password = 'jdoe-pass'; disabled = $false; attributes = @{}; lastActive = 1767263400000 } $Ctx['userGroups'] = @{} $Ctx['userGroups']['admins'] = [ordered]@{ identifier = 'admins'; disabled = $false; attributes = @{} } $Ctx['userGroups']['auditors'] = [ordered]@{ identifier = 'auditors'; disabled = $false; attributes = @{} } $Ctx['sharingProfiles'] = @{} $Ctx['sharingProfiles']['sp-readonly'] = [ordered]@{ name = 'read-only'; primaryConnectionIdentifier = 'conn-1' parameters = @{ 'guac-readonly' = 'true' }; attributes = @{} } # NOTE: permission sets are keyed "collection:id" (for example # "users:jdoe"), matching the subject key used by the permissions GET/PATCH # handler below. $admin = New-GuacMockEmptyPermissionSet $admin['systemPermissions'] = @('ADMINISTER') $Ctx['permissions']['users:guacadmin'] = $admin $jdoe = New-GuacMockEmptyPermissionSet $jdoe['connectionPermissions'] = @{} $jdoe['connectionPermissions']['conn-1'] = @('READ') $Ctx['permissions']['users:jdoe'] = $jdoe $Ctx['memberships']['admins'] = @{ memberUsers = @('guacadmin'); memberUserGroups = @() } $Ctx['memberships']['auditors'] = @{ memberUsers = @(); memberUserGroups = @() } $Ctx['history'] = [ordered]@{ connections = @( [ordered]@{ identifier = 'rec-1'; connectionIdentifier = 'conn-1'; connectionName = 'test-connection' username = 'guacadmin'; remoteHost = '10.0.0.5' startDate = 1767261600000; endDate = 1767263400000 active = $false; uuid = [System.Guid]::NewGuid(); attributes = @{}; logs = @{} sharingProfileIdentifier = $null; sharingProfileName = $null } ) users = @( [ordered]@{ identifier = 'urec-1'; username = 'guacadmin'; remoteHost = '10.0.0.5' startDate = 1767261600000; endDate = 1767263400000 active = $false; uuid = [System.Guid]::NewGuid(); attributes = @{}; logs = @{} } ) } # Seed a couple of active connections for testing # Using UUID-style identifiers to match real Guacamole behavior $Ctx['activeConnections'] = @{} $Ctx['activeConnections']['da7cb142-d5f5-3683-ad22-98b1a1f132b4'] = [ordered]@{ identifier = 'da7cb142-d5f5-3683-ad22-98b1a1f132b4' connectionIdentifier = 'conn-1' startDate = 1767261600000 remoteHost = '10.0.0.5' username = 'guacadmin' connectable = $true } $Ctx['activeConnections']['e3f4a5b6-c7d8-9012-efab-345678901234'] = [ordered]@{ identifier = 'e3f4a5b6-c7d8-9012-efab-345678901234' connectionIdentifier = 'conn-2' startDate = 1767263400000 remoteHost = '192.168.1.10' username = 'jdoe' connectable = $true } $Ctx['Seeded'] = $true } $mockSchemaSets = [ordered]@{ userAttributes = @( [ordered]@{ identifier = 'guac-full-name'; name = 'Full name'; label = 'Full name'; description = ''; order = 0; options = @{} } [ordered]@{ identifier = 'guac-email-address'; name = 'Email address'; label = 'Email address'; description = ''; order = 1; options = @{} } ) userPreferenceAttributes = @( [ordered]@{ identifier = 'guac-timezone'; name = 'Timezone'; label = 'Timezone'; description = ''; order = 0; options = @{} } [ordered]@{ identifier = 'guac-language'; name = 'Language'; label = 'Language'; description = ''; order = 1; options = @{} } ) userGroupAttributes = @( [ordered]@{ identifier = 'guac-description'; name = 'Description'; label = 'Description'; description = ''; order = 0; options = @{} } ) connectionAttributes = @( [ordered]@{ identifier = 'guac-clipboard'; name = 'Clipboard'; label = 'Clipboard'; description = ''; order = 0; options = @{ 'READWRITE' = 'Read / Write' } } [ordered]@{ identifier = 'guac-readonly'; name = 'Read only'; label = 'Read only'; description = ''; order = 1; options = @{} } [ordered]@{ identifier = 'guac-resize-method'; name = 'Resize method'; label = 'Resize method'; description = ''; order = 2; options = @{} } ) sharingProfileAttributes = @( [ordered]@{ identifier = 'guac-reconnect'; name = 'Reconnect'; label = 'Reconnect'; description = ''; order = 0; options = @{} } ) connectionGroupAttributes = @( [ordered]@{ identifier = 'guac-failover-method'; name = 'Failover method'; label = 'Failover method'; description = ''; order = 0; options = @{} } ) } $mockProtocols = [ordered]@{ ssh = [ordered]@{ name = 'ssh' connectionForms = @([ordered]@{ identifier = 'SSH'; name = 'SSH'; order = 0; options = @{}; fields = @('hostname', 'port', 'username', 'password', 'guac-readonly') }) sharingProfileForms = @([ordered]@{ identifier = 'SHARING'; name = 'Sharing'; order = 0; options = @{}; fields = @('guac-reconnect') }) } rdp = [ordered]@{ name = 'rdp' connectionForms = @([ordered]@{ identifier = 'RDP'; name = 'RDP'; order = 0; options = @{}; fields = @('hostname', 'port', 'username', 'password', 'guac-readonly', 'guac-clipboard') }) sharingProfileForms = @() } vnc = [ordered]@{ name = 'vnc' connectionForms = @([ordered]@{ identifier = 'VNC'; name = 'VNC'; order = 0; options = @{}; fields = @('hostname', 'port', 'password', 'guac-readonly') }) sharingProfileForms = @() } telnet = [ordered]@{ name = 'telnet' connectionForms = @([ordered]@{ identifier = 'TELNET'; name = 'Telnet'; order = 0; options = @{}; fields = @('hostname', 'port', 'guac-readonly') }) sharingProfileForms = @() } kubernetes = [ordered]@{ name = 'kubernetes' connectionForms = @([ordered]@{ identifier = 'KUBERNETES'; name = 'Kubernetes'; order = 0; options = @{}; fields = @('hostname', 'port', 'guac-readonly') }) sharingProfileForms = @() } } # Collection name -> store key (same in all data sources). $collectionKeys = [ordered]@{ connections = 'connections' connectionGroups = 'connectionGroups' users = 'users' userGroups = 'userGroups' sharingProfiles = 'sharingProfiles' } function Get-MockUserContext { param ([string] $DataSource) if (-not $userContexts.ContainsKey($DataSource)) { $userContexts[$DataSource] = New-GuacMockUserContext } $ctx = $userContexts[$DataSource] if ($DataSource -eq 'mysql') { New-GuacMockSeededContext -Ctx $ctx } return $ctx } function Get-MockUserContextCollection { param ($Ctx, [string] $Collection) if (-not $collectionKeys.Contains($Collection)) { return $null } return $Ctx[[string]$collectionKeys[$Collection]] } # Identifier of a newly created object, per collection type. function Get-MockCreatedIdentifier { param ([string] $Collection, [object] $Body) if ($Collection -eq 'users') { return [string]$Body.username } if ($Collection -eq 'userGroups') { return [string]$Body.identifier } return [System.Guid]::NewGuid().ToString() } # Full-replace the mutable fields of an object, per collection type # (mirrors each *ObjectTranslator.applyExternalChanges; the user password is # conditional). function Update-MockEntity { param ([string] $Collection, $Existing, [object] $Body) switch ($Collection) { 'connections' { $Existing['name'] = [string]$Body.name $Existing['protocol'] = [string]$Body.protocol $Existing['parameters'] = (Get-MockMap $Body.parameters) $Existing['parentIdentifier'] = [string]$Body.parentIdentifier $Existing['attributes'] = (Get-MockMap $Body.attributes) } 'connectionGroups' { $Existing['name'] = [string]$Body.name $Existing['type'] = [string]$Body.type $Existing['parentIdentifier'] = [string]$Body.parentIdentifier $Existing['attributes'] = (Get-MockMap $Body.attributes) } 'users' { # Password only when present (UserObjectTranslator). if ($null -ne $Body.password) { $Existing['password'] = [string]$Body.password } $Existing['disabled'] = [bool]$Body.disabled $Existing['attributes'] = (Get-MockMap $Body.attributes) } 'userGroups' { $Existing['disabled'] = [bool]$Body.disabled $Existing['attributes'] = (Get-MockMap $Body.attributes) } 'sharingProfiles' { $Existing['name'] = [string]$Body.name $Existing['primaryConnectionIdentifier'] = [string]$Body.primaryConnectionIdentifier $Existing['parameters'] = (Get-MockMap $Body.parameters) $Existing['attributes'] = (Get-MockMap $Body.attributes) } } } function Get-MockMap { param ([object] $Value) if ($null -eq $Value) { return @{} } if ($Value -is [System.Collections.IDictionary]) { $map = @{} foreach ($key in $Value.Keys) { $map[[string]$key] = $Value[$key] } return $map } $map = @{} foreach ($prop in $Value.PSObject.Properties) { $map[$prop.Name] = $prop.Value } return $map } function Get-MockBool { param ([object] $Value) if ($null -eq $Value) { return $false } return [bool]$Value } # Build the connection group tree (ConnectionGroupResource.getConnectionGroupTree). function Get-MockGroupTree { param ($Ctx, [string] $Id) # ROOT is an implicit root group that always exists, even with no entities. if ($Id -eq 'ROOT') { $group = [ordered]@{ name = 'ROOT'; type = 'ORGANIZATIONAL'; parentIdentifier = ''; attributes = @{} } } else { $group = $Ctx['connectionGroups'][$Id] if ($null -eq $group) { return $null } } $children = [ordered]@{} foreach ($otherId in @($Ctx['connectionGroups'].Keys)) { $other = $Ctx['connectionGroups'][$otherId] if ([string]$other['parentIdentifier'] -eq $Id) { $subTree = Get-MockGroupTree -Ctx $Ctx -Id $otherId if ($null -ne $subTree) { $children[[string]$otherId] = $subTree } } } $childConns = [ordered]@{} foreach ($connId in @($Ctx['connections'].Keys)) { $conn = $Ctx['connections'][$connId] if ([string]$conn['parentIdentifier'] -eq $Id) { $childConns[[string]$connId] = $conn } } return [ordered]@{ identifier = $Id name = $group['name'] type = $group['type'] parentIdentifier = $group['parentIdentifier'] activeConnections = @() childConnectionGroups = $children childConnections = $childConns attributes = $group['attributes'] } } # Permission-set PATCH handling (PermissionSetResource.patchPermissions). function Invoke-MockPermissionPatch { param ($Ctx, [string] $SubjectKey, [object] $Operations) if (-not $Ctx['permissions'].ContainsKey($SubjectKey)) { $Ctx['permissions'][$SubjectKey] = (New-GuacMockEmptyPermissionSet) } $set = $Ctx['permissions'][$SubjectKey] $prefixToCategory = [ordered]@{ '/connectionPermissions/' = 'connectionPermissions' '/connectionGroupPermissions/' = 'connectionGroupPermissions' '/sharingProfilePermissions/' = 'sharingProfilePermissions' '/activeConnectionPermissions/' = 'activeConnectionPermissions' '/userPermissions/' = 'userPermissions' '/userGroupPermissions/' = 'userGroupPermissions' } $categoryTarget = [ordered]@{ connectionPermissions = 'connections' connectionGroupPermissions = 'connectionGroups' sharingProfilePermissions = 'sharingProfiles' activeConnectionPermissions = 'activeConnections' userPermissions = 'users' userGroupPermissions = 'userGroups' } foreach ($operation in @($Operations)) { $op = [string]$operation.op $path = [string]$operation.path $type = [string]$operation.value if ($path -eq '/systemPermissions') { $types = @($set['systemPermissions']) if ($op -eq 'add') { if ($types -notcontains $type) { $types += $type } } elseif ($op -eq 'remove') { $types = @($types | Where-Object { $_ -ne $type }) } $set['systemPermissions'] = $types continue } $matched = $false foreach ($prefix in $prefixToCategory.Keys) { if ($path.StartsWith($prefix)) { $matched = $true $category = [string]$prefixToCategory[$prefix] $targetId = $path.Substring($prefix.Length) if ($category -eq 'activeConnectionPermissions') { # Active connections are runtime state; reject like the # server does when the record is not found. Send-ApiError -Context $script:currentContext -Code 404 -Type 'NOT_FOUND' -Message ('No such active connection: ' + $targetId) return $false } $store = $Ctx[[string]$categoryTarget[$category]] if ($null -eq $store -or -not $store.ContainsKey($targetId)) { Send-ApiError -Context $script:currentContext -Code 404 -Type 'NOT_FOUND' -Message ('No such object: ' + $targetId) return $false } $map = $set[$category] if (-not $map.ContainsKey($targetId)) { $map[$targetId] = @() } $types = @($map[$targetId]) if ($op -eq 'add') { if ($types -notcontains $type) { $types += $type } } elseif ($op -eq 'remove') { $types = @($types | Where-Object { $_ -ne $type }) if ($types.Count -eq 0) { $map.Remove($targetId); continue 2 } } $map[$targetId] = $types break } } if (-not $matched) { Send-ApiError -Context $script:currentContext -Code 400 -Type 'UNSUPPORTED' -Message ('Unsupported patch path: ' + $path) return $false } } return $true } # Related-object-set PATCH handling (RelatedObjectSetResource.patchObjects). function Invoke-MockRelatedSetPatch { param ( $Ctx, [string] $Group, [string] $SubPath, [object] $Operations ) if (-not $Ctx['userGroups'].ContainsKey($Group)) { Send-ApiError -Context $script:currentContext -Code 404 -Type 'NOT_FOUND' -Message ('No such user group: ' + $Group) return $false } if (-not $Ctx['memberships'].ContainsKey($Group)) { $Ctx['memberships'][$Group] = @{ memberUsers = @(); memberUserGroups = @() } } $members = $Ctx['memberships'][$Group] $key = if ($SubPath -eq 'memberUsers') { 'memberUsers' } else { 'memberUserGroups' } $targetStore = if ($SubPath -eq 'memberUsers') { $Ctx['users'] } else { $Ctx['userGroups'] } foreach ($operation in @($Operations)) { $op = [string]$operation.op if ([string]$operation.path -ne '/') { Send-ApiError -Context $script:currentContext -Code 400 -Type 'UNSUPPORTED' -Message ('Unsupported patch path: ' + [string]$operation.path) return $false } $identifier = [string]$operation.value if (-not $targetStore.ContainsKey($identifier)) { Send-ApiError -Context $script:currentContext -Code 404 -Type 'NOT_FOUND' -Message ('No such object: ' + $identifier) return $false } $list = @($members[$key]) if ($op -eq 'add') { if ($list -notcontains $identifier) { $list += $identifier } } elseif ($op -eq 'remove') { $list = @($list | Where-Object { $_ -ne $identifier }) } $members[$key] = $list } return $true } # Parse a JSON array body that may arrive as a single object (5.1 unwraps # single-element arrays in ConvertFrom-Json). function ConvertTo-GuacMockOperationArray { param ([object] $Decoded) if ($null -eq $Decoded) { return @() } if ($Decoded -is [System.Management.Automation.PSCustomObject]) { if ($Decoded.PSObject.Properties['op']) { return @($Decoded) } return @() } return @($Decoded) } $userContexts = @{} while ($listener.IsListening) { $context = $null try { $context = $listener.GetContext() } catch [System.Exception] { break } $script:currentContext = $context $request = $context.Request $method = $request.HttpMethod.ToUpperInvariant() $relPath = $request.Url.AbsolutePath -replace '^/guacamole', '' $tokenHeader = $request.Headers['Guacamole-Token'] $queryToken = $null try { $queryString = $request.Url.Query if ($queryString -and $queryString.StartsWith('?')) { $queryString = $queryString.Substring(1) } foreach ($pair in @($queryString -split '&')) { $kv = $pair -split '=', 2 if ($kv.Count -eq 2 -and $kv[0] -eq 'token') { $queryToken = [uri]::UnescapeDataString($kv[1]) } } } catch [System.Exception] { $queryToken = $null } $token = $tokenHeader if ([string]::IsNullOrEmpty($token) -and $queryToken) { $token = $queryToken } $bodyReader = [System.IO.StreamReader]::new($request.InputStream) $body = $bodyReader.ReadToEnd() $bodyReader.Dispose() # Record the request (JSON line). $record = [ordered]@{ method = $method path = $relPath query = [string]$request.Url.Query token = $token tokenViaHeader = [bool]$tokenHeader body = $body contentType = [string]$request.ContentType } Add-Content -LiteralPath $LogFile -Value ($record | ConvertTo-Json -Compress -Depth 5) -Encoding UTF8 # ---- Control endpoints ---- if ($relPath -eq '/_control/totp-enable') { $requireTotp = $true Send-Response -Context $context -Code 200 -ContentType 'application/json' -Body '{}' continue } if ($relPath -eq '/_control/totp-disable') { $requireTotp = $false Send-Response -Context $context -Code 200 -ContentType 'application/json' -Body '{}' continue } # ---- Auth endpoints ---- if ($relPath -eq '/api/tokens' -and $method -eq 'POST') { $fields = @{} foreach ($pair in @($body -split '&')) { if ([string]::IsNullOrEmpty($pair)) { continue } $kv = $pair -split '=', 2 $k = [uri]::UnescapeDataString($kv[0]) $v = [uri]::UnescapeDataString($kv[1]) $fields[$k] = $v } $username = [string]$fields['username'] $password = [string]$fields['password'] $totp = [string]$fields['guac-totp'] if ($username -ne 'guacadmin' -or $password -ne 'secret') { Send-ApiError -Context $context -Code 401 -Type 'INVALID_CREDENTIALS' -Message 'Invalid credentials.' continue } if ($requireTotp) { if ([string]::IsNullOrEmpty($totp)) { Send-ApiError -Context $context -Code 401 -Type 'INSUFFICIENT_CREDENTIALS' -Message 'A TOTP authentication code is required before login can continue.' continue } if ($totp -ne '123456') { Send-ApiError -Context $context -Code 401 -Type 'INVALID_CREDENTIALS' -Message 'Provided TOTP code is not valid.' continue } } $newToken = ('mocktoken-' + [System.Guid]::NewGuid().ToString('N')) $validTokens[$newToken] = $true $result = [ordered]@{ authToken = $newToken username = $username dataSource = 'mysql' availableDataSources = @('mysql', 'ldap') } Send-Json -Context $context -Code 200 -Object $result continue } if ($relPath -like '/api/tokens/*' -and $method -eq 'DELETE') { $tokenPath = $relPath.Substring('/api/tokens/'.Length) if ($validTokens.ContainsKey($tokenPath)) { $validTokens.Remove($tokenPath) Send-Response -Context $context -Code 204 -ContentType $null -Body $null } else { Send-ApiError -Context $context -Code 401 -Type 'NOT_FOUND' -Message 'No such token.' } continue } # Everything under /api/session requires a valid token. if ($relPath -like '/api/session*') { if ([string]::IsNullOrEmpty($token) -or -not $validTokens.ContainsKey($token)) { Send-ApiError -Context $context -Code 401 -Type 'NOT_FOUND' -Message 'No such token.' continue } if ($relPath -eq '/api/session' -and $method -eq 'HEAD') { Send-Response -Context $context -Code 200 -ContentType $null -Body $null continue } # ---- Per-DataSource UserContext surface ---- if ($relPath -like '/api/session/data/*') { $segments = @($relPath.Substring('/api/session/data/'.Length) -split '/') $dataSource = [uri]::UnescapeDataString($segments[0]) $ctx = Get-MockUserContext -DataSource $dataSource if ($segments.Count -ge 2 -and $segments[1] -eq 'self' -and $method -eq 'GET') { $self = [ordered]@{ identifier = 'guacadmin' username = 'guacadmin' disabled = $false attributes = $ctx['users']['guacadmin']['attributes'] } Send-Json -Context $context -Code 200 -Object $self continue } if ($segments.Count -ge 2 -and $collectionKeys.Contains($segments[1])) { $collection = [string]$segments[1] $store = Get-MockUserContextCollection -Ctx $ctx -Collection $collection if ($segments.Count -eq 2) { if ($method -eq 'GET') { Send-Json -Context $context -Code 200 -Object $store continue } if ($method -eq 'POST') { $decoded = $body | ConvertFrom-Json -Depth 12 $identifier = Get-MockCreatedIdentifier -Collection $collection -Body $decoded if ([string]::IsNullOrWhiteSpace([string]$identifier)) { Send-ApiError -Context $context -Code 400 -Type 'BAD_REQUEST' -Message 'No identifier supplied.' continue } $object = [ordered]@{ attributes = (Get-MockMap $decoded.attributes) } switch ($collection) { 'connections' { $object['name'] = [string]$decoded.name $object['protocol'] = [string]$decoded.protocol $object['parameters'] = (Get-MockMap $decoded.parameters) $object['parentIdentifier'] = [string]$decoded.parentIdentifier $object['maximumConnections'] = -1 $object['lastActive'] = $null } 'connectionGroups' { $object['name'] = [string]$decoded.name $object['type'] = [string]$decoded.type $object['parentIdentifier'] = [string]$decoded.parentIdentifier } 'users' { $object['username'] = $identifier $object['password'] = [string]$decoded.password $object['disabled'] = [bool]$decoded.disabled $object['lastActive'] = $null } 'userGroups' { $object['identifier'] = $identifier $object['disabled'] = [bool]$decoded.disabled } 'sharingProfiles' { $object['name'] = [string]$decoded.name $object['primaryConnectionIdentifier'] = [string]$decoded.primaryConnectionIdentifier $object['parameters'] = (Get-MockMap $decoded.parameters) } } if ($collection -ne 'users') { $object['identifier'] = $identifier } $store[$identifier] = $object Send-Json -Context $context -Code 200 -Object $object continue } if ($method -eq 'PATCH') { $operations = ConvertTo-GuacMockOperationArray ($body | ConvertFrom-Json -Depth 12) $outcomes = @() foreach ($operation in $operations) { $op = [string]$operation.op $path = [string]$operation.path $identifier = [string]::Empty if ($op -eq 'add') { $identifier = Get-MockCreatedIdentifier -Collection $collection -Body $operation.value if ([string]::IsNullOrWhiteSpace($identifier)) { Send-ApiError -Context $context -Code 400 -Type 'API_PATCH_FAILURE' -Message 'No identifier supplied.' continue } $object = [ordered]@{ attributes = (Get-MockMap $operation.value.attributes) } switch ($collection) { 'connections' { $object['name'] = [string]$operation.value.name $object['protocol'] = [string]$operation.value.protocol $object['parameters'] = (Get-MockMap $operation.value.parameters) $object['parentIdentifier'] = [string]$operation.value.parentIdentifier $object['maximumConnections'] = -1 $object['lastActive'] = $null } 'connectionGroups' { $object['name'] = [string]$operation.value.name $object['type'] = [string]$operation.value.type $object['parentIdentifier'] = [string]$operation.value.parentIdentifier } 'users' { $object['username'] = $identifier $object['password'] = [string]$operation.value.password $object['disabled'] = [bool]$operation.value.disabled $object['lastActive'] = $null } 'userGroups' { $object['identifier'] = $identifier $object['disabled'] = [bool]$operation.value.disabled } 'sharingProfiles' { $object['name'] = [string]$operation.value.name $object['primaryConnectionIdentifier'] = [string]$operation.value.primaryConnectionIdentifier $object['parameters'] = (Get-MockMap $operation.value.parameters) } } if ($collection -ne 'users') { $object['identifier'] = $identifier } $store[$identifier] = $object } elseif ($op -eq 'replace') { $identifier = $path.TrimStart('/') if (-not $store.ContainsKey($identifier)) { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such object: ' + $identifier) continue } Update-MockEntity -Collection $collection -Existing $store[$identifier] -Body $operation.value } elseif ($op -eq 'remove') { $identifier = $path.TrimStart('/') if (-not $store.ContainsKey($identifier)) { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such object: ' + $identifier) continue } $store.Remove($identifier) if ($collection -eq 'userGroups') { $ctx['memberships'].Remove($identifier) } } $outcomes += [ordered]@{ op = $op; identifier = $identifier; path = $path } } Send-Json -Context $context -Code 200 -Object ([ordered]@{ patches = $outcomes }) continue } } else { $id = [uri]::UnescapeDataString($segments[2]) if (-not $store.ContainsKey($id) -and -not ($collection -eq 'connectionGroups' -and $id -eq 'ROOT')) { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such object: ' + $id) continue } if ($segments.Count -eq 3) { if ($method -eq 'GET') { Send-Json -Context $context -Code 200 -Object $store[$id] continue } if ($method -eq 'PUT') { $decoded = $body | ConvertFrom-Json -Depth 12 # A user may not change their own password here # (UserResource.updateObject). if ($collection -eq 'users' -and $id -eq 'guacadmin' -and $null -ne $decoded.password) { Send-ApiError -Context $context -Code 403 -Type 'PERMISSION_DENIED' -Message 'Permission denied. The password update endpoint must be used to change the current user''s password.' continue } Update-MockEntity -Collection $collection -Existing $store[$id] -Body $decoded Send-Response -Context $context -Code 204 -ContentType $null -Body $null continue } if ($method -eq 'DELETE') { $store.Remove($id) if ($collection -eq 'userGroups') { $ctx['memberships'].Remove($id) } Send-Response -Context $context -Code 204 -ContentType $null -Body $null continue } } # ---- Object subresources ---- if ($segments.Count -ge 4) { $sub = $segments[3] # Connection group tree. if ($collection -eq 'connectionGroups' -and $sub -eq 'tree' -and $method -eq 'GET') { $tree = Get-MockGroupTree -Ctx $ctx -Id $id if ($null -eq $tree) { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such connection group: ' + $id) continue } Send-Json -Context $context -Code 200 -Object $tree continue } # User: password change. if ($collection -eq 'users' -and $sub -eq 'password' -and $method -eq 'PUT') { $decoded = $body | ConvertFrom-Json -Depth 6 $oldPassword = [string]$decoded.oldPassword $newPassword = [string]$decoded.newPassword if ([string]$store[$id]['password'] -ne $oldPassword) { Send-ApiError -Context $context -Code 403 -Type 'PERMISSION_DENIED' -Message 'Permission denied.' continue } $store[$id]['password'] = $newPassword Send-Response -Context $context -Code 204 -ContentType $null -Body $null continue } # User / user group: permission set. if (($collection -eq 'users' -or $collection -eq 'userGroups') -and $sub -eq 'permissions') { $subjectKey = ($collection + ':' + $id) if ($method -eq 'GET') { if (-not $ctx['permissions'].ContainsKey($subjectKey)) { $ctx['permissions'][$subjectKey] = (New-GuacMockEmptyPermissionSet) } Send-Json -Context $context -Code 200 -Object $ctx['permissions'][$subjectKey] continue } if ($method -eq 'PATCH') { $operations = ConvertTo-GuacMockOperationArray ($body | ConvertFrom-Json -Depth 8) $ok = Invoke-MockPermissionPatch -Ctx $ctx -SubjectKey $subjectKey -Operations $operations if (-not $ok) { continue } Send-Response -Context $context -Code 204 -ContentType $null -Body $null continue } } # User: effective permissions (same shape as the # directly-granted set in this mock). if ($collection -eq 'users' -and $sub -eq 'effectivePermissions' -and $method -eq 'GET') { # Same "collection:id" subject key as the permissions # handler, so the seeded set is what is returned. $subjectKey = ($collection + ':' + $id) if (-not $ctx['permissions'].ContainsKey($subjectKey)) { $ctx['permissions'][$subjectKey] = (New-GuacMockEmptyPermissionSet) } Send-Json -Context $context -Code 200 -Object $ctx['permissions'][$subjectKey] continue } # User: the user groups the user belongs to. if ($collection -eq 'users' -and $sub -eq 'userGroups') { $ids = @() foreach ($groupId in @($ctx['memberships'].Keys)) { $members = $ctx['memberships'][$groupId] if (@($members['memberUsers']) -contains $id) { $ids += [string]$groupId } } if ($method -eq 'GET') { Send-Json -Context $context -Code 200 -Object $ids continue } if ($method -eq 'PATCH') { $operations = ConvertTo-GuacMockOperationArray ($body | ConvertFrom-Json -Depth 8) foreach ($operation in $operations) { $groupId = [string]$operation.value $op = [string]$operation.op if (-not $ctx['userGroups'].ContainsKey($groupId)) { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such user group: ' + $groupId) continue } if (-not $ctx['memberships'].ContainsKey($groupId)) { $ctx['memberships'][$groupId] = @{ memberUsers = @(); memberUserGroups = @() } } $list = @($ctx['memberships'][$groupId]['memberUsers']) if ($op -eq 'add') { if ($list -notcontains $id) { $list += $id } } elseif ($op -eq 'remove') { $list = @($list | Where-Object { $_ -ne $id }) } $ctx['memberships'][$groupId]['memberUsers'] = $list } Send-Response -Context $context -Code 204 -ContentType $null -Body $null continue } } # User: history records for this user. if ($collection -eq 'users' -and $sub -eq 'history' -and $method -eq 'GET') { $records = @($ctx['history']['users'] | Where-Object { [string]$_.username -eq $id }) Send-Json -Context $context -Code 200 -Object $records continue } # User group: member sets. if ($collection -eq 'userGroups' -and ($sub -eq 'memberUsers' -or $sub -eq 'memberUserGroups')) { if ($method -eq 'GET') { if (-not $ctx['memberships'].ContainsKey($id)) { $ctx['memberships'][$id] = @{ memberUsers = @(); memberUserGroups = @() } } Send-Json -Context $context -Code 200 -Object $ctx['memberships'][$id][$sub] continue } if ($method -eq 'PATCH') { $operations = ConvertTo-GuacMockOperationArray ($body | ConvertFrom-Json -Depth 8) $ok = Invoke-MockRelatedSetPatch -Ctx $ctx -Group $id -SubPath $sub -Operations $operations if (-not $ok) { continue } Send-Response -Context $context -Code 204 -ContentType $null -Body $null continue } } # User group: the user groups this group belongs to. if ($collection -eq 'userGroups' -and $sub -eq 'userGroups') { $ids = @() foreach ($groupId in @($ctx['memberships'].Keys)) { $members = $ctx['memberships'][$groupId] if (@($members['memberUserGroups']) -contains $id) { $ids += [string]$groupId } } if ($method -eq 'GET') { Send-Json -Context $context -Code 200 -Object $ids continue } if ($method -eq 'PATCH') { $operations = ConvertTo-GuacMockOperationArray ($body | ConvertFrom-Json -Depth 8) foreach ($operation in $operations) { $groupId = [string]$operation.value $op = [string]$operation.op if (-not $ctx['userGroups'].ContainsKey($groupId)) { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such user group: ' + $groupId) continue } if (-not $ctx['memberships'].ContainsKey($groupId)) { $ctx['memberships'][$groupId] = @{ memberUsers = @(); memberUserGroups = @() } } $list = @($ctx['memberships'][$groupId]['memberUserGroups']) if ($op -eq 'add') { if ($list -notcontains $id) { $list += $id } } elseif ($op -eq 'remove') { $list = @($list | Where-Object { $_ -ne $id }) } $ctx['memberships'][$groupId]['memberUserGroups'] = $list } Send-Response -Context $context -Code 204 -ContentType $null -Body $null continue } } } } } # Active connections surface (Phase 3). if ($segments.Count -ge 2 -and $segments[1] -eq 'activeConnections') { $activeConns = $ctx['activeConnections'] if ($segments.Count -eq 2) { if ($method -eq 'GET') { Send-Json -Context $context -Code 200 -Object $activeConns continue } if ($method -eq 'PATCH') { # JSON Patch remove operation to stop an active connection $operations = ConvertTo-GuacMockOperationArray ($body | ConvertFrom-Json -Depth 8) $outcomes = @() foreach ($operation in $operations) { $op = [string]$operation.op $path = [string]$operation.path if ($op -eq 'remove') { $activeId = $path.TrimStart('/') if (-not $activeConns.ContainsKey($activeId)) { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such active connection: ' + $activeId) continue } $activeConns.Remove($activeId) } $outcomes += [ordered]@{ op = $op; identifier = $activeId; path = $path } } Send-Json -Context $context -Code 200 -Object ([ordered]@{ patches = $outcomes }) continue } } elseif ($segments.Count -ge 3) { $activeId = [uri]::UnescapeDataString($segments[2]) if ($segments.Count -eq 3) { if (-not $activeConns.ContainsKey($activeId)) { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such active connection: ' + $activeId) continue } if ($method -eq 'GET') { Send-Json -Context $context -Code 200 -Object $activeConns[$activeId] continue } if ($method -eq 'DELETE') { $activeConns.Remove($activeId) Send-Response -Context $context -Code 204 -ContentType $null -Body $null continue } } # Active connection subresources if ($segments.Count -ge 4) { if (-not $activeConns.ContainsKey($activeId)) { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such active connection: ' + $activeId) continue } $sub = $segments[3] $activeConn = $activeConns[$activeId] # Get the underlying connection object if ($sub -eq 'connection' -and $method -eq 'GET') { $connId = [string]$activeConn['connectionIdentifier'] if ($ctx['connections'].ContainsKey($connId)) { Send-Json -Context $context -Code 200 -Object $ctx['connections'][$connId] } else { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such connection: ' + $connId) } continue } # Sharing credentials — returns APIUserCredentials structure # (guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/APIUserCredentials.java) # The actual API returns a single 'key' QUERY_PARAMETER field. if ($sub -eq 'sharingCredentials' -and $segments.Count -ge 5 -and $method -eq 'GET') { $sharingProfile = $segments[4] $sharingKey = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(('share-{0}-{1}-{2}' -f ($activeId, $sharingProfile, [System.Guid]::NewGuid())))) $sh_creds = [ordered]@{ expected = @{ type = 'QUERY_PARAMETER' name = 'key' } values = @{ key = $sharingKey } } Send-Json -Context $context -Code 200 -Object $sh_creds continue } } } } # History surface. if ($segments.Count -ge 2 -and $segments[1] -eq 'history' -and $method -eq 'GET') { if ($segments.Count -eq 3 -and $segments[2] -eq 'connections') { Send-Json -Context $context -Code 200 -Object $ctx['history']['connections'] continue } if ($segments.Count -eq 3 -and $segments[2] -eq 'users') { Send-Json -Context $context -Code 200 -Object $ctx['history']['users'] continue } } # Schema surface. if ($segments.Count -ge 2 -and $segments[1] -eq 'schema' -and $method -eq 'GET') { if ($segments.Count -eq 3 -and $segments[2] -eq 'protocols') { Send-Json -Context $context -Code 200 -Object $mockProtocols continue } if ($segments.Count -eq 3 -and $mockSchemaSets.Contains($segments[2])) { Send-Json -Context $context -Code 200 -Object $mockSchemaSets[[string]$segments[2]] continue } } } } # ---- Session-level endpoints (Phase 3) ---- # Extension resource (ExtensionRESTService @Path("/ext/{identifier}")) if ($relPath -like '/api/ext/*') { $extDs = [uri]::UnescapeDataString($relPath.Substring('/api/ext/'.Length)) if ($extDs -eq 'nonexistent' -or $extDs -eq 'ldap') { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such extension: ' + $extDs) continue } $extInfo = [ordered]@{ dataSource = $extDs name = ('guacamole-auth-' + $extDs) version = '1.6.0' } Send-Json -Context $context -Code 200 -Object $extInfo continue } if ($relPath -eq '/api/languages' -and $method -eq 'GET') { $languages = [ordered]@{ en = 'English' fr = 'Français' de = 'Deutsch' es = 'Español' ru = 'Русский' ja = '日本語' } Send-Json -Context $context -Code 200 -Object $languages continue } if ($relPath -eq '/api/patches' -and $method -eq 'GET') { $patches = @( '<html><head><meta name="guac-patch" content="test-patch-1"></head><body></body></html>' ) Send-Json -Context $context -Code 200 -Object $patches continue } if ($relPath -like '/api/session*') { if ([string]::IsNullOrEmpty($token) -or -not $validTokens.ContainsKey($token)) { Send-ApiError -Context $context -Code 401 -Type 'NOT_FOUND' -Message 'No such token.' continue } # Tunnels collection if ($relPath -eq '/api/session/tunnels' -and $method -eq 'GET') { $tunnelIds = @('tunnel-1', 'tunnel-2') Send-Json -Context $context -Code 200 -Object $tunnelIds continue } if ($relPath -like '/api/session/tunnels/*' -and $method -eq 'GET') { $tunnelId = $relPath.Substring('/api/session/tunnels/'.Length) $tunnelInfo = [ordered]@{ identifier = $tunnelId protocol = 'ssh' activeConnection = 'active-1' streams = @() } Send-Json -Context $context -Code 200 -Object $tunnelInfo continue } } if ($relPath -eq '/api/notfound' -and $method -eq 'GET') { Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message 'No such resource.' continue } if ($relPath -eq '/api/plaintext-error' -and $method -eq 'GET') { Send-Response -Context $context -Code 500 -ContentType 'text/plain' -Body 'Internal server error (no JSON body).' continue } # Default: 404 APIError. Send-ApiError -Context $context -Code 404 -Type 'NOT_FOUND' -Message ('No such resource: ' + $relPath) } $listener.Stop() Write-Output 'STOPPED' |