From 35c1904d0da4879541b319282b0b72f104a3c86e Mon Sep 17 00:00:00 2001 From: ek-dyoder Date: Wed, 30 Sep 2026 08:12:55 -0400 Subject: [PATCH] add upload feature to DSA manifest creation --- Get-DSAManifest.ps1 | 53 +++++++++---- README.md | 6 +- tests/Get-DSAManifest.Tests.ps1 | 132 ++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 tests/Get-DSAManifest.Tests.ps1 diff --git a/Get-DSAManifest.ps1 b/Get-DSAManifest.ps1 index 77f2b57..aee33e4 100644 --- a/Get-DSAManifest.ps1 +++ b/Get-DSAManifest.ps1 @@ -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 +} diff --git a/README.md b/README.md index 5106f02..bceff4f 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,10 @@ After dot-sourcing `Tools.ps1`, scripts can upload a completed file to the Ember ```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}" ``` +`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. @@ -31,4 +30,5 @@ 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 ``` diff --git a/tests/Get-DSAManifest.Tests.ps1 b/tests/Get-DSAManifest.Tests.ps1 new file mode 100644 index 0000000..b5310f0 --- /dev/null +++ b/tests/Get-DSAManifest.Tests.ps1 @@ -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 +}