Compare commits

...
2 Commits
Author SHA1 Message Date
dyoder 35c1904d0d add upload feature to DSA manifest creation 2026-09-30 08:12:55 -04:00
dyoder c74fe82d36 add Upload-File function to Tools.ps1 2026-09-30 07:45:01 -04:00
5 changed files with 591 additions and 14 deletions
+40 -13
View File
@@ -2,15 +2,18 @@
<#
.SYNOPSIS
Downloads and runs the DSA manifest collector.
Downloads and runs the DSA manifest collector, with optional upload and cleanup.
.DESCRIPTION
Tools.ps1 must be dot-sourced by the caller first. Set $DSAManifestSource
in the caller's scope before using Run-Script -LivePSScript Get-DSAManifest.
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.
Set $Zip to 'true', '$true', 'yes', 'y', or '1' to include --zip.
Use 'false', '$false', 'no', 'n', or '0' to disable ZIP; an empty value also disables it.
Values are case-insensitive and surrounding whitespace is ignored.
$Zip enables ZIP compression; $Upload sends the result to the FileDrop;
$DeleteAfterUpload removes the local result only after a confirmed upload.
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) ) {
@@ -18,16 +21,20 @@ If ( [string]::IsNullOrWhiteSpace([string]$DSAManifestSource) ) {
}
# Normalize RMM text values without treating every nonempty string as true.
$DsaZipValue = ([string]$Zip).Trim().ToLowerInvariant()
If ( $DsaZipValue -in @('true', '$true', 'yes', 'y', '1') ) {
$DsaZipEnabled = $true
}
ElseIf ( $DsaZipValue -in @('', 'false', '$false', 'no', 'n', '0') ) {
$DsaZipEnabled = $false
}
Else {
Throw 'Invalid $Zip value. Use true, $true, yes, y, 1, false, $false, no, n, or 0.'
$DsaOptions = @{ Zip = $Zip; Upload = $Upload; DeleteAfterUpload = $DeleteAfterUpload }
ForEach ( $DsaOptionName in @('Zip', 'Upload', 'DeleteAfterUpload') ) {
$DsaOptionValue = ([string]$DsaOptions[$DsaOptionName]).Trim().ToLowerInvariant()
If ( $DsaOptionValue -in @('true', '$true', 'yes', 'y', '1') ) {
$DsaOptions[$DsaOptionName] = $true
}
ElseIf ( $DsaOptionValue -in @('', 'false', '$false', 'no', 'n', '0') ) {
$DsaOptions[$DsaOptionName] = $false
}
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 (
'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')
}) -join ' '
$DsaCollectionTime = Get-Date
Write-Output "Running dsa-collect.exe (ZIP compression: ${DsaZipEnabled})"
$DsaProcess = Start-Process -FilePath $DsaExecutablePath -ArgumentList $DsaCommandLine `
-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}"
}
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
}
+26 -1
View File
@@ -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'
```
`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
```
+219
View File
@@ -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(
+132
View File
@@ -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
}
+174
View File
@@ -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
}