diff --git a/README.md b/README.md index cacf6aa..5106f02 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,29 @@ Most scripts here require the Tools.ps1 script. You can dot source that script l You can then run any of the scripts using the Run-Script function: Run-Script -LivePSScript Script-Name -If you're using an RMM tool, the scripts in the /rmm directory are all that's needed to hook the main script in this directory. \ No newline at end of file +If you're using an RMM tool, the scripts in the /rmm directory are all that's needed to hook the main script in this directory. + +## Uploading files + +After dot-sourcing `Tools.ps1`, scripts can upload a completed file to the Emberkom FileDrop: + +```powershell +Upload-File -Path 'C:\ProgramData\Emberkom\Output\report.zip' + +# Inside Get-DSAManifest.ps1, after successful collection: +Upload-File -Path $DsaResultPath -Subject "DSA manifest from ${Env:COMPUTERNAME}" +``` + +The default destination is `https://xfer.emberkom.com/filedrop/cmd`. The sender defaults to the computer's hostname plus its primary DNS suffix, followed by `@ek-upload.net` (for example, `pc01.example.com@ek-upload.net`). Without a DNS suffix, it uses the hostname. Override these with `-FileDropUrl` and `-From`; `-Subject` and `-Message` are also optional. + +`Upload-File` uses the [LiquidFiles FileDrop API](https://docs.liquidfiles.com/api/v4.3/filedrop/) to obtain a temporary token, upload the file, and submit the message that triggers FileDrop delivery and its configured email notification. It returns a receipt with the path, destination, sender, attachment ID, byte count, and server status. It retains the local file and throws on failure. + +Uploads use [binary chunks](https://docs.liquidfiles.com/api/v4.3/attachments/chunks.html) with a default 10 MiB buffer, so large files do not need to fit in memory. The FileDrop's size and extension restrictions are checked before upload. `-ChunkSizeMB`, `-RetryCount`, and `-TimeoutSeconds` control chunk size, retries, and each request's timeout; `-Verbose` displays transfer details. + +Allow the RMM job to run for the entire upload. The function waits for completion and retries failed chunks within that run; it does not resume across process restarts. LiquidFiles tokens expire after 24 hours. Final submission is attempted once: if its response is lost, check the FileDrop before rerunning to avoid duplicate notifications. + +Offline tests (no uploads or notifications): + +```powershell +powershell.exe -NoProfile -File .\tests\Upload-File.Tests.ps1 +``` diff --git a/Tools.ps1 b/Tools.ps1 index 9010fde..3e45948 100644 --- a/Tools.ps1 +++ b/Tools.ps1 @@ -209,6 +209,225 @@ Function Download-File { Return $File } +# Upload a file to a LiquidFiles Filedrop and submit its notification message. +Function Upload-File { + <# + .SYNOPSIS + Uploads a file to the Emberkom LiquidFiles Filedrop. + .DESCRIPTION + Uses the LiquidFiles 3.7+ JSON API with bounded binary chunks, including in + 32-bit Windows PowerShell 5. The local file is retained. Returns a receipt + only after the Filedrop accepts the final message for delivery. + + Uploads run synchronously. Allow enough time in the RMM for the entire + transfer; the temporary Filedrop API key expires after 24 hours. Failed + chunks are retried within this call, but restarting the script starts a + new transfer. Final message submission is not retried automatically, + because a lost response could otherwise cause duplicate notifications. + .PARAMETER Path + Literal path of one completed file. Also accepts -File or a pipeline path. + .PARAMETER From + Optional sender override. Defaults to the computer's fully qualified host + name at ek-upload.net (or its host name when no DNS suffix is configured). + .PARAMETER ChunkSizeMB + Maximum upload buffer size in MiB; defaults to 10 regardless of file size. + .PARAMETER TimeoutSeconds + Timeout for each HTTP request, not for the entire transfer. + .EXAMPLE + Upload-File -Path $DsaResultPath + .EXAMPLE + Upload-File -File 'C:\Reports\report.zip' -Subject 'Diagnostic report' -Verbose + .LINK + https://docs.liquidfiles.com/api/v4.3/filedrop/ + .LINK + https://docs.liquidfiles.com/api/v4.3/attachments/chunks.html + #> + [CmdletBinding()] + param( + [Parameter(Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)] + [Alias('File','FullName')][ValidateNotNullOrEmpty()][string]$Path, + [ValidateNotNullOrEmpty()][string]$FileDropUrl = 'https://xfer.emberkom.com/filedrop/cmd', + [string]$From, + [string]$Subject, + [string]$Message, + [ValidateRange(1,100)][int]$ChunkSizeMB = 10, + [ValidateRange(1,3600)][int]$TimeoutSeconds = 900, + [ValidateRange(0,10)][int]$RetryCount = 3 + ) + PROCESS { + $UploadUri = [uri]$FileDropUrl + If ( !$UploadUri.IsAbsoluteUri -or $UploadUri.Scheme -ne 'https' -or + $UploadUri.UserInfo -or $UploadUri.Query -or $UploadUri.Fragment ) { + Throw 'FileDropUrl must be an absolute HTTPS Filedrop URL without credentials, query, or fragment.' + } + $UploadBaseUrl = $UploadUri.AbsoluteUri.TrimEnd('/') + $UploadFile = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + If ( $UploadFile -isnot [System.IO.FileInfo] ) { Throw 'Upload-File requires a file, not a directory.' } + + $UploadHostInfo = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties() + $UploadHostName = $UploadHostInfo.HostName + If ( [string]::IsNullOrWhiteSpace($UploadHostName) ) { $UploadHostName = [Environment]::MachineName } + $UploadDnsSuffix = $UploadHostInfo.DomainName.Trim('.') + If ( $UploadDnsSuffix -and !$UploadHostName.EndsWith(".${UploadDnsSuffix}", [StringComparison]::OrdinalIgnoreCase) ) { + $UploadHostName = "${UploadHostName}.${UploadDnsSuffix}" + } + $UploadSender = $From + If ( [string]::IsNullOrWhiteSpace($UploadSender) ) { $UploadSender = $UploadHostName.ToLowerInvariant() + '@ek-upload.net' } + Try { $UploadSender = ([System.Net.Mail.MailAddress]::new($UploadSender)).Address } + Catch { Throw 'From must be a valid sender email address.' } + $UploadSubject = $Subject + If ( [string]::IsNullOrWhiteSpace($UploadSubject) ) { $UploadSubject = "File upload from ${UploadHostName}: $($UploadFile.Name)" } + $UploadMessage = $Message + If ( [string]::IsNullOrWhiteSpace($UploadMessage) ) { $UploadMessage = "Uploaded by ${UploadHostName}." } + + Add-Type -AssemblyName System.Net.Http -ErrorAction Stop + $UploadClient = $null + $UploadHandler = $null + $UploadStream = $null + $UploadApiKey = $null + + # Keep the same HTTP client (and cookies) for every chunk in this session. + Function Invoke-FileDropRequest { + param( + [string]$Method, + [string]$Uri, + [byte[]]$Data, + [int]$Count = 0, + [string]$ContentType, + [int]$Retries = $RetryCount + ) + For ( $UploadAttempt = 0; $UploadAttempt -le $Retries; $UploadAttempt++ ) { + $UploadRequest = $null + $UploadResponse = $null + $UploadStatus = 0 + Try { + $UploadRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method), $Uri) + If ( $Method -eq 'POST' ) { + $UploadRequest.Content = [System.Net.Http.ByteArrayContent]::new($Data, 0, $Count) + If ( $ContentType ) { + $UploadRequest.Content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse($ContentType) + } + } + $UploadResponse = $UploadClient.SendAsync($UploadRequest).GetAwaiter().GetResult() + $UploadStatus = [int]$UploadResponse.StatusCode + $UploadResponseText = $UploadResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult() + If ( !$UploadResponse.IsSuccessStatusCode ) { + # Do not include authentication tokens in errors or RMM logs. + If ( $UploadApiKey ) { $UploadResponseText = $UploadResponseText.Replace($UploadApiKey, '[redacted]') } + If ( $UploadResponseText.Length -gt 1000 ) { $UploadResponseText = $UploadResponseText.Substring(0, 1000) } + Throw "Filedrop returned HTTP ${UploadStatus}: ${UploadResponseText}" + } + If ( [string]::IsNullOrWhiteSpace($UploadResponseText) ) { Return $null } + $UploadResponseData = ConvertFrom-Json -InputObject $UploadResponseText -ErrorAction Stop + If ( $UploadResponseData.errors ) { Throw ('Filedrop rejected the request: ' + ($UploadResponseData.errors -join '; ')) } + Return $UploadResponseData + } + Catch { + $UploadCanRetry = $UploadStatus -eq 0 -or $UploadStatus -in @(408,429,500,502,503,504) + If ( !$UploadCanRetry -or $UploadAttempt -ge $Retries ) { Throw } + Write-Verbose "Retrying Filedrop request after a connection error or HTTP ${UploadStatus}." + } + Finally { + If ( $null -ne $UploadResponse ) { $UploadResponse.Dispose() } + If ( $null -ne $UploadRequest ) { $UploadRequest.Dispose() } + } + Start-Sleep -Seconds ([Math]::Min(30, [Math]::Pow(2, $UploadAttempt + 1))) + } + } + + Try { + # Deny writes while reading so retried chunks always contain the same bytes. + $UploadStream = [System.IO.File]::Open($UploadFile.FullName, [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) + [long]$UploadLength = $UploadStream.Length + $UploadHandler = [System.Net.Http.HttpClientHandler]::new() + $UploadHandler.AllowAutoRedirect = $false + $UploadClient = New-Object -TypeName System.Net.Http.HttpClient -ArgumentList $UploadHandler + $UploadClient.Timeout = [TimeSpan]::FromSeconds($TimeoutSeconds) + $UploadClient.DefaultRequestHeaders.Accept.ParseAdd('application/json') + + $UploadInfo = (Invoke-FileDropRequest -Method GET -Uri $UploadBaseUrl).filedrop + $UploadApiKey = [string]$UploadInfo.api_key + If ( [string]::IsNullOrWhiteSpace($UploadApiKey) ) { + Throw 'The Filedrop did not provide an API key. Check its URL, password, and email-validation requirements.' + } + If ( $UploadInfo.max_upload_size -gt 0 -and $UploadLength -gt ([long]$UploadInfo.max_upload_size * 1MB) ) { + Throw "The file exceeds the Filedrop limit of $($UploadInfo.max_upload_size) MiB." + } + $UploadExtension = $UploadFile.Extension.TrimStart('.').ToLowerInvariant() + $UploadPermitted = $UploadInfo.permitted_extensions + If ( !$UploadPermitted ) { $UploadPermitted = $UploadInfo.limited_extensions } + $UploadAllowedExtensions = @(([string]$UploadPermitted -split '[,\s]+') | ForEach-Object { $_.TrimStart('.') } | Where-Object { $_ }) + $UploadBlockedExtensions = @(([string]$UploadInfo.blocked_extensions -split '[,\s]+') | ForEach-Object { $_.TrimStart('.') } | Where-Object { $_ }) + If ( ($UploadAllowedExtensions.Count -gt 0 -and $UploadExtension -notin $UploadAllowedExtensions) -or + $UploadExtension -in $UploadBlockedExtensions ) { + Throw "The Filedrop does not permit the file extension '$UploadExtension'." + } + $UploadAuth = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($UploadApiKey + ':x')) + $UploadClient.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Basic', $UploadAuth) + + [int]$UploadChunkSize = $ChunkSizeMB * 1MB + [long]$UploadChunks = [Math]::Max(1, [Math]::Ceiling($UploadLength / [double]$UploadChunkSize)) + $UploadBuffer = [byte[]]::new([int][Math]::Min($UploadChunkSize, [Math]::Max(1, $UploadLength))) + $UploadEncodedName = [uri]::EscapeDataString($UploadFile.Name) + $UploadAttachment = $null + Write-Verbose "Uploading $($UploadFile.Name) ($UploadLength bytes) in $UploadChunks chunk(s) as ${UploadSender}." + For ( [long]$UploadChunk = 0; $UploadChunk -lt $UploadChunks; $UploadChunk++ ) { + [int]$UploadBytesNeeded = [Math]::Min($UploadChunkSize, $UploadLength - $UploadStream.Position) + [int]$UploadBytesRead = 0 + While ( $UploadBytesRead -lt $UploadBytesNeeded ) { + $UploadRead = $UploadStream.Read($UploadBuffer, $UploadBytesRead, $UploadBytesNeeded - $UploadBytesRead) + If ( $UploadRead -eq 0 ) { Throw 'The local file ended before all expected bytes were read.' } + $UploadBytesRead += $UploadRead + } + $UploadChunkUrl = "${UploadBaseUrl}/attachments/upload?filename=${UploadEncodedName}&chunk=${UploadChunk}&chunks=${UploadChunks}" + $UploadChunkResult = Invoke-FileDropRequest -Method POST -Uri $UploadChunkUrl -Data $UploadBuffer -Count $UploadBytesRead + If ( $UploadChunkResult.attachment.id ) { $UploadAttachment = $UploadChunkResult.attachment } + Write-Progress -Activity "Uploading $($UploadFile.Name)" -Status "Chunk $($UploadChunk + 1) of ${UploadChunks}" ` + -PercentComplete ([int](100 * ($UploadChunk + 1) / $UploadChunks)) + } + If ( !$UploadAttachment.id ) { Throw 'The upload did not return an attachment ID; the Filedrop message was not submitted.' } + If ( $null -ne $UploadAttachment.size -and [long]$UploadAttachment.size -ne $UploadLength ) { + Throw 'The uploaded attachment size does not match the local file; the Filedrop message was not submitted.' + } + If ( $UploadAttachment.content_blocked ) { Throw 'LiquidFiles blocked this attachment; the Filedrop message was not submitted.' } + + $UploadBody = @{ message = @{ + from = $UploadSender + subject = $UploadSubject + message = $UploadMessage + attachments = @([string]$UploadAttachment.id) + } } | ConvertTo-Json -Depth 4 -Compress + $UploadBodyBytes = [Text.Encoding]::UTF8.GetBytes($UploadBody) + Try { + $UploadReceipt = Invoke-FileDropRequest -Method POST -Uri $UploadBaseUrl -Data $UploadBodyBytes ` + -Count $UploadBodyBytes.Length -ContentType 'application/json; charset=utf-8' -Retries 0 + If ( [string]::IsNullOrWhiteSpace([string]$UploadReceipt.message.status) ) { + Throw 'The server did not return a Filedrop submission confirmation.' + } + } + Catch { + Throw "Filedrop submission was not confirmed. Check the Filedrop before retrying to avoid duplicate notifications. $($_.Exception.Message)" + } + [pscustomobject]@{ + Path = $UploadFile.FullName + FileDropUrl = $UploadBaseUrl + From = $UploadSender + AttachmentId = [string]$UploadAttachment.id + Size = $UploadLength + Status = [string]$UploadReceipt.message.status + } + } + Finally { + Write-Progress -Activity "Uploading $($UploadFile.Name)" -Completed + If ( $null -ne $UploadStream ) { $UploadStream.Dispose() } + If ( $null -ne $UploadClient ) { $UploadClient.Dispose() } + ElseIf ( $null -ne $UploadHandler ) { $UploadHandler.Dispose() } + $UploadApiKey = $null + } + } +} + # Install a program from a provided path Function Install-Program { param( diff --git a/tests/Upload-File.Tests.ps1 b/tests/Upload-File.Tests.ps1 new file mode 100644 index 0000000..0b5ec4c --- /dev/null +++ b/tests/Upload-File.Tests.ps1 @@ -0,0 +1,174 @@ +#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 +}