Files
elysium/tests/FakeStorage.ps1
tomas.kracmar 5714570ad2 fix(storage): detect a download that closed before Content-Length and retry
HttpClient's GetAwaiter().GetResult() on ReadAsStreamAsync returns once headers arrive; a
connection that then closes early leaves CopyTo finishing normally with a short file instead of
throwing. Compare the written length against the advertised Content-Length and treat a mismatch
as a transient failure. FakeStorage.ps1 can now simulate this (keys containing '/short/').
2026-11-06 09:16:55 +01:00

83 lines
4.2 KiB
PowerShell

# Test helper: a minimal S3-style object store on HttpListener, served from a background runspace so
# the real HttpClient/SigV4/runspace-pool code can run against it on any OS.
# - GET <bucket>/<key> serves $Root/<bucket>/<key>, 404 if missing.
# - PUT stores the body only if it matches x-amz-content-sha256, else 400 BadDigest (as S3 does).
# - Keys containing '/flaky/' answer 503 to their first GET; keys containing '/reject/' refuse PUTs.
# - Keys containing '/short/' advertise the full Content-Length on their first GET, then close
# after all but the last byte. That is a truncated body with a success status, not an HTTP error.
# Requests are logged as "<METHOD> <bucket>/<key>"; Get-FakeStorageLog drains the log.
function Start-FakeStorage {
param([Parameter(Mandatory)][string]$Root)
$probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$probe.Start(); $port = $probe.LocalEndpoint.Port; $probe.Stop()
$listener = [System.Net.HttpListener]::new()
$listener.Prefixes.Add("http://localhost:$port/")
$listener.Start()
$log = [System.Collections.Concurrent.ConcurrentQueue[string]]::new()
$serve = {
param($listener, $root, $log)
$gets = @{}
while ($listener.IsListening) {
try { $context = $listener.GetContext() } catch { break }
$request = $context.Request
$response = $context.Response
$key = [uri]::UnescapeDataString($request.Url.AbsolutePath.TrimStart('/'))
$path = Join-Path $root $key
$log.Enqueue("$($request.HttpMethod) $key")
try {
if ($request.HttpMethod -eq 'GET') {
$gets[$key] = 1 + [int]$gets[$key]
if ($key -like '*/flaky/*' -and $gets[$key] -eq 1) {
$response.StatusCode = 503
} elseif ($key -like '*/short/*' -and $gets[$key] -eq 1 -and (Test-Path -LiteralPath $path -PathType Leaf)) {
$bytes = [System.IO.File]::ReadAllBytes($path)
$response.ContentLength64 = $bytes.Length
if ($bytes.Length -gt 1) { $response.OutputStream.Write($bytes, 0, $bytes.Length - 1) }
} elseif (Test-Path -LiteralPath $path -PathType Leaf) {
$bytes = [System.IO.File]::ReadAllBytes($path)
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
} else {
$response.StatusCode = 404
}
} elseif ($request.HttpMethod -eq 'PUT') {
$buffer = [System.IO.MemoryStream]::new()
$request.InputStream.CopyTo($buffer)
$body = $buffer.ToArray()
$sha = [System.Security.Cryptography.SHA256]::Create()
$actual = [BitConverter]::ToString($sha.ComputeHash($body)).Replace('-', '').ToLowerInvariant()
if ($key -like '*/reject/*' -or $request.Headers['x-amz-content-sha256'] -ne $actual) {
$response.StatusCode = 400
$message = [System.Text.Encoding]::UTF8.GetBytes('<Error><Code>BadDigest</Code></Error>')
$response.OutputStream.Write($message, 0, $message.Length)
} else {
[void][System.IO.Directory]::CreateDirectory((Split-Path -Path $path -Parent))
[System.IO.File]::WriteAllBytes($path, $body)
}
}
} finally {
$response.Close()
}
}
}
$shell = [powershell]::Create()
[void]$shell.AddScript($serve.ToString()).AddArgument($listener).AddArgument($Root).AddArgument($log)
[pscustomobject]@{ Url = "http://localhost:$port"; Listener = $listener; Shell = $shell; Handle = $shell.BeginInvoke(); Log = $log }
}
function Stop-FakeStorage {
param([Parameter(Mandatory)]$Server)
$Server.Listener.Stop()
$Server.Listener.Close()
$Server.Shell.Dispose()
}
function Get-FakeStorageLog {
param([Parameter(Mandatory)]$Server)
$entry = $null
while ($Server.Log.TryDequeue([ref]$entry)) { $entry }
}