Files
management-scripts/tests/Upload-File.Tests.ps1

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
}