#Requires -Version 5.0 # Offline protocol tests. No network requests, production setup, or notifications. $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' Add-Type -AssemblyName System.Net.Http $ToolsPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Tools.ps1' $ParseTokens = $null $ParseErrors = $null $ToolsAst = [System.Management.Automation.Language.Parser]::ParseFile($ToolsPath, [ref]$ParseTokens, [ref]$ParseErrors) If ( $ParseErrors.Count ) { Throw ($ParseErrors | Out-String) } $Definition = $ToolsAst.Find({ param($Node) $Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq 'Upload-File' }, $true) . ([scriptblock]::Create($Definition.Extent.Text)) If ( !('FileDropTestHandler' -as [type]) ) { Add-Type -ReferencedAssemblies System.Net.Http -TypeDefinition @' using System; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; public class FileDropTestHandler : HttpMessageHandler { public string Mode; public int InfoRequests, UploadRequests, Submissions; public string Submission, Filename; public long ReceivedSize; public List Parts = new List(); public Dictionary Attempts = new Dictionary(); public FileDropTestHandler(string mode) { Mode = mode; } private HttpResponseMessage Json(int status, string body) { return new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) { if (request.Method == HttpMethod.Get) { InfoRequests++; if (request.Headers.Authorization != null) throw new Exception("Unexpected authentication on initial request."); if (Mode == "info-error") return Task.FromResult(Json(401, "{\"errors\":[\"Authentication required\"]}")); string limit = Mode == "too-large" ? "1" : "102400"; string extensions = Mode == "extension" ? "pdf" : "zip"; return Task.FromResult(Json(200, "{\"filedrop\":{\"api_key\":\"test-token\",\"max_upload_size\":" + limit + ",\"permitted_extensions\":\"" + extensions + "\",\"blocked_extensions\":\"\"}}")); } if (request.Headers.Authorization == null || request.Headers.Authorization.ToString() != "Basic dGVzdC10b2tlbjp4") throw new Exception("Incorrect FileDrop authentication."); byte[] data = request.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); if (request.RequestUri.AbsolutePath.EndsWith("/attachments/upload")) { UploadRequests++; if (request.Content.Headers.ContentType != null) throw new Exception("Binary upload must allow server content detection."); var query = new Dictionary(); foreach (string part in request.RequestUri.Query.TrimStart('?').Split('&')) { string[] pair = part.Split(new char[] { '=' }, 2); query[pair[0]] = Uri.UnescapeDataString(pair[1]); } int chunk = Int32.Parse(query["chunk"]), chunks = Int32.Parse(query["chunks"]); Filename = query["filename"]; if (!Attempts.ContainsKey(chunk)) Attempts[chunk] = 0; Attempts[chunk]++; if (Mode == "permanent") return Task.FromResult(Json(413, "{\"errors\":[\"Rejected\"]}")); if (Mode == "json-error") return Task.FromResult(Json(200, "{\"errors\":[\"Rejected\"]}")); if ((Mode == "retry" && chunk == 1 && Attempts[chunk] == 1) || Mode == "exhausted") return Task.FromResult(Json(503, "{\"errors\":[\"Temporarily unavailable\"]}")); if (Mode == "lost-chunk" && chunk == 1 && Attempts[chunk] == 1) throw new HttpRequestException("Simulated connection loss."); if (chunk != Parts.Count) throw new Exception("Unexpected chunk ordering."); Parts.Add(data); ReceivedSize += data.Length; if (chunk + 1 != chunks || Mode == "no-id") return Task.FromResult(Json(200, "{\"chunk\":true}")); long size = ReceivedSize + (Mode == "bad-size" ? 1 : 0); return Task.FromResult(Json(200, "{\"attachment\":{\"id\":\"test-attachment\",\"size\":" + size + ",\"content_blocked\":" + (Mode == "blocked" ? "true" : "false") + "}}")); } Submissions++; Submission = Encoding.UTF8.GetString(data); if (request.Content.Headers.ContentType.MediaType != "application/json") throw new Exception("Submission must be JSON."); if (Mode == "submission-failure") return Task.FromResult(Json(503, "{\"errors\":[\"Unavailable\"]}")); if (Mode == "submission-lost") throw new HttpRequestException("Simulated lost submission response."); if (Mode == "no-confirmation") return Task.FromResult(Json(200, "{}")); return Task.FromResult(Json(200, "{\"message\":{\"status\":\"Filedrop message sent successfully\"}}")); } } '@ } # Replace only the HTTP transport, keeping production request/response handling. Function New-Object { param([string]$TypeName, [object[]]$ArgumentList) If ( $TypeName -ne 'System.Net.Http.HttpClient' ) { Throw "Unexpected object: ${TypeName}" } $ArgumentList[0].Dispose() Return [System.Net.Http.HttpClient]::new($script:TestHandler) } Function Start-Sleep { param($Seconds) } # Keep retry tests fast. Function Assert-True { param([bool]$Condition, [string]$Description) If (!$Condition) { Throw $Description } } $TestRoot = Join-Path ([IO.Path]::GetTempPath()) ('filedrop-tests-' + [guid]::NewGuid().ToString('N')) $null = New-Item -Path $TestRoot -ItemType Directory Try { # Spaces, URL metacharacters, and a Unicode name; more than two full chunks. $TestFile = Join-Path $TestRoot ('report [1] & ' + [char]0xE9 + '.zip') $TestBytes = [byte[]]::new(2MB + 37) [Random]::new(73).NextBytes($TestBytes) [IO.File]::WriteAllBytes($TestFile, $TestBytes) $TestMessage = 'Machine diagnostics "quoted" ' + [char]0xE9 Foreach ($Mode in @('success', 'retry', 'lost-chunk')) { $script:TestHandler = [FileDropTestHandler]::new($Mode) $Result = @(Upload-File -Path $TestFile -ChunkSizeMB 1 -Message $TestMessage) Assert-True ($Result.Count -eq 1 -and $Result[0].AttachmentId -eq 'test-attachment') 'Expected one structured receipt.' Assert-True ($TestHandler.Parts.Count -eq 3 -and $TestHandler.Submissions -eq 1) 'Expected three chunks and one submission.' Assert-True ($TestHandler.Filename -ceq [IO.Path]::GetFileName($TestFile)) 'Filename did not round trip through URL encoding.' $Rebuilt = [IO.MemoryStream]::new() Try { Foreach ($Part in $TestHandler.Parts) { $Rebuilt.Write($Part, 0, $Part.Length) } $Hash = [Security.Cryptography.SHA256]::Create() Try { $ExpectedHash = [Convert]::ToBase64String($Hash.ComputeHash($TestBytes)) $ActualHash = [Convert]::ToBase64String($Hash.ComputeHash($Rebuilt.ToArray())) Assert-True ($ExpectedHash -eq $ActualHash) 'Reassembled upload does not match the source.' } Finally { $Hash.Dispose() } } Finally { $Rebuilt.Dispose() } $Sent = ($TestHandler.Submission | ConvertFrom-Json).message Assert-True ($Sent.message -ceq $TestMessage) 'Submission text was corrupted.' Assert-True ($Sent.attachments.Count -eq 1 -and $Sent.attachments[0] -eq 'test-attachment') 'Incorrect attachment list.' $HostInfo = [Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties() $ExpectedHost = $HostInfo.HostName If ($HostInfo.DomainName -and !$ExpectedHost.EndsWith('.' + $HostInfo.DomainName, [StringComparison]::OrdinalIgnoreCase)) { $ExpectedHost += '.' + $HostInfo.DomainName } Assert-True ($Sent.from -eq ($ExpectedHost.ToLowerInvariant() + '@ek-upload.net')) 'Incorrect default sender.' If ($Mode -ne 'success') { Assert-True ($TestHandler.Attempts[1] -eq 2) 'Failed chunk was not retried.' } Write-Host "PASS: $Mode; exact bytes, filename, sender, metadata, receipt." } Foreach ($Mode in @('info-error','too-large','extension','permanent','json-error','exhausted','no-id','bad-size','blocked', 'submission-failure','submission-lost','no-confirmation')) { $script:TestHandler = [FileDropTestHandler]::new($Mode) $Caught = $false Try { $null = Upload-File -Path $TestFile -ChunkSizeMB 1 -RetryCount 1 } Catch { $Caught = $true } Assert-True $Caught "Expected a terminating error for ${Mode}." If ($Mode -in @('info-error','too-large','extension')) { Assert-True ($TestHandler.UploadRequests -eq 0) 'Validation must precede upload.' } If ($Mode -in @('permanent','json-error')) { Assert-True ($TestHandler.UploadRequests -eq 1) 'Permanent error must not retry.' } If ($Mode -eq 'exhausted') { Assert-True ($TestHandler.UploadRequests -eq 2) 'Retry limit was not enforced.' } $ExpectedSubmissions = If ($Mode -in @('submission-failure','submission-lost','no-confirmation')) { 1 } Else { 0 } Assert-True ($TestHandler.Submissions -eq $ExpectedSubmissions) 'Submission was retried or sent despite upload failure.' Write-Host "PASS: $Mode; correct failure and submission behavior." } $EmptyFile = Join-Path $TestRoot 'empty.zip' [IO.File]::WriteAllBytes($EmptyFile, [byte[]]@()) $script:TestHandler = [FileDropTestHandler]::new('success') $EmptyResult = Upload-File -File $EmptyFile -From 'override@example.com' Assert-True ($EmptyResult.Size -eq 0 -and $EmptyResult.From -eq 'override@example.com') 'Empty file or sender override failed.' Assert-True ($TestHandler.UploadRequests -eq 1) 'Empty file must still upload once.' # Confirm the source can be opened exclusively after failure and success. $CheckStream = [IO.File]::Open($TestFile, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) $CheckStream.Dispose() Write-Host "PASS: empty file, sender override, and file handles released." Write-Host "All Upload-File tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size * 8)-bit." } Finally { $ResolvedTestRoot = (Resolve-Path -LiteralPath $TestRoot).Path $ResolvedTempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' If (!$ResolvedTestRoot.StartsWith($ResolvedTempRoot, [StringComparison]::OrdinalIgnoreCase) -or (Split-Path $ResolvedTestRoot -Leaf) -notlike 'filedrop-tests-*') { Throw 'Unexpected cleanup directory.' } Remove-Item -LiteralPath $ResolvedTestRoot -Recurse -Force }