Files
management-scripts/tests/Test-FileDownload.Tests.ps1
2026-09-30 10:34:09 -04:00

143 lines
10 KiB
PowerShell

#Requires -Version 5.0
# Local HTTP fixtures only. No external downloads, execution of payloads, or uploads.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$Repo = Split-Path $PSScriptRoot -Parent
$Diagnostic = Join-Path $Repo 'Test-FileDownload.ps1'
$Tokens=$null; $Errors=$null
$Ast=[Management.Automation.Language.Parser]::ParseFile($Diagnostic,[ref]$Tokens,[ref]$Errors)
if ($Errors.Count) { throw ($Errors | Out-String) }
$WorkerAssignment=$Ast.Find({param($Node) $Node -is [Management.Automation.Language.AssignmentStatementAst] -and $Node.Left.Extent.Text -eq '$FdWorker'},$true)
$WorkerExpression=$WorkerAssignment.Find({param($Node) $Node -is [Management.Automation.Language.ScriptBlockExpressionAst]},$true)
$Worker=$WorkerExpression.ScriptBlock.GetScriptBlock()
$ToolsAst=[Management.Automation.Language.Parser]::ParseFile((Join-Path $Repo 'Tools.ps1'),[ref]$Tokens,[ref]$Errors)
foreach ($Name in @('Download-File','Get-AbsoluteURI','IsURLValid','Get-TempPath')) {
$Definition=$ToolsAst.Find({param($Node) $Node -is [Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq $Name},$true)
. ([scriptblock]::Create($Definition.Extent.Text))
}
Import-Module BitsTransfer
function Assert-Fd([bool]$Condition,[string]$Message) { if (!$Condition) { throw $Message } }
Add-Type -TypeDefinition @'
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
public sealed class FdHttpFixture : IDisposable {
private readonly TcpListener listener;
private volatile bool stopping;
public readonly byte[] Payload = new byte[131072];
public readonly string BaseUrl;
public FdHttpFixture() {
for (int i=0;i<Payload.Length;i++) Payload[i]=(byte)(i%251);
listener=new TcpListener(IPAddress.Loopback,0); listener.Start();
BaseUrl="http://127.0.0.1:"+((IPEndPoint)listener.LocalEndpoint).Port;
Thread t=new Thread(Accept); t.IsBackground=true; t.Start();
}
private void Accept() {
while(!stopping) {
try { TcpClient c=listener.AcceptTcpClient(); ThreadPool.QueueUserWorkItem(Serve,c); }
catch { if(stopping) return; }
}
}
private void Serve(object state) {
using(TcpClient client=(TcpClient)state) {
try {
client.ReceiveTimeout=5000; client.SendTimeout=5000;
NetworkStream stream=client.GetStream();
StreamReader reader=new StreamReader(stream,Encoding.ASCII);
string first=reader.ReadLine(); if(first==null) return;
string[] parts=first.Split(' '); string method=parts[0],path=parts[1];
string range=null,line;
while(!String.IsNullOrEmpty(line=reader.ReadLine())) if(line.StartsWith("Range:",StringComparison.OrdinalIgnoreCase)) range=line.Substring(6).Trim();
if(path.StartsWith("/redirect")) { Send(stream,"302 Found","Location: /file.bin\r\nContent-Length: 0\r\n",null); return; }
if(path.StartsWith("/missing")) { Send(stream,"404 Not Found","Content-Length: 0\r\n",null); return; }
if(path.StartsWith("/empty")) { Send(stream,"200 OK","Content-Length: 0\r\n",null); return; }
if(path.StartsWith("/truncated")) { Send(stream,"200 OK","Content-Length: 100\r\n",Encoding.ASCII.GetBytes("short")); return; }
if(path.StartsWith("/chunked")) { Send(stream,"200 OK","Transfer-Encoding: chunked\r\n",Encoding.ASCII.GetBytes("5\r\nhello\r\n0\r\n\r\n")); return; }
int start=0,end=Payload.Length-1; string status="200 OK",extra="";
if(range!=null) {
string[] limits=range.Substring(6).Split('-'); start=Int32.Parse(limits[0]);
if(limits.Length>1 && limits[1].Length>0) end=Math.Min(end,Int32.Parse(limits[1]));
status="206 Partial Content"; extra="Content-Range: bytes "+start+"-"+end+"/"+Payload.Length+"\r\n";
}
byte[] body=new byte[end-start+1]; Buffer.BlockCopy(Payload,start,body,0,body.Length);
Send(stream,status,extra+"Content-Length: "+body.Length+"\r\n",method=="HEAD"?null:body);
} catch { }
}
}
private static void Send(NetworkStream stream,string status,string headers,byte[] body) {
byte[] header=Encoding.ASCII.GetBytes("HTTP/1.1 "+status+"\r\nConnection: close\r\nContent-Type: application/octet-stream\r\nAccept-Ranges: bytes\r\nLast-Modified: Mon, 01 Jan 2024 00:00:00 GMT\r\n"+headers+"\r\n");
stream.Write(header,0,header.Length); if(body!=null) stream.Write(body,0,body.Length);
}
public void Dispose() { stopping=true; listener.Stop(); }
}
'@
$Fixture=New-Object FdHttpFixture
$Root=Join-Path ([IO.Path]::GetTempPath()) ('fd-diag-tests-' + [guid]::NewGuid().ToString('N'))
$null=New-Item -Path $Root -ItemType Directory
$SentinelJob=$null
try {
# Another application's BITS job must survive all diagnostic cleanups.
$SentinelJob=BitsTransfer\Start-BitsTransfer -Source ($Fixture.BaseUrl+'/file.bin') -Destination (Join-Path $Root 'unrelated.bin') -Suspended -Asynchronous -DisplayName ('fd-test-sentinel-'+[guid]::NewGuid()) -Description 'Unrelated test job'
$OriginalProtocol=[Net.ServicePointManager]::SecurityProtocol
$OriginalHelper=(Get-Command Download-File).Definition
$ReportPaths=& $Diagnostic -URL ($Fixture.BaseUrl+'/redirect.exe') -OutputDirectory (Join-Path $Root 'reports with spaces') -TimeoutSeconds 15
$Report=Import-Clixml -LiteralPath $ReportPaths.XmlReport
Assert-Fd ($Report.Probes.Count -eq 10) 'Expected metadata plus both destination variants for four download methods.'
Assert-Fd ($Report.Context.DestinationExtension -eq '.exe') 'Original file extension was not preserved.'
$Failures=@($Report.Probes | Where-Object {$_.Status -ne 'Complete'})
Assert-Fd ($Failures.Count -eq 0) ('Unexpected failures: '+($Failures | Select-Object Name,Status,@{Name='Message';Expression={$_.Errors.Message -join '; '}} | Format-List | Out-String))
$Downloads=@($Report.Probes | Where-Object {$_.Snapshots.Count -gt 0})
$Hasher=[Security.Cryptography.SHA256]::Create()
try { $ExpectedHash=[BitConverter]::ToString($Hasher.ComputeHash($Fixture.Payload)).Replace('-','') } finally {$Hasher.Clear()}
foreach ($Probe in $Downloads) {
$Snapshot=$Probe.Snapshots[-1]
Assert-Fd ($Snapshot.Length -eq $Fixture.Payload.Length -and $Snapshot.SHA256 -eq $ExpectedHash) ('Incorrect bytes: '+$Probe.Name)
Assert-Fd ($Probe.ChildProcessBits -eq [IntPtr]::Size*8) 'Child changed process architecture.'
}
Assert-Fd ((Get-Command Download-File).Definition -ceq $OriginalHelper) 'Diagnostic modified the caller helper.'
Assert-Fd ([Net.ServicePointManager]::SecurityProtocol -eq $OriginalProtocol) 'Diagnostic modified caller TLS.'
Assert-Fd (@($Report.Cleanup | Where-Object {$_.Status -ne 'Complete' -or $_.CleanupErrors.Count}).Count -eq 0) 'Cleanup reported an error.'
Assert-Fd (!(Test-Path -LiteralPath (Join-Path $Report.ReportDirectory 'work'))) 'Scratch files were not removed.'
Assert-Fd (Test-Path -LiteralPath $ReportPaths.TextReport) 'Readable report missing.'
Assert-Fd (@(($Report.Probes | Where-Object Method -eq 'Headers').Http | Where-Object Status -eq 302).Count -gt 0) 'Redirect chain missing.'
$null=BitsTransfer\Get-BitsTransfer -JobId $SentinelJob.JobId -ErrorAction Stop
Write-Host 'PASS: helper, sync BITS, inspectable BITS, and GET; redirects; existing-empty overwrite; hashes; isolation; cleanup.'
# Exercise response edge cases directly through the same diagnostic worker.
foreach ($Case in @('empty','chunked','truncated','missing')) {
$ContextPath=Join-Path $Root ($Case+'-context.xml')
@{URL=$Fixture.BaseUrl+'/'+$Case; SecurityProtocol=[int]$OriginalProtocol; TimeoutSeconds=5; RunId='test'} | Export-Clixml -Path $ContextPath
$SpecPath=Join-Path $Root ($Case+'-input.xml')
$ResultPath=Join-Path $Root ($Case+'-result.xml')
@{Name=$Case; Method='DotNetGet'; ExistingEmpty=$false; ContextPath=$ContextPath; ResultPath=$ResultPath; Destination=(Join-Path $Root ($Case+'.bin'))} | Export-Clixml -Path $SpecPath
& $Worker $SpecPath
$Edge=Import-Clixml -LiteralPath $ResultPath
if ($Case -eq 'empty') { Assert-Fd ($Edge.Status -eq 'EmptyFile' -and $Edge.Snapshots[-1].Length -eq 0) 'Empty body misclassified.' }
elseif ($Case -eq 'chunked') { Assert-Fd ($Edge.Status -eq 'Complete' -and $Edge.Snapshots[-1].Length -eq 5) 'Chunked response failed.' }
else { Assert-Fd ($Edge.Status -eq 'Failed' -and $Edge.Errors.Count -gt 0) ('Failure details missing: '+$Case) }
Write-Host "PASS: $Case response."
}
function Download-File { [CmdletBinding()] param($URL,$File) Start-Sleep -Seconds 90 }
$Watch=[Diagnostics.Stopwatch]::StartNew()
$TimeoutPaths=& $Diagnostic -URL ($Fixture.BaseUrl+'/file.bin') -OutputDirectory $Root -TimeoutSeconds 3
$TimeoutReport=Import-Clixml -LiteralPath $TimeoutPaths.XmlReport
$HelperTimeouts=@($TimeoutReport.Probes | Where-Object {$_.Method -eq 'Helper' -and $_.Status -eq 'TimedOut'})
Assert-Fd ($HelperTimeouts.Count -eq 2) 'Stalled helper was not bounded in both destination variants.'
Assert-Fd ($Watch.Elapsed.TotalSeconds -lt 100) 'Timeout run exceeded the expected total bound.'
Assert-Fd (!(Test-Path -LiteralPath (Join-Path $TimeoutReport.ReportDirectory 'work'))) 'Timed-out scratch files remain.'
$null=BitsTransfer\Get-BitsTransfer -JobId $SentinelJob.JobId -ErrorAction Stop
Write-Host 'PASS: stalled helper is stopped; partial report survives; unrelated BITS job survives.'
Write-Host "All Test-FileDownload tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size*8)-bit."
} finally {
$Fixture.Dispose()
if ($SentinelJob) { BitsTransfer\Remove-BitsTransfer -BitsJob $SentinelJob -ErrorAction SilentlyContinue }
$Resolved=(Resolve-Path -LiteralPath $Root).Path
$Temp=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')+'\'
if (!$Resolved.StartsWith($Temp,[StringComparison]::OrdinalIgnoreCase) -or (Split-Path $Resolved -Leaf) -notlike 'fd-diag-tests-*') { throw 'Unexpected cleanup path.' }
Remove-Item -LiteralPath $Resolved -Recurse -Force
}