From 035e93fcbea33aa84b44ff27d4656e4e94de3da9 Mon Sep 17 00:00:00 2001 From: kellymichels Date: Sun, 26 Jul 2026 12:49:47 -0500 Subject: [PATCH] test: cover target-argument parsing across the scripts (phase 4) (#29) Part of #26. This layer has regressed more than any other - bare vs dashed keys, 'all' expansion, and whether a bad key fails loudly or quietly selects nothing. 42 tests over three guarantees: - Running bare shows usage and exits non-zero, for all ten target-taking scripts. Some of these used to mean 'do it to everything' when run with no args, which is how an unintended full backup or deploy happens. - An unknown key fails loudly. Exiting 0 having selected nothing is the dangerous outcome: a typo'd key in a scheduled task looks like a successful run that backed up nothing. - A leading dash is stripped before the lookup, so -myapp == myapp. Asserted via a dashed *unknown* key, so the error must name 'x' rather than '-x'. Plus zkill's own resolution: bare key, dashed key, several keys, 'all' and '-all' expanding to projects that have a ports.dev, edge/docker stacks skipped, and -Port overriding the configured port. Safety: the tests run the real scripts as child processes, so they are confined to paths that exit before doing any work. Only zkill runs with a valid target, because the fixture's dev ports (59990/59991) are deliberately unused and its localRoots do not exist - it finds no listeners and kills nothing. zdeploy, zbackup_ec2, zec2, zec2online, zrepair, zstop, zstart and zbackup are never invoked with a real target; that is integration territory needing a disposable server. Each test runs against an isolated temp installation - the .ps1 files copied beside a fixture zconfig.json - so nothing touches this repo, no real config is read, and the suite passes on a machine that has never been configured. That also makes it independent of the ZCONFIG seam in #27, so the PRs can merge in any order. Verified by mutation testing: removing zkill's all-expansion, dash tolerance and no-args guard, making an unknown key exit 0, and letting underscore comment keys leak in as projects each turn the suite red (2/1/2/16/4 tests). Both mutated files confirmed restored byte-for-byte. --- tests/ArgumentParsing.Tests.ps1 | 217 ++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tests/ArgumentParsing.Tests.ps1 diff --git a/tests/ArgumentParsing.Tests.ps1 b/tests/ArgumentParsing.Tests.ps1 new file mode 100644 index 0000000..16174ff --- /dev/null +++ b/tests/ArgumentParsing.Tests.ps1 @@ -0,0 +1,217 @@ +# Evomedia.net Token Savers - https://github.com/kellymichels/zscripts-token-savers +# Created by Kelly Michels - dev@evomedia.net +# Licensed under the MIT License. See LICENSE. + +# ArgumentParsing.Tests.ps1 - Pester 5 suite for how the scripts read their +# target arguments. +# +# Invoke-Pester .\tests +# +# This is the layer that has regressed most often: bare vs dashed keys, 'all' +# expansion, and whether a bad key fails loudly or silently selects nothing. +# +# SAFETY +# ------ +# These tests run the real scripts as child processes, so they are restricted to +# code paths that exit BEFORE doing any work: +# +# * no arguments -> usage + non-zero exit +# * unknown project key -> error + non-zero exit (Get-ZProject exits first) +# +# Only zkill is exercised with a *valid* target, because with the fixture config +# it looks for listeners on deliberately unused high ports and finds none. The +# scripts that SSH, scp, deploy, or start servers (zdeploy, zbackup_ec2, zec2, +# zec2online, zrepair, zstop, zstart, zbackup) are NEVER invoked with a real +# target here - that is integration territory and needs a disposable server. +# +# Each test runs against an isolated temp installation: the .ps1 files copied to +# a temp dir alongside a fixture zconfig.json. Nothing touches this repo, and no +# real zconfig.json is read - so the suite is safe on a configured machine and +# passes on one that has never been configured. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + + # Isolated installation: the scripts, plus a fixture config beside them. + $script:Install = Join-Path ([IO.Path]::GetTempPath()) ("zargs-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $script:Install -Force | Out-Null + Copy-Item (Join-Path $script:RepoRoot "*.ps1") $script:Install -Force + + # Fake roots that do not exist and ports nothing listens on, so any script + # that somehow got past its guard would still find nothing to act on. + $fixture = @{ + ec2 = @{ ip = "203.0.113.10"; user = "testuser"; pemKey = "C:\fixtures\test.pem"; stackRoot = "/home/testuser/stack" } + paths = @{ + temp = (Join-Path $script:Install "temp") + backupsLocal = (Join-Path $script:Install "backups") + backupsEc2 = (Join-Path $script:Install "backups-ec2") + scriptsRoot = $script:Install + oneDriveBackups = "" + } + projects = [ordered]@{ + _note = "comment key - must never be treated as a project" + pyapp = @{ + label = "Fixture Python App"; kind = "python" + localRoot = (Join-Path $script:Install "nonexistent-pyapp") + startModule = "pyapp.main" + ports = @{ dev = 59990 } + remote = @{ path = "/home/testuser/stack/pyapp" } + } + viteapp = @{ + label = "Fixture Vite Site"; kind = "vite" + localRoot = (Join-Path $script:Install "nonexistent-viteapp") + ports = @{ dev = 59991 } + remote = @{ path = "/home/testuser/stack/viteapp" } + } + edgeproxy = @{ + label = "Fixture Edge"; kind = "edge" + localRoot = (Join-Path $script:Install "nonexistent-edge") + remote = @{ path = "/home/testuser/stack/edge" } + } + } + } + $fixture | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $script:Install "zconfig.json") -Encoding UTF8 + + # Run a script in a child process; capture merged output and exit code. + function Invoke-ZScript { + param([string]$Script, [string[]]$ScriptArgs = @()) + $path = Join-Path $script:Install $Script + $out = & powershell -NoProfile -ExecutionPolicy Bypass -File $path @ScriptArgs 2>&1 | ForEach-Object { "$_" } + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = ($out -join "`n") + } + } +} + +AfterAll { + Remove-Item -LiteralPath $script:Install -Recurse -Force -ErrorAction SilentlyContinue +} + +# Every target-taking script must refuse to guess. Running bare used to mean +# "do it to everything" for some of these, which is how an unintended full +# backup / deploy happens. +Describe "no arguments shows usage and exits non-zero" { + It "<_> refuses to run bare" -ForEach @( + "zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1", "zbackup.ps1", + "zbackup_ec2.ps1", "zec2.ps1", "zec2online.ps1", "zrepair.ps1", + "zstop.ps1", "zbackup_and_sync.ps1" + ) { + $r = Invoke-ZScript -Script $_ + $r.ExitCode | Should -Not -Be 0 -Because "$_ run bare must not silently act on every project" + $r.Output | Should -Match "(?i)usage" + } + + It "<_> lists the available project keys in its usage" -ForEach @( + "zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1", "zbackup.ps1" + ) { + $r = Invoke-ZScript -Script $_ + $r.Output | Should -Match "pyapp" + $r.Output | Should -Match "viteapp" + } + + It "usage never advertises the underscore comment key" -ForEach @( + "zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1", "zbackup.ps1" + ) { + (Invoke-ZScript -Script $_).Output | Should -Not -Match "_note" + } +} + +# An unknown key must fail loudly. Silently selecting nothing and exiting 0 is +# the dangerous outcome: a typo'd key in a scheduled task would look like a +# successful run that backed up nothing. +Describe "unknown project key fails loudly" { + It "<_> rejects a key that is not in the config" -ForEach @( + "zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1", "zbackup.ps1", + "zbackup_ec2.ps1", "zec2.ps1", "zec2online.ps1", "zrepair.ps1", "zstop.ps1" + ) { + $r = Invoke-ZScript -Script $_ -ScriptArgs @("definitelynotaproject") + $r.ExitCode | Should -Not -Be 0 -Because "$_ must not treat an unknown key as success" + $r.Output | Should -Match "(?i)unknown project key|not.*found|available" + } + + It "<_> names the offending key and the valid ones" -ForEach @( + "zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1" + ) { + $r = Invoke-ZScript -Script $_ -ScriptArgs @("definitelynotaproject") + $r.Output | Should -Match "definitelynotaproject" + $r.Output | Should -Match "pyapp" + } + + It "the underscore comment key is not addressable as a project" { + $r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("_note") + $r.ExitCode | Should -Not -Be 0 + } +} + +# zdeploy -myapp == zdeploy myapp. The dash is tolerated everywhere for anyone +# with switch-style muscle memory; it must be stripped before the lookup, not +# passed through into the key. +Describe "leading-dash tolerance" { + It "<_> strips a leading dash before resolving the key" -ForEach @( + "zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1" + ) { + # A dashed *unknown* key proves the strip happened: the error must name + # 'definitelynotaproject', not '-definitelynotaproject'. Using an unknown + # key keeps this safe - it exits before doing any work. + $r = Invoke-ZScript -Script $_ -ScriptArgs @("-definitelynotaproject") + $r.Output | Should -Match "'definitelynotaproject'" + } +} + +# zkill is the one script safe to run for real here: the fixture's dev ports are +# deliberately unused, so it finds no listeners and kills nothing. +Describe "zkill target resolution (safe: fixture ports are unused)" { + It "accepts a bare project key" { + $r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("pyapp") + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match "Fixture Python App" + $r.Output | Should -Match "59990" + } + + It "accepts the same key with a leading dash and behaves identically" { + $bare = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("pyapp") + $dashed = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("-pyapp") + $dashed.ExitCode | Should -Be $bare.ExitCode + $dashed.Output | Should -Match "Fixture Python App" + } + + It "accepts several keys at once" { + $r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("pyapp", "viteapp") + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match "Fixture Python App" + $r.Output | Should -Match "Fixture Vite Site" + } + + It "'all' expands to every project that has a dev port" { + $r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("all") + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match "Fixture Python App" + $r.Output | Should -Match "Fixture Vite Site" + } + + It "'all' skips projects with no dev port (edge/docker stacks)" { + # edgeproxy has no ports.dev - it has no local dev server to stop. + $r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("all") + $r.Output | Should -Not -Match "Fixture Edge" + } + + It "'-all' works the same as 'all'" { + $r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("-all") + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match "Fixture Python App" + } + + It "reports the port it inspected rather than failing on an idle port" { + $r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("viteapp") + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match "59991" + } + + It "-Port overrides the configured dev port" { + $r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("pyapp", "-Port", "59999") + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match "59999" + $r.Output | Should -Not -Match "59990" + } +}