<# .SYNOPSIS Tests deploy-generator.ps1 end to end against a local mock WebDAV server. .DESCRIPTION Starts mock-webdav.ps1 on a free localhost port, then runs the deploy script in a child process for each scenario, asserting on its exit code, its output, the files left on the "server", and the exact sequence of WebDAV methods it sent. Needs no network and no real credentials. Usage: powershell -NoProfile -ExecutionPolicy Bypass -File test-deploy.ps1 Exit code 0 when every check passes. #> $ErrorActionPreference = 'Stop' $here = $PSScriptRoot $deploy = Join-Path (Split-Path $here -Parent) 'deploy-generator.ps1' $realGen = Join-Path (Split-Path $here -Parent) 'hcl-catalog-generate.php' $work = Join-Path ([IO.Path]::GetTempPath()) ("hcl-deploy-test-" + [Guid]::NewGuid().ToString('N').Substring(0, 8)) $root = Join-Path $work 'server-root' $log = Join-Path $work 'requests.log' $envFile = Join-Path $work 'test.env' $source = Join-Path $work 'hcl-catalog-generate.php' $user = 'deployer' $pass = 'S3cret-' + [Guid]::NewGuid().ToString('N').Substring(0, 12) New-Item -ItemType Directory -Force -Path $root | Out-Null # The deploy source is a copy of the real generator, outside any SVN working # copy, so the uncommitted-changes guard does not apply to it. Copy-Item -LiteralPath $realGen -Destination $source $oldGen = " function Invoke-Deploy([string[]] $ExtraArgs, [switch] $DefaultSource) { $args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $deploy, '-EnvFile', $envFile) if (-not $DefaultSource) { $args += @('-SourceFile', $source) } $args += $ExtraArgs $out = & powershell.exe @args 2>&1 | Out-String $script:outputs.Add($out) return [pscustomobject]@{ Code = $LASTEXITCODE; Out = $out } } function Test-Case([string] $Name, [scriptblock] $Body) { $script:current = $Name Reset-Server try { & $Body } catch { $script:failures.Add("${Name}: threw $($_.Exception.Message)") } } # --------------------------------------------------------------------------- $port = Get-FreePort $base = "http://localhost:$port" $mock = Start-Process -FilePath powershell.exe -PassThru -WindowStyle Hidden -ArgumentList @( '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $here 'mock-webdav.ps1'), '-Port', $port, '-Root', $root, '-User', $user, '-Pass', $pass, '-LogFile', $log) try { # Wait for the mock to accept connections. $up = $false for ($i = 0; $i -lt 50 -and -not $up; $i++) { try { $c = New-Object Net.Sockets.TcpClient('localhost', $port); $c.Close(); $up = $true } catch { Start-Sleep -Milliseconds 200 } } if (-not $up) { throw 'mock WebDAV server did not start' } $good = @{ WEBDAV_URL = $base; WEBDAV_USER = $user; WEBDAV_PASS = $pass; HCL_REMOTE_DIR = '/safePHP/'; HCL_CATALOG_URL = "$base/public/catalog.json" } $live = Join-Path $root 'safePHP\hcl-catalog-generate.php' $bak = "$live.bak" $upl = "$live.uploading" function Seed-Old { New-Item -ItemType Directory -Force -Path (Split-Path $live) | Out-Null; [IO.File]::WriteAllText($live, $oldGen) } Test-Case 'misconfigured remote dir is caught before writing' { Write-Env $good $r = Invoke-Deploy @() Check ($r.Code -eq 1) "exit code 1 (got $($r.Code))" Check ($r.Out -match 'HCL_REMOTE_DIR' -and $r.Out -match 'AllowNew') 'message names HCL_REMOTE_DIR and -AllowNew' Check ((Get-Requests) -join ',' -eq 'GET /safePHP/hcl-catalog-generate.php') "only one GET (got: $((Get-Requests) -join ', '))" Check (-not (Test-Path $live)) 'nothing written' } Test-Case 'refuses to overwrite a file that is not the generator' { Write-Env $good New-Item -ItemType Directory -Force -Path (Split-Path $live) | Out-Null [IO.File]::WriteAllText($live, 'something else entirely') $r = Invoke-Deploy @() Check ($r.Code -eq 1) 'exit code 1' Check ($r.Out -match 'does not look like the catalog generator') 'explains why' Check ([IO.File]::ReadAllText($live) -eq 'something else entirely') 'file untouched' Check ((Get-Requests).Count -eq 1) 'no writes' } Test-Case 'dry run writes nothing' { Write-Env $good; Seed-Old $r = Invoke-Deploy @('-DryRun') Check ($r.Code -eq 0) 'exit code 0' Check ($r.Out -match 'Dry run') 'says dry run' Check ([IO.File]::ReadAllText($live) -eq $oldGen) 'live unchanged' Check (-not (Test-Path $bak)) 'no backup made' Check ((Get-Requests).Count -eq 1) 'only the GET' } Test-Case 'deploy: backup, upload, verify, move, verify' { Write-Env $good; Seed-Old $r = Invoke-Deploy @() Check ($r.Code -eq 0) "exit code 0 (got $($r.Code)): $($r.Out)" Check ((Get-FileSha $live) -eq (Get-FileSha $source)) 'live now matches the source' Check ((Test-Path $bak) -and [IO.File]::ReadAllText($bak) -eq $oldGen) '.bak holds the old version' Check (-not (Test-Path $upl)) 'no .uploading left behind' $expected = @( 'GET /safePHP/hcl-catalog-generate.php', 'COPY /safePHP/hcl-catalog-generate.php', 'PUT /safePHP/hcl-catalog-generate.php.uploading', 'GET /safePHP/hcl-catalog-generate.php.uploading', 'MOVE /safePHP/hcl-catalog-generate.php.uploading', 'GET /safePHP/hcl-catalog-generate.php') Check (((Get-Requests) -join '|') -eq ($expected -join '|')) "request order (got: $((Get-Requests) -join ', '))" Check ($r.Out -match 'VerifyCatalog') 'points at the outside check' } Test-Case 'cPanel-style 200 replies to COPY/MOVE are accepted (seen live 2026-09-25)' { Write-Env $good; Seed-Old [IO.File]::WriteAllText((Join-Path $root '.mock-cpanel'), '') $r = Invoke-Deploy @() Check ($r.Code -eq 0) "exit code 0 (got $($r.Code)): $($r.Out)" Check ((Get-FileSha $live) -eq (Get-FileSha $source)) 'deployed' Check ((Test-Path $bak) -and [IO.File]::ReadAllText($bak) -eq $oldGen) 'backed up' Check (-not (Test-Path $upl)) 'no .uploading left behind' $r = Invoke-Deploy @('-Rollback') Check ($r.Code -eq 0) "rollback exit code 0 (got $($r.Code)): $($r.Out)" Check ([IO.File]::ReadAllText($live) -eq $oldGen) 'rollback restored the old version' } Test-Case 'redeploying the same file is a no-op' { Write-Env $good; Seed-Old [void](Invoke-Deploy @()) Set-Content -LiteralPath $log -Value $null $r = Invoke-Deploy @() Check ($r.Code -eq 0) 'exit code 0' Check ($r.Out -match 'Already deployed') 'says already deployed' Check ((Get-Requests).Count -eq 1) 'only the GET' } Test-Case 'rollback restores the previous version' { Write-Env $good; Seed-Old [void](Invoke-Deploy @()) $r = Invoke-Deploy @('-Rollback') Check ($r.Code -eq 0) "exit code 0 (got $($r.Code)): $($r.Out)" Check ([IO.File]::ReadAllText($live) -eq $oldGen) 'live is the old version again' } Test-Case 'rollback without a backup fails cleanly' { Write-Env $good; Seed-Old $r = Invoke-Deploy @('-Rollback') Check ($r.Code -eq 1) 'exit code 1' Check ($r.Out -match 'No backup') 'says there is no backup' Check ([IO.File]::ReadAllText($live) -eq $oldGen) 'live untouched' } Test-Case 'first deploy with -AllowNew' { Write-Env $good $r = Invoke-Deploy @('-AllowNew') Check ($r.Code -eq 0) "exit code 0 (got $($r.Code)): $($r.Out)" Check ((Get-FileSha $live) -eq (Get-FileSha $source)) 'deployed' Check (-not (Test-Path $bak)) 'nothing to back up' Check (-not ((Get-Requests) -match '^COPY')) 'no COPY issued' } Test-Case 'wrong password: fails, writes nothing, leaks nothing' { $bad = $good.Clone(); $bad.WEBDAV_PASS = 'wrong-' + $pass Write-Env $bad; Seed-Old $r = Invoke-Deploy @() Check ($r.Code -eq 1) 'exit code 1' Check ($r.Out -match 'authentication failed') 'says authentication failed' Check ([IO.File]::ReadAllText($live) -eq $oldGen) 'live untouched' Check (-not $r.Out.Contains($bad.WEBDAV_PASS)) 'password not printed' } Test-Case 'plain http to a non-local host is refused before connecting' { $insecure = $good.Clone(); $insecure.WEBDAV_URL = 'http://webdisk.example.invalid' Write-Env $insecure $r = Invoke-Deploy @() Check ($r.Code -eq 1) 'exit code 1' Check ($r.Out -match 'must use https') 'explains' } Test-Case 'missing setting is named, value-free' { $partial = $good.Clone(); $partial.Remove('WEBDAV_PASS') Write-Env $partial $r = Invoke-Deploy @() Check ($r.Code -eq 1) 'exit code 1' Check ($r.Out -match 'WEBDAV_PASS is not set') 'names the key' } Test-Case 'default source path resolves (PowerShell 5.1 param-default trap)' { Write-Env $good; Seed-Old $r = Invoke-Deploy @('-DryRun') -DefaultSource Check ($r.Code -eq 0) "exit code 0 (got $($r.Code)): $($r.Out)" Check ($r.Out -match ("Local : {0} bytes" -f (Get-Item -LiteralPath $realGen).Length)) 'deploys server\hcl-catalog-generate.php by default' } Test-Case 'quoted values and comments in .env' { [IO.File]::WriteAllLines($envFile, [string[]]@( '# comment', '', "WEBDAV_URL=`"$base`"", "WEBDAV_USER='$user'", "WEBDAV_PASS=$pass", 'HCL_REMOTE_DIR=safePHP')) Seed-Old $r = Invoke-Deploy @('-DryRun') Check ($r.Code -eq 0) "exit code 0 (got $($r.Code)): $($r.Out)" } Test-Case 'VerifyCatalog: old-style catalog reports not yet' { Write-Env $good New-Item -ItemType Directory -Force -Path (Join-Path $root 'public') | Out-Null [IO.File]::WriteAllText((Join-Path $root 'public\catalog.json'), '{"generated":"2026-09-25T12:00:46Z","categories":[{"files":[{"fileName":"","size":65752}],"subcategories":[]}]}') $r = Invoke-Deploy @('-VerifyCatalog') Check ($r.Code -eq 2) "exit code 2 (got $($r.Code)): $($r.Out)" Check ($r.Out -match '0 with a filename') 'reports counts' } Test-Case 'VerifyCatalog: fixed catalog passes' { Write-Env $good New-Item -ItemType Directory -Force -Path (Join-Path $root 'public') | Out-Null [IO.File]::WriteAllText((Join-Path $root 'public\catalog.json'), '{"generated":"2026-09-25T16:00:00Z","categories":[{"files":[{"fileName":"a.zip","size":5411224}],"subcategories":[{"files":[{"fileName":"b.zip","size":1,"artifactType":"ExportDLL"}],"subcategories":[]}]}]}') $r = Invoke-Deploy @('-VerifyCatalog') Check ($r.Code -eq 0) "exit code 0 (got $($r.Code)): $($r.Out)" Check ($r.Out -match '2 with a filename, 1 with a file-level artifactType') 'counts nested files' } $script:current = 'no output ever contains the credentials' $all = $script:outputs -join "`n" Check (-not $all.Contains($pass)) 'password never printed' Check (-not ($all -match [regex]::Escape([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${user}:${pass}"))))) 'auth header never printed' } finally { try { Invoke-WebRequest -UseBasicParsing "$base/__shutdown" -TimeoutSec 5 | Out-Null } catch { } if (-not $mock.WaitForExit(5000)) { Stop-Process -Id $mock.Id -Force -ErrorAction SilentlyContinue } Remove-Item -Recurse -Force -Path $work -ErrorAction SilentlyContinue } $total = $script:passed + $script:failures.Count Write-Host "$($script:passed)/$total checks passed" foreach ($f in $script:failures) { Write-Host "FAIL $f" -ForegroundColor Red } exit ([int]($script:failures.Count -gt 0))