Compare commits
2
Commits
745902f8c2
...
35c1904d0d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c1904d0d | ||
|
|
c74fe82d36 |
+40
-13
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Downloads and runs the DSA manifest collector.
|
Downloads and runs the DSA manifest collector, with optional upload and cleanup.
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Tools.ps1 must be dot-sourced by the caller first. Set $DSAManifestSource
|
Tools.ps1 must be dot-sourced by the caller first. Set $DSAManifestSource
|
||||||
in the caller's scope before using Run-Script -LivePSScript Get-DSAManifest.
|
in the caller's scope before using Run-Script -LivePSScript Get-DSAManifest.
|
||||||
The output path is generated in $Global:OutputDirectory with the filename
|
The output path is generated in $Global:OutputDirectory with the filename
|
||||||
dsa-manifest-HOSTNAME-yyyyMMddHHmmss.csv, using the computer name and current local date and time.
|
dsa-manifest-HOSTNAME-yyyyMMddHHmmss.csv, using the computer name and current local date and time.
|
||||||
Set $Zip to 'true', '$true', 'yes', 'y', or '1' to include --zip.
|
$Zip enables ZIP compression; $Upload sends the result to the FileDrop;
|
||||||
Use 'false', '$false', 'no', 'n', or '0' to disable ZIP; an empty value also disables it.
|
$DeleteAfterUpload removes the local result only after a confirmed upload.
|
||||||
Values are case-insensitive and surrounding whitespace is ignored.
|
All three accept 'true', '$true', 'yes', 'y', or '1' to enable, and
|
||||||
|
'false', '$false', 'no', 'n', or '0' to disable. Empty values disable the option.
|
||||||
|
Values are case-insensitive and surrounding whitespace is ignored. When
|
||||||
|
$Upload is false, the local CSV or ZIP is retained regardless of $DeleteAfterUpload.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
If ( [string]::IsNullOrWhiteSpace([string]$DSAManifestSource) ) {
|
If ( [string]::IsNullOrWhiteSpace([string]$DSAManifestSource) ) {
|
||||||
@@ -18,16 +21,20 @@ If ( [string]::IsNullOrWhiteSpace([string]$DSAManifestSource) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Normalize RMM text values without treating every nonempty string as true.
|
# Normalize RMM text values without treating every nonempty string as true.
|
||||||
$DsaZipValue = ([string]$Zip).Trim().ToLowerInvariant()
|
$DsaOptions = @{ Zip = $Zip; Upload = $Upload; DeleteAfterUpload = $DeleteAfterUpload }
|
||||||
If ( $DsaZipValue -in @('true', '$true', 'yes', 'y', '1') ) {
|
ForEach ( $DsaOptionName in @('Zip', 'Upload', 'DeleteAfterUpload') ) {
|
||||||
$DsaZipEnabled = $true
|
$DsaOptionValue = ([string]$DsaOptions[$DsaOptionName]).Trim().ToLowerInvariant()
|
||||||
}
|
If ( $DsaOptionValue -in @('true', '$true', 'yes', 'y', '1') ) {
|
||||||
ElseIf ( $DsaZipValue -in @('', 'false', '$false', 'no', 'n', '0') ) {
|
$DsaOptions[$DsaOptionName] = $true
|
||||||
$DsaZipEnabled = $false
|
}
|
||||||
}
|
ElseIf ( $DsaOptionValue -in @('', 'false', '$false', 'no', 'n', '0') ) {
|
||||||
Else {
|
$DsaOptions[$DsaOptionName] = $false
|
||||||
Throw 'Invalid $Zip value. Use true, $true, yes, y, 1, false, $false, no, n, or 0.'
|
}
|
||||||
|
Else {
|
||||||
|
Throw ('Invalid ${0} value. Use true, $true, yes, y, 1, false, $false, no, n, or 0.' -f $DsaOptionName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
$DsaZipEnabled = $DsaOptions.Zip
|
||||||
|
|
||||||
$DSAManifestOutput = Join-Path -Path $Global:OutputDirectory -ChildPath (
|
$DSAManifestOutput = Join-Path -Path $Global:OutputDirectory -ChildPath (
|
||||||
'dsa-manifest-{0}-{1}.csv' -f $Env:COMPUTERNAME, (Get-Date -Format 'yyyyMMddHHmmss')
|
'dsa-manifest-{0}-{1}.csv' -f $Env:COMPUTERNAME, (Get-Date -Format 'yyyyMMddHHmmss')
|
||||||
@@ -65,6 +72,7 @@ $DsaCommandLine = ($DsaArguments | ForEach-Object {
|
|||||||
'"{0}"' -f ($_ -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1')
|
'"{0}"' -f ($_ -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1')
|
||||||
}) -join ' '
|
}) -join ' '
|
||||||
|
|
||||||
|
$DsaCollectionTime = Get-Date
|
||||||
Write-Output "Running dsa-collect.exe (ZIP compression: ${DsaZipEnabled})"
|
Write-Output "Running dsa-collect.exe (ZIP compression: ${DsaZipEnabled})"
|
||||||
$DsaProcess = Start-Process -FilePath $DsaExecutablePath -ArgumentList $DsaCommandLine `
|
$DsaProcess = Start-Process -FilePath $DsaExecutablePath -ArgumentList $DsaCommandLine `
|
||||||
-Wait -PassThru -WindowStyle Hidden -ErrorAction Stop
|
-Wait -PassThru -WindowStyle Hidden -ErrorAction Stop
|
||||||
@@ -80,3 +88,22 @@ If ( !(Test-Path -LiteralPath $DsaResultPath -PathType Leaf) ) {
|
|||||||
Throw "dsa-collect.exe did not create the expected output: ${DsaResultPath}"
|
Throw "dsa-collect.exe did not create the expected output: ${DsaResultPath}"
|
||||||
}
|
}
|
||||||
Write-Output "DSA manifest created: ${DsaResultPath}"
|
Write-Output "DSA manifest created: ${DsaResultPath}"
|
||||||
|
|
||||||
|
If ( $DsaOptions.Upload ) {
|
||||||
|
Write-Output "Uploading DSA manifest: ${DsaResultPath}"
|
||||||
|
$DsaUploadReceipt = Upload-File -Path $DsaResultPath `
|
||||||
|
-Subject "DSA manifest file uploaded from ${Env:COMPUTERNAME}" `
|
||||||
|
-Message "DSA collection performed on ${DSAManifestSource} at $($DsaCollectionTime.ToString('yyyy-MM-dd HH:mm:ss zzz'))" `
|
||||||
|
-ErrorAction Stop
|
||||||
|
If ( [string]::IsNullOrWhiteSpace([string]$DsaUploadReceipt.AttachmentId) -or
|
||||||
|
[string]::IsNullOrWhiteSpace([string]$DsaUploadReceipt.Status) ) {
|
||||||
|
Throw 'The upload did not return a confirmation receipt. The local manifest has been retained.'
|
||||||
|
}
|
||||||
|
Write-Output "DSA manifest uploaded: ${DsaResultPath}"
|
||||||
|
|
||||||
|
If ( $DsaOptions.DeleteAfterUpload ) {
|
||||||
|
Remove-Item -LiteralPath $DsaResultPath -Force -ErrorAction Stop
|
||||||
|
Write-Output "Deleted uploaded DSA manifest: ${DsaResultPath}"
|
||||||
|
}
|
||||||
|
$DsaUploadReceipt
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
You can then run any of the scripts using the Run-Script function:
|
||||||
Run-Script -LivePSScript Script-Name
|
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'
|
||||||
|
```
|
||||||
|
|
||||||
|
`Get-DSAManifest.ps1` uploads its completed CSV or ZIP when `$Upload` is enabled. If `$DeleteAfterUpload` is also enabled, it deletes that same local file only after a confirmed successful upload. Disabled or failed uploads retain the file. `$Zip`, `$Upload`, and `$DeleteAfterUpload` all accept `true`, `$true`, `yes`, `y`, or `1`; `false`, `$false`, `no`, `n`, `0`, or an empty value disable the option. Casing and surrounding whitespace are ignored. The notification subject identifies the computer, and its message includes the source path and collection start time (local time with UTC offset).
|
||||||
|
|
||||||
|
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
|
||||||
|
powershell.exe -NoProfile -File .\tests\Get-DSAManifest.Tests.ps1
|
||||||
|
```
|
||||||
|
|||||||
@@ -209,6 +209,225 @@ Function Download-File {
|
|||||||
Return $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
|
# Install a program from a provided path
|
||||||
Function Install-Program {
|
Function Install-Program {
|
||||||
param(
|
param(
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#Requires -Version 5.0
|
||||||
|
# Offline tests: collector and upload calls are mocked; only temporary files are deleted.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
$DsaScriptUnderTest = Join-Path (Split-Path $PSScriptRoot -Parent) 'Get-DSAManifest.ps1'
|
||||||
|
$DsaTokens = $null
|
||||||
|
$DsaErrors = $null
|
||||||
|
$null = [Management.Automation.Language.Parser]::ParseFile($DsaScriptUnderTest, [ref]$DsaTokens, [ref]$DsaErrors)
|
||||||
|
If ($DsaErrors.Count) { Throw ($DsaErrors | Out-String) }
|
||||||
|
|
||||||
|
Function Assert-Dsa { param([bool]$Condition, [string]$Description) If (!$Condition) { Throw $Description } }
|
||||||
|
Function Download-File {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param($URL, $File)
|
||||||
|
$script:DsaDownloadCalls++
|
||||||
|
[IO.File]::WriteAllText($File, 'Mock executable; never executed.')
|
||||||
|
Return $File
|
||||||
|
}
|
||||||
|
Function Start-Process {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param($FilePath, $ArgumentList, [switch]$Wait, [switch]$PassThru, $WindowStyle)
|
||||||
|
$DsaMatch = [regex]::Match($ArgumentList, '"--output"\s+"([^"]+)"')
|
||||||
|
If (!$DsaMatch.Success) { Throw 'Collector output argument missing.' }
|
||||||
|
$script:DsaCsvPath = $DsaMatch.Groups[1].Value
|
||||||
|
$script:DsaGeneratedPath = $DsaCsvPath
|
||||||
|
If ($ArgumentList -match '"--zip"') { $script:DsaGeneratedPath = [IO.Path]::ChangeExtension($DsaCsvPath, '.zip') }
|
||||||
|
If (!$DsaGeneratedPath.StartsWith($script:DsaTestRoot + '\', [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
Throw 'Mock collector output is outside the test directory.'
|
||||||
|
}
|
||||||
|
If ($script:DsaTestMode -eq 'collector-failure') { Return [pscustomobject]@{ExitCode=7} }
|
||||||
|
If ($script:DsaTestMode -ne 'missing-output') {
|
||||||
|
[IO.File]::WriteAllText($DsaGeneratedPath, 'Synthetic manifest.')
|
||||||
|
If ($DsaGeneratedPath -ne $DsaCsvPath) {
|
||||||
|
# A same-basename CSV must not be deleted when the uploaded result is ZIP.
|
||||||
|
[IO.File]::WriteAllText($DsaCsvPath, 'Unrelated CSV sentinel.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Return [pscustomobject]@{ExitCode=0}
|
||||||
|
}
|
||||||
|
Function Upload-File {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param($Path, $Subject, $Message)
|
||||||
|
Assert-Dsa (Test-Path -LiteralPath $Path -PathType Leaf) 'File was deleted before upload.'
|
||||||
|
$script:DsaRecordedUploads += [pscustomobject]@{Path=$Path; Subject=$Subject; Message=$Message}
|
||||||
|
If ($script:DsaTestMode -eq 'upload-failure') { Throw 'Simulated upload failure.' }
|
||||||
|
If ($script:DsaTestMode -eq 'no-receipt') { Return }
|
||||||
|
If ($script:DsaTestMode -eq 'incomplete-receipt') { Return [pscustomobject]@{AttachmentId='test-attachment'} }
|
||||||
|
Return [pscustomobject]@{Path=$Path; AttachmentId='test-attachment'; Status='Filedrop message sent successfully'}
|
||||||
|
}
|
||||||
|
|
||||||
|
Function Invoke-DsaCase {
|
||||||
|
param([bool]$ZipExpected, [bool]$UploadExpected, [bool]$DeleteExpected, [string]$Mode='success', [hashtable]$Inputs)
|
||||||
|
$Zip = If ($ZipExpected) { ' YeS ' } Else { ' n ' }
|
||||||
|
$Upload = If ($UploadExpected) { ' $TrUe ' } Else { ' 0 ' }
|
||||||
|
$DeleteAfterUpload = If ($DeleteExpected) { ' 1 ' } Else { ' $FaLsE ' }
|
||||||
|
If ($null -ne $Inputs) {
|
||||||
|
$Zip = $Inputs.Zip
|
||||||
|
$Upload = $Inputs.Upload
|
||||||
|
$DeleteAfterUpload = $Inputs.DeleteAfterUpload
|
||||||
|
}
|
||||||
|
$DSAManifestSource = 'C:\Synthetic Source'
|
||||||
|
$Global:ToolsDirectory = Join-Path $script:DsaTestRoot 'Tools'
|
||||||
|
$Global:OutputDirectory = Join-Path $script:DsaTestRoot ([guid]::NewGuid().ToString('N'))
|
||||||
|
$script:DsaTestMode = $Mode
|
||||||
|
$script:DsaRecordedUploads = @()
|
||||||
|
$script:DsaDownloadCalls = 0
|
||||||
|
$script:DsaGeneratedPath = $null
|
||||||
|
$DsaCaught = $null
|
||||||
|
Try { . $DsaScriptUnderTest | Out-Null } Catch { $DsaCaught = $_ }
|
||||||
|
|
||||||
|
If ($Mode -eq 'invalid-input') {
|
||||||
|
Assert-Dsa ($null -ne $DsaCaught -and $DsaCaught.Exception.Message -like 'Invalid $* value*') 'Invalid flag was not rejected.'
|
||||||
|
Assert-Dsa ($script:DsaDownloadCalls -eq 0) 'Invalid flag must fail before downloading.'
|
||||||
|
Return
|
||||||
|
}
|
||||||
|
If ($Mode -in @('collector-failure','missing-output')) {
|
||||||
|
Assert-Dsa ($null -ne $DsaCaught -and $script:DsaRecordedUploads.Count -eq 0) 'Collection failure must prevent upload.'
|
||||||
|
Return
|
||||||
|
}
|
||||||
|
If ($Mode -eq 'success') { Assert-Dsa ($null -eq $DsaCaught) "Unexpected failure: ${DsaCaught}" }
|
||||||
|
Else { Assert-Dsa ($null -ne $DsaCaught) 'Expected upload/receipt failure.' }
|
||||||
|
|
||||||
|
$DsaExpectedExtension = If ($ZipExpected) { '.zip' } Else { '.csv' }
|
||||||
|
Assert-Dsa ([IO.Path]::GetExtension($script:DsaGeneratedPath) -eq $DsaExpectedExtension) 'Incorrect collected file type.'
|
||||||
|
$DsaExpectedCalls = If ($UploadExpected) { 1 } Else { 0 }
|
||||||
|
Assert-Dsa ($script:DsaRecordedUploads.Count -eq $DsaExpectedCalls) 'Unexpected upload call count.'
|
||||||
|
If ($UploadExpected) {
|
||||||
|
Assert-Dsa ($DsaRecordedUploads[0].Path -eq $script:DsaGeneratedPath) 'Upload targeted the wrong file.'
|
||||||
|
Assert-Dsa ($DsaRecordedUploads[0].Subject -ceq "DSA manifest file uploaded from ${Env:COMPUTERNAME}") 'Subject changed.'
|
||||||
|
$DsaExpectedMessage = "DSA collection performed on ${DSAManifestSource} at $($DsaCollectionTime.ToString('yyyy-MM-dd HH:mm:ss zzz'))"
|
||||||
|
Assert-Dsa ($DsaRecordedUploads[0].Message -ceq $DsaExpectedMessage) 'Message changed.'
|
||||||
|
}
|
||||||
|
$DsaShouldRetain = !($UploadExpected -and $DeleteExpected -and $Mode -eq 'success')
|
||||||
|
Assert-Dsa ((Test-Path -LiteralPath $script:DsaGeneratedPath) -eq $DsaShouldRetain) 'Incorrect deletion or retention behavior.'
|
||||||
|
If ($ZipExpected) { Assert-Dsa (Test-Path -LiteralPath $script:DsaCsvPath) 'Cleanup incorrectly deleted the CSV neighbor.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:DsaTestRoot = Join-Path ([IO.Path]::GetTempPath()) ('dsa-options-tests-' + [guid]::NewGuid().ToString('N'))
|
||||||
|
$null = New-Item -Path $script:DsaTestRoot -ItemType Directory
|
||||||
|
$DsaOriginalToolsDirectory = $Global:ToolsDirectory
|
||||||
|
$DsaOriginalOutputDirectory = $Global:OutputDirectory
|
||||||
|
Try {
|
||||||
|
Foreach ($ZipExpected in @($false,$true)) {
|
||||||
|
Foreach ($UploadExpected in @($false,$true)) {
|
||||||
|
Foreach ($DeleteExpected in @($false,$true)) {
|
||||||
|
Invoke-DsaCase $ZipExpected $UploadExpected $DeleteExpected
|
||||||
|
Write-Host "PASS: ZIP=$ZipExpected Upload=$UploadExpected DeleteAfterUpload=$DeleteExpected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Foreach ($Mode in @('upload-failure','no-receipt','incomplete-receipt','collector-failure','missing-output')) {
|
||||||
|
Invoke-DsaCase $ZipExpected $true $true $Mode
|
||||||
|
Write-Host "PASS: ZIP=$ZipExpected $Mode prevents deletion."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Invoke-DsaCase $false $false $false -Inputs @{}
|
||||||
|
Foreach ($Name in @('Zip','Upload','DeleteAfterUpload')) {
|
||||||
|
$Inputs = @{Zip='no'; Upload='no'; DeleteAfterUpload='no'}
|
||||||
|
$Inputs[$Name] = 'maybe'
|
||||||
|
Invoke-DsaCase $false $false $false 'invalid-input' $Inputs
|
||||||
|
}
|
||||||
|
Write-Host "PASS: empty options default to false; invalid options fail early."
|
||||||
|
Write-Host "All Get-DSAManifest tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size * 8)-bit."
|
||||||
|
}
|
||||||
|
Finally {
|
||||||
|
$Global:ToolsDirectory = $DsaOriginalToolsDirectory
|
||||||
|
$Global:OutputDirectory = $DsaOriginalOutputDirectory
|
||||||
|
$DsaResolvedRoot = (Resolve-Path -LiteralPath $script:DsaTestRoot).Path
|
||||||
|
$DsaTempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
|
||||||
|
If (!$DsaResolvedRoot.StartsWith($DsaTempRoot, [StringComparison]::OrdinalIgnoreCase) -or
|
||||||
|
(Split-Path $DsaResolvedRoot -Leaf) -notlike 'dsa-options-tests-*') { Throw 'Unexpected cleanup directory.' }
|
||||||
|
Remove-Item -LiteralPath $DsaResolvedRoot -Recurse -Force
|
||||||
|
}
|
||||||
@@ -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