add Upload-File function to Tools.ps1
This commit is contained in:
@@ -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.
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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() + '@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
|
||||
}
|
||||
Reference in New Issue
Block a user