param( [string]$BackendApiBase = "http://192.168.200.61:8080/api/v1", [string]$BackendWsBase = "ws://192.168.200.61:8080/api/v1/gateway/ws", [string]$DockerSshAlias = "docker-test", [string]$WorkerContainerName = "rap_worker_smoke", [string]$WorkerImage = "rap-rdp-worker:stage5-drive-visible", [string]$ResourceName = "Windows Smoke Default Resource", [string]$Email = "windows-smoke@example.local", [string]$Password = "SmokePass!123", [string]$OutputLog = "artifacts/stage5-file-upload-smoke.log" ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $log = [System.Collections.Generic.List[string]]::new() function Write-SmokeLog { param([string]$Message) $line = "[$(Get-Date -Format o)] $Message" $log.Add($line) Write-Host $line } function Invoke-RemoteSqlScalar { param([string]$Sql) $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Sql)) $output = ssh $DockerSshAlias "printf '%s' '$encoded' | base64 -d | docker exec -i rap_postgres psql -U rap_user -d remote_access_platform -t -A -v ON_ERROR_STOP=1" return (($output | Select-Object -Last 1) -as [string]).Trim() } function Set-FileTransferMode { param([ValidateSet("disabled", "client_to_server")][string]$Mode) $enabled = if ($Mode -eq "disabled") { "FALSE" } else { "TRUE" } $escapedName = $ResourceName.Replace("'", "''") $sql = @" UPDATE resource_policies rp SET file_transfer_mode = '$Mode', file_transfer_enabled = $enabled, updated_at = NOW() FROM resources r WHERE rp.resource_id = r.id AND r.name = '$escapedName'; SELECT COALESCE(MAX(rp.file_transfer_mode), '') FROM resource_policies rp JOIN resources r ON r.id = rp.resource_id WHERE r.name = '$escapedName'; "@ $actual = Invoke-RemoteSqlScalar -Sql $sql if ($actual -ne $Mode) { throw "file_transfer_mode was not set. Expected=$Mode actual=$actual" } Write-SmokeLog "policy file_transfer_mode=$Mode" } function Invoke-Api { param( [ValidateSet("Get", "Post")][string]$Method, [string]$Path, [object]$Body = $null ) $uri = "$BackendApiBase/$Path" if ($null -eq $Body) { return Invoke-RestMethod -Method $Method -Uri $uri } return Invoke-RestMethod -Method $Method -Uri $uri -ContentType "application/json" -Body ($Body | ConvertTo-Json -Depth 12) } function New-GatewayWebSocket { param([string]$AttachToken) $ws = [Net.WebSockets.ClientWebSocket]::new() $uri = [Uri]::new("${BackendWsBase}?attach_token=$([Uri]::EscapeDataString($AttachToken))") $null = $ws.ConnectAsync($uri, [Threading.CancellationToken]::None).GetAwaiter().GetResult() return $ws } function Receive-Envelope { param( [Net.WebSockets.ClientWebSocket]$WebSocket, [int]$TimeoutMilliseconds = 30000 ) $buffer = [byte[]]::new(1024 * 1024) $segment = [ArraySegment[byte]]::new($buffer) $stream = [IO.MemoryStream]::new() while ($true) { $cts = [Threading.CancellationTokenSource]::new($TimeoutMilliseconds) try { $result = $WebSocket.ReceiveAsync($segment, $cts.Token).GetAwaiter().GetResult() } finally { $cts.Dispose() } if ($result.MessageType -eq [Net.WebSockets.WebSocketMessageType]::Close) { throw "websocket closed" } $stream.Write($buffer, 0, $result.Count) if ($result.EndOfMessage) { break } } $text = [Text.Encoding]::UTF8.GetString($stream.ToArray()) return $text | ConvertFrom-Json } function Receive-EnvelopeOfType { param( [Net.WebSockets.ClientWebSocket]$WebSocket, [string[]]$Types, [int]$TimeoutSeconds = 30 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { $envelope = Receive-Envelope -WebSocket $WebSocket -TimeoutMilliseconds 10000 if ($Types -contains [string]$envelope.type) { return $envelope } Write-SmokeLog "skipping ws event type=$($envelope.type)" } throw "timed out waiting for envelope type: $($Types -join ',')" } function Send-Envelope { param( [Net.WebSockets.ClientWebSocket]$WebSocket, [object]$Envelope ) $json = $Envelope | ConvertTo-Json -Depth 16 -Compress $bytes = [Text.Encoding]::UTF8.GetBytes($json) $null = $WebSocket.SendAsync( [ArraySegment[byte]]::new($bytes), [Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None ).GetAwaiter().GetResult() } function Get-PayloadProperty { param( [object]$Payload, [string]$Name ) if ($null -eq $Payload) { return "" } if ($Payload.PSObject.Properties.Name -contains $Name) { return $Payload.$Name } return "" } function Wait-SessionActive { param([Net.WebSockets.ClientWebSocket]$WebSocket) $deadline = (Get-Date).AddSeconds(90) while ((Get-Date) -lt $deadline) { $envelope = Receive-Envelope -WebSocket $WebSocket -TimeoutMilliseconds 30000 Write-SmokeLog "ws event type=$($envelope.type) state=$($envelope.payload.state)" if ($envelope.type -eq "session.state" -and $envelope.payload.state -eq "active") { return } } throw "session did not become active" } function Start-SmokeSession { param([string]$UserId, [string]$DeviceId, [string]$ResourceId) return Invoke-Api -Method Post -Path "sessions" -Body @{ resource_id = $ResourceId user_id = $UserId device_id = $DeviceId } } function Stop-SmokeSession { param([string]$SessionId, [string]$UserId) try { Invoke-Api -Method Post -Path "sessions/$SessionId/terminate" -Body @{ user_id = $UserId reason = "stage5_file_upload_smoke_cleanup" } | Out-Null } catch { Write-SmokeLog "terminate skipped session=$SessionId reason=$($_.Exception.Message)" } } function Restart-SmokeWorker { Write-SmokeLog "restarting worker container=$WorkerContainerName" $cmd = "docker rm -f $WorkerContainerName 2>/dev/null || true; docker run -d --name $WorkerContainerName --network host -e RDP_WORKER_ID=rdp-worker-1 -e RDP_WORKER_REDIS_HOST=127.0.0.1 -e RDP_WORKER_REDIS_PORT=6379 -e RDP_WORKER_TRANSFER_ROOT=/tmp/rap-rdp-worker-transfers -e RDP_WORKER_CAPABILITIES=adaptive-quality,dirty-rects,clipboard,file-transfer $WorkerImage" $output = ssh $DockerSshAlias $cmd Write-SmokeLog "worker restart output=$($output -join ' ')" Start-Sleep -Seconds 5 } function Stop-SmokeWorker { Write-SmokeLog "stopping worker container=$WorkerContainerName" ssh $DockerSshAlias "docker rm -f $WorkerContainerName 2>/dev/null || true" | Out-Null } function Wait-SessionState { param( [string]$SessionId, [string]$ExpectedState, [int]$TimeoutSeconds = 90 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { $escaped = $SessionId.Replace("'", "''") $state = Invoke-RemoteSqlScalar -Sql "SELECT state FROM remote_sessions WHERE id = '$escaped' LIMIT 1;" Write-SmokeLog "session_state session=$SessionId state=$state expected=$ExpectedState" if ($state -eq $ExpectedState) { return } Start-Sleep -Seconds 3 } throw "session $SessionId did not reach state $ExpectedState" } function Assert-UploadBlockedOnConnection { param( [Net.WebSockets.ClientWebSocket]$WebSocket, [string]$Label ) try { Send-Envelope -WebSocket $WebSocket -Envelope @{ type = "file_upload.start" payload = @{ direction = "client_to_server" transfer_id = [guid]::NewGuid().ToString("D") file_name = "$Label.txt" file_size = 5 total_chunks = 1 content_hash = "" } } $response = Receive-EnvelopeOfType -WebSocket $WebSocket -Types @("file_upload.blocked", "session.taken_over", "transport.closed", "session.state") -TimeoutSeconds 20 $reason = Get-PayloadProperty -Payload $response.payload -Name "reason" $state = Get-PayloadProperty -Payload $response.payload -Name "state" Write-SmokeLog "$Label upload gate response type=$($response.type) state=$state reason=$reason" if ($response.type -eq "file_upload.progress") { throw "$Label upload was accepted unexpectedly" } if ($response.type -eq "session.state" -and $state -eq "active") { throw "$Label upload gate returned active session state" } } catch { Write-SmokeLog "$Label upload blocked by closed/failed transport: $($_.Exception.Message)" } } function Upload-Bytes { param( [Net.WebSockets.ClientWebSocket]$WebSocket, [string]$FileName, [byte[]]$Bytes ) $transferId = [guid]::NewGuid().ToString("D") $chunkSize = 256 * 1024 $totalChunks = [int64][Math]::Ceiling($Bytes.Length / $chunkSize) Write-SmokeLog "client upload start transfer_id=$transferId file=$FileName bytes=$($Bytes.Length)" Send-Envelope -WebSocket $WebSocket -Envelope @{ type = "file_upload.start" payload = @{ direction = "client_to_server" transfer_id = $transferId file_name = $FileName file_size = [int64]$Bytes.Length total_chunks = $totalChunks content_hash = "" } } $startResponse = Receive-EnvelopeOfType -WebSocket $WebSocket -Types @("file_upload.progress", "file_upload.blocked") -TimeoutSeconds 20 $startStatus = Get-PayloadProperty -Payload $startResponse.payload -Name "status" $startReason = Get-PayloadProperty -Payload $startResponse.payload -Name "reason" Write-SmokeLog "client upload response type=$($startResponse.type) status=$startStatus reason=$startReason" if ($startResponse.type -ne "file_upload.progress" -or $startResponse.payload.status -ne "started") { throw "upload was not accepted by gateway" } $offset = 0 $chunkIndex = 0 while ($offset -lt $Bytes.Length) { $take = [Math]::Min($chunkSize, $Bytes.Length - $offset) $chunk = [byte[]]::new($take) [Array]::Copy($Bytes, $offset, $chunk, 0, $take) Send-Envelope -WebSocket $WebSocket -Envelope @{ type = "file_upload.chunk" payload = @{ direction = "client_to_server" transfer_id = $transferId chunk_index = [int64]$chunkIndex offset = [int64]$offset chunk_size = $take chunk_bytes = [Convert]::ToBase64String($chunk) } } $progress = Receive-EnvelopeOfType -WebSocket $WebSocket -Types @("file_upload.progress", "file_upload.blocked") -TimeoutSeconds 20 $progressStatus = Get-PayloadProperty -Payload $progress.payload -Name "status" $progressReceived = Get-PayloadProperty -Payload $progress.payload -Name "received" $progressTotal = Get-PayloadProperty -Payload $progress.payload -Name "total" Write-SmokeLog "client upload progress type=$($progress.type) status=$progressStatus received=$progressReceived total=$progressTotal" $offset += $take $chunkIndex++ } return $transferId } function Get-RemoteFileEvidence { param( [string]$SessionId, [string]$TransferId, [string]$FileName, [string]$ExpectedSha256 ) $path = "/tmp/rap-rdp-worker-transfers/$SessionId/visible/$FileName" $remoteScript = "p='$path'; ls -l ""`$p""; wc -c < ""`$p""; sha256sum ""`$p""" $output = $null $deadline = (Get-Date).AddSeconds(20) while ((Get-Date) -lt $deadline) { $output = ssh $DockerSshAlias "docker exec $WorkerContainerName sh -lc '$remoteScript'" 2>$null if ($LASTEXITCODE -eq 0 -and (($output -join "`n") -like "*$ExpectedSha256*")) { break } Start-Sleep -Milliseconds 500 } foreach ($line in $output) { Write-SmokeLog "worker_fs $line" } if ((($output -join "`n") -notlike "*$ExpectedSha256*")) { throw "remote sha256 mismatch for $FileName" } } function Get-Sha256 { param([byte[]]$Bytes) $stream = [IO.MemoryStream]::new($Bytes) try { return (Get-FileHash -Algorithm SHA256 -InputStream $stream).Hash.ToLowerInvariant() } finally { $stream.Dispose() } } New-Item -ItemType Directory -Force -Path (Split-Path -Parent $OutputLog) | Out-Null $login = Invoke-Api -Method Post -Path "auth/login" -Body @{ email = $Email password = $Password trust_device = $true device_fingerprint = "stage5-file-upload-a" device_label = "stage5-file-upload-a" } $userId = [string]$login.user.ID if ([string]::IsNullOrWhiteSpace($userId)) { $userId = [string]$login.user.id } $deviceId = [string]$login.device.ID if ([string]::IsNullOrWhiteSpace($deviceId)) { $deviceId = [string]$login.device.id } $escapedResourceName = $ResourceName.Replace("'", "''") $resourceSql = "SELECT id::text FROM resources WHERE name = '$escapedResourceName' LIMIT 1;" $resourceId = Invoke-RemoteSqlScalar -Sql $resourceSql if ([string]::IsNullOrWhiteSpace($resourceId)) { throw "resource not found: $ResourceName" } Write-SmokeLog "smoke user=$userId device=$deviceId resource=$resourceId" Restart-SmokeWorker $sessions = Invoke-Api -Method Get -Path "sessions?user_id=$([Uri]::EscapeDataString($userId))" foreach ($session in @($sessions.sessions)) { if ($session.state -in @("starting", "active", "detached", "reconnecting")) { Stop-SmokeSession -SessionId $session.id -UserId $userId } } Set-FileTransferMode -Mode disabled $disabled = Start-SmokeSession -UserId $userId -DeviceId $deviceId -ResourceId $resourceId $disabledSessionId = [string]$disabled.session.ID $disabledAttachToken = [string]$disabled.attach_token.token Write-SmokeLog "disabled session=$disabledSessionId" $disabledWs = New-GatewayWebSocket -AttachToken $disabledAttachToken try { Wait-SessionActive -WebSocket $disabledWs $blockedTransferId = [guid]::NewGuid().ToString("D") Send-Envelope -WebSocket $disabledWs -Envelope @{ type = "file_upload.start" payload = @{ direction = "client_to_server" transfer_id = $blockedTransferId file_name = "blocked.txt" file_size = 5 total_chunks = 1 content_hash = "" } } $blocked = Receive-EnvelopeOfType -WebSocket $disabledWs -Types @("file_upload.blocked") -TimeoutSeconds 20 $blockedReason = if ($blocked.payload.PSObject.Properties.Name -contains "reason") { $blocked.payload.reason } else { "" } Write-SmokeLog "disabled upload response type=$($blocked.type) reason=$blockedReason" if ($blocked.type -ne "file_upload.blocked") { throw "disabled policy did not block upload" } } finally { $disabledWs.Dispose() Stop-SmokeSession -SessionId $disabledSessionId -UserId $userId } Set-FileTransferMode -Mode client_to_server $allowed = Start-SmokeSession -UserId $userId -DeviceId $deviceId -ResourceId $resourceId $sessionId = [string]$allowed.session.ID $attachmentId = [string]$allowed.attachment.ID $attachToken = [string]$allowed.attach_token.token Write-SmokeLog "allowed session=$sessionId attachment=$attachmentId" $ws = New-GatewayWebSocket -AttachToken $attachToken try { Wait-SessionActive -WebSocket $ws Send-Envelope -WebSocket $ws -Envelope @{ type = "input"; payload = @{ kind = "mouse"; action = "move"; normalized_x = 0.5; normalized_y = 0.5; surface_width = 1024; surface_height = 768 } } Send-Envelope -WebSocket $ws -Envelope @{ type = "input"; payload = @{ kind = "keyboard"; action = "key_down"; scan_code = 30; is_extended = $false } } Send-Envelope -WebSocket $ws -Envelope @{ type = "input"; payload = @{ kind = "keyboard"; action = "key_up"; scan_code = 30; is_extended = $false } } Send-Envelope -WebSocket $ws -Envelope @{ type = "clipboard"; payload = @{ direction = "client_to_server"; text = "stage5 clipboard regression"; sequence_id = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(); origin = $attachmentId; content_hash = "" } } Start-Sleep -Seconds 2 $textBytes = [Text.Encoding]::UTF8.GetBytes("Stage 5.1 text upload`nрусский текст`n") $textSha = Get-Sha256 -Bytes $textBytes $textTransfer = Upload-Bytes -WebSocket $ws -FileName "stage5-upload-text.txt" -Bytes $textBytes Get-RemoteFileEvidence -SessionId $sessionId -TransferId $textTransfer -FileName "stage5-upload-text.txt" -ExpectedSha256 $textSha $binaryBytes = [byte[]]::new(4096) [Security.Cryptography.RandomNumberGenerator]::Fill($binaryBytes) $binarySha = Get-Sha256 -Bytes $binaryBytes $binaryTransfer = Upload-Bytes -WebSocket $ws -FileName "stage5-upload-binary.bin" -Bytes $binaryBytes Get-RemoteFileEvidence -SessionId $sessionId -TransferId $binaryTransfer -FileName "stage5-upload-binary.bin" -ExpectedSha256 $binarySha Invoke-Api -Method Post -Path "sessions/$sessionId/detach" -Body @{ attachment_id = $attachmentId user_id = $userId reason = "stage5_file_upload_detach_gate" } | Out-Null Start-Sleep -Seconds 2 try { Send-Envelope -WebSocket $ws -Envelope @{ type = "file_upload.start" payload = @{ direction = "client_to_server" transfer_id = [guid]::NewGuid().ToString("D") file_name = "after-detach.txt" file_size = 5 total_chunks = 1 content_hash = "" } } $detachGate = Receive-Envelope -WebSocket $ws -TimeoutMilliseconds 8000 Write-SmokeLog "detach upload response type=$($detachGate.type) reason=$($detachGate.payload.reason)" } catch { Write-SmokeLog "detach upload blocked by closed transport: $($_.Exception.Message)" } Stop-SmokeSession -SessionId $sessionId -UserId $userId $takeoverSession = Start-SmokeSession -UserId $userId -DeviceId $deviceId -ResourceId $resourceId $takeoverSessionId = [string]$takeoverSession.session.ID $takeoverAttachmentId = [string]$takeoverSession.attachment.ID $takeoverAttachToken = [string]$takeoverSession.attach_token.token Write-SmokeLog "takeover session=$takeoverSessionId old_attachment=$takeoverAttachmentId" $takeoverOldWs = New-GatewayWebSocket -AttachToken $takeoverAttachToken $takeoverNewWs = $null try { Wait-SessionActive -WebSocket $takeoverOldWs $loginB = Invoke-Api -Method Post -Path "auth/login" -Body @{ email = $Email password = $Password trust_device = $true device_fingerprint = "stage5-file-upload-b" device_label = "stage5-file-upload-b" } $deviceB = [string]$loginB.device.ID if ([string]::IsNullOrWhiteSpace($deviceB)) { $deviceB = [string]$loginB.device.id } $takeoverResult = Invoke-Api -Method Post -Path "sessions/$takeoverSessionId/takeover" -Body @{ user_id = $userId device_id = $deviceB reason = "stage5_file_upload_takeover_gate" } $newAttachmentId = [string]$takeoverResult.attachment.ID $newAttachToken = [string]$takeoverResult.attach_token.token Write-SmokeLog "takeover complete new_attachment=$newAttachmentId" $takeoverNewWs = New-GatewayWebSocket -AttachToken $newAttachToken Wait-SessionActive -WebSocket $takeoverNewWs Assert-UploadBlockedOnConnection -WebSocket $takeoverOldWs -Label "takeover_old_client" } finally { if ($null -ne $takeoverNewWs) { $takeoverNewWs.Dispose() } $takeoverOldWs.Dispose() Stop-SmokeSession -SessionId $takeoverSessionId -UserId $userId } $failureSession = Start-SmokeSession -UserId $userId -DeviceId $deviceId -ResourceId $resourceId $failureSessionId = [string]$failureSession.session.ID $failureAttachToken = [string]$failureSession.attach_token.token Write-SmokeLog "worker_failure session=$failureSessionId" $failureWs = New-GatewayWebSocket -AttachToken $failureAttachToken try { Wait-SessionActive -WebSocket $failureWs Stop-SmokeWorker Wait-SessionState -SessionId $failureSessionId -ExpectedState "failed" -TimeoutSeconds 120 Assert-UploadBlockedOnConnection -WebSocket $failureWs -Label "worker_failure" } finally { $failureWs.Dispose() Restart-SmokeWorker Stop-SmokeSession -SessionId $failureSessionId -UserId $userId } } finally { $ws.Dispose() Stop-SmokeSession -SessionId $sessionId -UserId $userId $log | Set-Content -LiteralPath $OutputLog -Encoding UTF8 Write-SmokeLog "wrote $OutputLog" }