From d4980b3086ffbe5452f7b9b0b291be5d4e29d146 Mon Sep 17 00:00:00 2001 From: kellymichels Date: Sun, 26 Jul 2026 12:20:32 -0500 Subject: [PATCH] test: add Pester suite for ZHelpers pure logic + ZCONFIG seam (phases 1-2) (#27) Part of #26. The toolkit had no automated tests at all - including for the functions that decide what goes into a deploy zip, which is where a dev .env reached production (#23). Phase 1 - the seam. Get-ZConfig read a hardcoded $PSScriptRoot\zconfig.json, so nothing config-dependent could be tested without touching the real config. Adds Get-ZConfigPath honoring $env:ZCONFIG (the bash port has always had this, so it also closes a parity gap) and Reset-ZConfigCache to drop the memoised config between fixtures. Deliberately did NOT convert the exit 1 paths to throw: that changes observed CLI output, and the pure functions don't need it. Phase 2 - 61 tests over the functions with no side effects: Get-ArchiveExcludes (common/python/vite/nextjs lists, deploy.exclude merging, dedupe, array shape), Get-ZConfig / Get-ZConfigPath / Get-ZProjectKeys / Get-ZProject (dash tolerance, underscore-key filtering, memoisation), Get-ZEdgeProject, Get-RemoteComposeDir, Get-Ec2Target / Get-Ec2Home, Get-LabelFromBuildJsonObj, Read-JsonBuildVersion. The suite is verified by mutation testing rather than assumed useful - six deliberate regressions were each introduced and confirmed to turn it red, including reintroducing the exact #23 bug and its inverse (backups silently dropping .env/uploads, which would produce restore points that cannot restore). Runs off a fixture config injected via ZCONFIG, so it never reads a real zconfig.json and passes on a machine that has never been configured. --- CHANGELOG.md | 9 + README.md | 14 ++ ZHelpers.ps1 | 17 +- tests/ZHelpers.Tests.ps1 | 265 ++++++++++++++++++++++++++++ tests/fixtures/zconfig.fixture.json | 89 ++++++++++ 5 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 tests/ZHelpers.Tests.ps1 create mode 100644 tests/fixtures/zconfig.fixture.json diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa308f..cb755d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,15 @@ Notable changes to the Evomedia.net Token Savers. credentials and RSA signing keys. ### Added +- **Test suite (Pester)** — the toolkit now has automated coverage of its own + pure logic: `Get-ArchiveExcludes` (including the deploy-vs-backup rule that + keeps `.env`/`uploads` out of deploys but *in* backups), config and project + lookups, `remote.composeDir` fallback, EC2 target composition, and build-label + formatting. Run with `Invoke-Pester .\tests` (Pester 5+). Verified by mutation + testing — reintroducing each historical bug turns the suite red. +- **`ZCONFIG` environment variable (PowerShell)** — overrides the path to + `zconfig.json`, matching the bash port, which has always honored it. Closes a + parity gap and gives the test suite a seam for injecting a fixture config. - **`zec2_rotatekeys` — safely rotate/reset server-side secrets** — a new tool for when a secret leaks or a deploy overwrites a production `.env` with dev values. `-Rotate KEY` regenerates a key **on the server** diff --git a/README.md b/README.md index aa33ea2..d0f6220 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ notepad zconfig.json The example config ships with sample projects named by their kind — `pyapp`, `viteapp`, `nextapp`, `edge`, `analytics`. **Rename the keys to your own project names**; the key is what you type as the command argument. Add as many projects as you like — no script edits ever needed. +Set the `ZCONFIG` environment variable to point at a config somewhere else (both the PowerShell scripts and the bash port honor it) — handy for a second machine profile, or for running against a scratch config without touching your real one. + ### Config reference ```jsonc @@ -382,6 +384,18 @@ Give the project a `verify` block instead, and `zdeploy` checks the app **from t 2. That's it: `zstart`, `zkill`, `zrestart`, `zbackup`, `zdeploy`, `zec2`, `zec2online`, `zrepair`, `zstop` all accept the new key immediately. 3. A project whose deploy doesn't fit the python/vite/nextjs/edge/docker patterns needs its own `Invoke-Deploy` function in `zdeploy.ps1` — copy an existing handler; they're all variations on zip → upload → compose up → verify. +## Tests + +The toolkit has its own [Pester](https://pester.dev) suite covering the pure logic — the exclude lists, config lookups, and version-label formatting that the deploy and backup paths depend on: + +```powershell +Invoke-Pester .\tests +``` + +Needs Pester 5+ (`Install-Module Pester -Scope CurrentUser`); Windows ships 3.x, which won't run these. The suite injects a fixture config through `ZCONFIG`, so it never reads your real `zconfig.json` and runs fine on a machine that has never been configured. + +The high-value case is the deploy-vs-backup split: **deploys must exclude `.env` files and `uploads/`, backups must keep them.** Get that backwards in either direction and you either ship secrets to production or quietly write backups that can't restore — neither fails loudly at runtime. + ## Troubleshooting - **"zconfig.json not found"** — you haven't copied `zconfig.example.json` yet. Every script tells you this and exits. diff --git a/ZHelpers.ps1 b/ZHelpers.ps1 index 52ae1e1..d83b566 100644 --- a/ZHelpers.ps1 +++ b/ZHelpers.ps1 @@ -33,9 +33,24 @@ $script:JunkDirNames = @( $script:ZConfigCache = $null +# Where zconfig.json lives. Defaults to next to the scripts; override with the +# ZCONFIG environment variable, matching the bash port (zhelpers.sh does the +# same). Useful for pointing a run at an alternate config, and it is the seam +# the test suite uses to inject a fixture. +function Get-ZConfigPath { + if ($env:ZCONFIG) { return $env:ZCONFIG } + return (Join-Path $PSScriptRoot "zconfig.json") +} + +# Drop the memoised config so the next Get-ZConfig re-reads from disk. Only +# needed when the config changes mid-process (tests switching fixtures). +function Reset-ZConfigCache { + $script:ZConfigCache = $null +} + function Get-ZConfig { if ($null -ne $script:ZConfigCache) { return $script:ZConfigCache } - $configPath = Join-Path $PSScriptRoot "zconfig.json" + $configPath = Get-ZConfigPath if (-not (Test-Path -LiteralPath $configPath)) { Write-Host "ERROR: zconfig.json not found at $configPath" -ForegroundColor Red Write-Host " Copy zconfig.example.json to zconfig.json and fill in your values." -ForegroundColor DarkGray diff --git a/tests/ZHelpers.Tests.ps1 b/tests/ZHelpers.Tests.ps1 new file mode 100644 index 0000000..9cb55fb --- /dev/null +++ b/tests/ZHelpers.Tests.ps1 @@ -0,0 +1,265 @@ +# 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. + +# ZHelpers.Tests.ps1 — Pester 5 suite for the pure/config functions in ZHelpers.ps1. +# +# Invoke-Pester .\tests +# +# Scope: functions with no side effects beyond reading a config file. Anything +# that SSHes, builds images, or kills processes is deliberately not covered here +# (see Invoke-Ec2Step, Stop-ProcessTree and friends) - those need a disposable +# server, not a unit test. +# +# The fixture config is injected via $env:ZCONFIG, the same override the bash +# port has always had. No real zconfig.json is read, so this suite is safe to +# run on a machine that has never been configured. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + $script:Fixture = Join-Path $PSScriptRoot "fixtures\zconfig.fixture.json" + $script:HelpersPsm = Join-Path $script:RepoRoot "ZHelpers.ps1" + + $env:ZCONFIG = $script:Fixture + . $script:HelpersPsm + Reset-ZConfigCache +} + +AfterAll { + Remove-Item Env:\ZCONFIG -ErrorAction SilentlyContinue +} + +Describe "Get-ZConfigPath" { + It "uses the ZCONFIG override when set" { + $env:ZCONFIG = "C:\somewhere\else.json" + Get-ZConfigPath | Should -Be "C:\somewhere\else.json" + $env:ZCONFIG = $script:Fixture + } + + It "falls back to zconfig.json beside the scripts when ZCONFIG is unset" { + Remove-Item Env:\ZCONFIG -ErrorAction SilentlyContinue + Get-ZConfigPath | Should -Be (Join-Path $script:RepoRoot "zconfig.json") + $env:ZCONFIG = $script:Fixture + } +} + +Describe "Get-ZConfig" { + BeforeEach { Reset-ZConfigCache } + + It "loads the fixture pointed at by ZCONFIG" { + (Get-ZConfig).ec2.ip | Should -Be "203.0.113.10" + } + + It "memoises - a second call returns the same object instance" { + $a = Get-ZConfig + $b = Get-ZConfig + [object]::ReferenceEquals($a, $b) | Should -BeTrue + } + + It "re-reads after Reset-ZConfigCache" { + $a = Get-ZConfig + Reset-ZConfigCache + $b = Get-ZConfig + [object]::ReferenceEquals($a, $b) | Should -BeFalse + $b.ec2.user | Should -Be "testuser" # still correct content + } +} + +Describe "Get-ZProjectKeys" { + It "returns every project key in config order" { + Get-ZProjectKeys | Should -Be @("pyapp", "viteapp", "nextapp", "edgeproxy", "dockeronly", "noports") + } + + It "omits underscore-prefixed comment keys" { + Get-ZProjectKeys | Should -Not -Contain "_note" + } +} + +Describe "Get-ZProject" { + It "returns the project for a known key" { + (Get-ZProject -Key "pyapp").label | Should -Be "Fixture Python App" + } + + It "tolerates a leading dash (-pyapp resolves the same as pyapp)" { + # Muscle memory from per-project switches; every z-script accepts both. + (Get-ZProject -Key "-pyapp").label | Should -Be "Fixture Python App" + } + + It "is case-insensitive on the key, as PowerShell property access is" { + (Get-ZProject -Key "PyApp").label | Should -Be "Fixture Python App" + } +} + +Describe "Get-ZEdgeProject" { + It "finds the first edge-kind project and reports its key" { + $edge = Get-ZEdgeProject + $edge.Key | Should -Be "edgeproxy" + $edge.Config.proxyContainer | Should -Be "edge_proxy" + } +} + +Describe "Get-RemoteComposeDir" { + It "prefers remote.composeDir when the project sets one" { + Get-RemoteComposeDir -Key "pyapp" | Should -Be "/home/testuser/stack/pyapp/docker" + } + + It "falls back to remote.path when composeDir is absent" { + Get-RemoteComposeDir -Key "viteapp" | Should -Be "/home/testuser/stack/viteapp" + } +} + +Describe "EC2 target helpers" { + It "composes user@ip" { + Get-Ec2Target | Should -Be "testuser@203.0.113.10" + } + + It "composes the remote home directory from the ssh user" { + Get-Ec2Home | Should -Be "/home/testuser" + } +} + +Describe "Get-LabelFromBuildJsonObj" { + It "formats the label as v{productVersion}.{buildNumber}" { + Get-LabelFromBuildJsonObj ([pscustomobject]@{ productVersion = "1.0"; buildNumber = 42 }) | + Should -Be "v1.0.42" + } + + It "returns null for a null object rather than a bare 'v.'" { + Get-LabelFromBuildJsonObj $null | Should -BeNullOrEmpty + } + + It "coerces a string buildNumber to int (JSON types vary)" { + Get-LabelFromBuildJsonObj ([pscustomobject]@{ productVersion = "2.1"; buildNumber = "7" }) | + Should -Be "v2.1.7" + } +} + +Describe "Read-JsonBuildVersion" { + BeforeAll { + $script:TmpDir = Join-Path ([IO.Path]::GetTempPath()) ("zhelptest-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $script:TmpDir -Force | Out-Null + } + AfterAll { + Remove-Item -LiteralPath $script:TmpDir -Recurse -Force -ErrorAction SilentlyContinue + } + + It "parses a valid build-version.json" { + $p = Join-Path $script:TmpDir "good.json" + '{ "productVersion": "3.4", "buildNumber": 11 }' | Set-Content -LiteralPath $p -Encoding UTF8 + (Read-JsonBuildVersion $p).buildNumber | Should -Be 11 + } + + It "returns null for a missing file" { + Read-JsonBuildVersion (Join-Path $script:TmpDir "nope.json") | Should -BeNullOrEmpty + } + + It "returns null for malformed JSON instead of throwing" { + $p = Join-Path $script:TmpDir "bad.json" + '{ not json at all' | Set-Content -LiteralPath $p -Encoding UTF8 + Read-JsonBuildVersion $p | Should -BeNullOrEmpty + } +} + +Describe "Get-ArchiveExcludes" { + BeforeAll { + $script:Py = Get-ZProject -Key "pyapp" + $script:Vite = Get-ZProject -Key "viteapp" + $script:Next = Get-ZProject -Key "nextapp" + $script:Dock = Get-ZProject -Key "dockeronly" + } + + Context "common excludes (every kind)" { + It "always excludes <_>" -ForEach @(".git", ".idea", ".vscode", ".claude", "tmp", "nul", ".DS_Store", "backups") { + Get-ArchiveExcludes -Project $script:Py | Should -Contain $_ + } + + It "applies the common list even to an unrecognised kind" { + Get-ArchiveExcludes -Project $script:Dock | Should -Contain ".git" + } + } + + Context "python kind" { + It "excludes build/venv cruft: <_>" -ForEach @(".venv", "venv", "__pycache__", ".pytest_cache", ".nicegui", "archive", "dist", "build", "htmlcov") { + Get-ArchiveExcludes -Project $script:Py | Should -Contain $_ + } + } + + Context "vite kind" { + It "excludes node_modules and dist" { + $x = Get-ArchiveExcludes -Project $script:Vite + $x | Should -Contain "node_modules" + $x | Should -Contain "dist" + } + } + + Context "nextjs kind" { + It "excludes .next, .vercel and build output" { + $x = Get-ArchiveExcludes -Project $script:Next + $x | Should -Contain ".next" + $x | Should -Contain ".vercel" + $x | Should -Contain "next-env.d.ts" + } + + It "excludes .env in deploys AND backups (nextjs has no ForBackup gate)" { + Get-ArchiveExcludes -Project $script:Next -ForBackup | Should -Contain ".env" + } + } + + # The regression that motivated this suite: a dev .env shipped to production + # in a deploy zip. Deploys must drop secrets and uploads; backups must keep + # them, or the backup is not a full restore point. + Context "deploy-vs-backup gating (python)" { + It "deploy excludes secret <_>" -ForEach @(".env", ".env.local", ".env.production") { + Get-ArchiveExcludes -Project $script:Py | Should -Contain $_ + } + + It "backup KEEPS secret <_>" -ForEach @(".env", ".env.local", ".env.production") { + Get-ArchiveExcludes -Project $script:Py -ForBackup | Should -Not -Contain $_ + } + + It "deploy excludes user uploads" { + Get-ArchiveExcludes -Project $script:Py | Should -Contain "uploads" + } + + It "backup KEEPS user uploads" { + Get-ArchiveExcludes -Project $script:Py -ForBackup | Should -Not -Contain "uploads" + } + } + + Context "deploy-vs-backup gating (vite)" { + It "deploy excludes secret <_>" -ForEach @(".env", ".env.local", ".env.production") { + Get-ArchiveExcludes -Project $script:Vite | Should -Contain $_ + } + + It "backup KEEPS secret <_>" -ForEach @(".env", ".env.local", ".env.production") { + Get-ArchiveExcludes -Project $script:Vite -ForBackup | Should -Not -Contain $_ + } + + It "still excludes node_modules in a backup (bulk, not a secret)" { + Get-ArchiveExcludes -Project $script:Vite -ForBackup | Should -Contain "node_modules" + } + } + + Context "per-project deploy.exclude" { + It "merges the project's own exclude list" { + $x = Get-ArchiveExcludes -Project $script:Py + $x | Should -Contain "docs" + $x | Should -Contain "fixtures-extra" + } + + It "omits nothing when the project has no deploy block" { + { Get-ArchiveExcludes -Project $script:Vite } | Should -Not -Throw + } + } + + Context "result shape" { + It "contains no duplicates" { + $x = @(Get-ArchiveExcludes -Project $script:Py) + ($x | Select-Object -Unique).Count | Should -Be $x.Count + } + + It "returns an array even for a kind with no specific excludes" { + , (Get-ArchiveExcludes -Project $script:Dock) | Should -BeOfType [System.Array] + } + } +} diff --git a/tests/fixtures/zconfig.fixture.json b/tests/fixtures/zconfig.fixture.json new file mode 100644 index 0000000..e22a717 --- /dev/null +++ b/tests/fixtures/zconfig.fixture.json @@ -0,0 +1,89 @@ +{ + "_comment": "Fixture config for the Pester suite. Not a real config - values are deliberately fake and are asserted against in ZHelpers.Tests.ps1. Keys starting with _ must be ignored by Get-ZProjectKeys.", + + "ec2": { + "ip": "203.0.113.10", + "user": "testuser", + "pemKey": "C:\\fixtures\\test.pem", + "stackRoot": "/home/testuser/stack" + }, + + "paths": { + "temp": "C:\\fixtures\\temp", + "backupsLocal": "C:\\fixtures\\backups\\projects", + "backupsEc2": "C:\\fixtures\\backups\\ec2", + "scriptsRoot": "C:\\fixtures\\zscripts", + "oneDriveBackups": "" + }, + + "projects": { + "_note": "This underscore key must never appear as a project.", + + "pyapp": { + "label": "Fixture Python App", + "kind": "python", + "localRoot": "C:\\fixtures\\pyapp", + "startModule": "pyapp.main", + "ports": { "dev": 8080 }, + "domain": "pyapp.example.com", + "remote": { + "path": "/home/testuser/stack/pyapp", + "composeDir": "/home/testuser/stack/pyapp/docker", + "appService": "app" + }, + "deploy": { + "zipName": "PyAppDeploy.zip", + "exclude": ["docs", "fixtures-extra"] + } + }, + + "viteapp": { + "label": "Fixture Vite Site", + "kind": "vite", + "localRoot": "C:\\fixtures\\viteapp", + "ports": { "dev": 5173 }, + "domain": "www.example.com", + "remote": { + "path": "/home/testuser/stack/viteapp" + } + }, + + "nextapp": { + "label": "Fixture Next App", + "kind": "nextjs", + "localRoot": "C:\\fixtures\\nextapp", + "ports": { "dev": 4173, "prod": 3000 }, + "remote": { + "path": "/home/testuser/stack/nextapp" + } + }, + + "edgeproxy": { + "label": "Fixture Edge", + "kind": "edge", + "localRoot": "C:\\fixtures\\edge", + "proxyContainer": "edge_proxy", + "remote": { + "path": "/home/testuser/stack/edge" + } + }, + + "dockeronly": { + "label": "Fixture Docker Stack", + "kind": "docker", + "localRoot": "C:\\fixtures\\dockeronly", + "remote": { + "path": "/home/testuser/stack/dockeronly" + } + }, + + "noports": { + "label": "Fixture No Dev Port", + "kind": "python", + "localRoot": "C:\\fixtures\\noports", + "remote": { + "path": "/home/testuser/stack/noports" + } + } + } +}