175 lines
11 KiB
PowerShell
175 lines
11 KiB
PowerShell
#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<byte[]> Parts = new List<byte[]>();
|
|
public Dictionary<int, int> Attempts = new Dictionary<int, int>();
|
|
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<HttpResponseMessage> 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<string, string>();
|
|
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() + '@emberkom.com')) '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
|
|
}
|