mirror of
https://github.com/kellymichels/zscripts-token-savers
synced 2026-10-07 07:18:18 +00:00
test: cover New-ProjectArchive by inspecting the zip it actually builds (phase 3)
Part of #26. Phase 2 asserted that Get-ArchiveExcludes returns the right list; this asserts the archive that actually ships. A name can be on the exclude list and still land in the zip - only opening the zip proves otherwise. Each test builds a real temp source tree, archives it, and reads back the entries. 66 tests across: TopLevelExclude (files, directories, multiple names, absent names), recursive junk-directory pruning at top level and any depth, junk extensions, nested archives, OS junk filenames, script-file inclusion via -IncludeScriptFiles, ExtraFiles (how zbackup bundles a pg_dump), zip mechanics (overwrite, destination creation, forward-slash entry paths), and two end-to-end shapes - a python deploy zip built from the real exclude list, and a backup of the same tree that must retain .env and uploads. Two behaviours are pinned deliberately: - TopLevelExclude matches TOP-LEVEL names only, so a nested backend/.env is NOT excluded by listing '.env'. That is current behaviour and the reason deploys rely on server-side preservation; the test exists so changing it is a decision rather than an accident. - An all-junk tree throws instead of producing an empty zip - a silent empty deploy would unpack to nothing on the server. Verified by mutation testing, which paid for itself immediately: the first run showed the junk-directory tests were vacuous. Inside a Where-Object, $_ rebinds to the pipeline item and shadowed Pester's -ForEach value, so the pattern matched nothing and the tests passed while asserting nothing. Fixed by capturing the value first; re-run now catches all 8 mutations (disabling TopLevelExclude, top-level and nested junk pruning, each file filter, and the empty-archive throw). ZHelpers.ps1 restored byte-for-byte afterwards.
This commit is contained in:
parent
4d086bafae
commit
34bbd75fd7
300
tests/NewProjectArchive.Tests.ps1
Normal file
300
tests/NewProjectArchive.Tests.ps1
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
# NewProjectArchive.Tests.ps1 - Pester 5 suite for the archive builder.
|
||||||
|
#
|
||||||
|
# Invoke-Pester .\tests
|
||||||
|
#
|
||||||
|
# Where the ZHelpers suite checks that Get-ArchiveExcludes returns the right
|
||||||
|
# *list*, this one checks the zip that actually gets built and shipped. A name
|
||||||
|
# can be on the exclude list and still end up in the archive; only opening the
|
||||||
|
# zip proves otherwise.
|
||||||
|
#
|
||||||
|
# Every test builds a real temp source tree, zips it, and inspects the entries.
|
||||||
|
# No config is read, so this file is independent of ZCONFIG and zconfig.json.
|
||||||
|
|
||||||
|
BeforeAll {
|
||||||
|
. (Join-Path (Split-Path -Parent $PSScriptRoot) "ZHelpers.ps1")
|
||||||
|
|
||||||
|
# Build a source tree from a list of relative paths. A path ending in '/'
|
||||||
|
# becomes an (empty) directory; anything else becomes a file with content.
|
||||||
|
function New-TestTree {
|
||||||
|
param([string[]]$Paths)
|
||||||
|
$root = Join-Path ([IO.Path]::GetTempPath()) ("zarc-" + [guid]::NewGuid().ToString("N"))
|
||||||
|
New-Item -ItemType Directory -Path $root -Force | Out-Null
|
||||||
|
foreach ($p in $Paths) {
|
||||||
|
$full = Join-Path $root ($p -replace '/', '\')
|
||||||
|
if ($p.EndsWith('/')) {
|
||||||
|
New-Item -ItemType Directory -Path $full -Force | Out-Null
|
||||||
|
} else {
|
||||||
|
$dir = Split-Path -Parent $full
|
||||||
|
if ($dir -and -not (Test-Path -LiteralPath $dir)) {
|
||||||
|
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||||
|
}
|
||||||
|
"content of $p" | Set-Content -LiteralPath $full -Encoding UTF8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $root
|
||||||
|
}
|
||||||
|
|
||||||
|
# Relative entry paths inside a zip, forward-slashed as stored.
|
||||||
|
function Get-ZipEntries {
|
||||||
|
param([string]$ZipPath)
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
|
||||||
|
$zip = [IO.Compression.ZipFile]::OpenRead($ZipPath)
|
||||||
|
try { return @($zip.Entries | ForEach-Object { $_.FullName }) }
|
||||||
|
finally { $zip.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build a tree, archive it, return the entry list. Cleans up after itself.
|
||||||
|
function Get-ArchivedEntries {
|
||||||
|
param([string[]]$Paths, [string[]]$TopLevelExclude = @(), [switch]$IncludeScriptFiles, [string[]]$ExtraFiles = @())
|
||||||
|
$root = New-TestTree -Paths $Paths
|
||||||
|
$zip = Join-Path ([IO.Path]::GetTempPath()) ("zarc-" + [guid]::NewGuid().ToString("N") + ".zip")
|
||||||
|
try {
|
||||||
|
$splat = @{ SourcePath = $root; DestinationZip = $zip; TopLevelExclude = $TopLevelExclude; ExtraFiles = $ExtraFiles }
|
||||||
|
if ($IncludeScriptFiles) { $splat.IncludeScriptFiles = $true }
|
||||||
|
New-ProjectArchive @splat 6>$null
|
||||||
|
return Get-ZipEntries -ZipPath $zip
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe "New-ProjectArchive - basic packaging" {
|
||||||
|
It "includes ordinary project files" {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "readme.md")
|
||||||
|
$e | Should -Contain "app.py"
|
||||||
|
$e | Should -Contain "readme.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "preserves nested structure with forward slashes (zip convention)" {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("src/lib/util.py")
|
||||||
|
$e | Should -Contain "src/lib/util.py"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "throws when the source directory does not exist" {
|
||||||
|
{ New-ProjectArchive -SourcePath (Join-Path ([IO.Path]::GetTempPath()) "definitely-not-here-$([guid]::NewGuid())") `
|
||||||
|
-DestinationZip (Join-Path ([IO.Path]::GetTempPath()) "x.zip") } | Should -Throw
|
||||||
|
}
|
||||||
|
|
||||||
|
It "throws rather than shipping an empty archive when everything is filtered out" {
|
||||||
|
# A tree of nothing but junk must fail loudly - a silently empty deploy
|
||||||
|
# zip would unpack to nothing on the server.
|
||||||
|
{ Get-ArchivedEntries -Paths @("debug.log", "notes.bak") } | Should -Throw
|
||||||
|
}
|
||||||
|
|
||||||
|
It "overwrites an existing zip at the destination" {
|
||||||
|
$root = New-TestTree -Paths @("app.py")
|
||||||
|
$zip = Join-Path ([IO.Path]::GetTempPath()) ("zarc-" + [guid]::NewGuid().ToString("N") + ".zip")
|
||||||
|
try {
|
||||||
|
"stale placeholder" | Set-Content -LiteralPath $zip -Encoding UTF8
|
||||||
|
New-ProjectArchive -SourcePath $root -DestinationZip $zip 6>$null
|
||||||
|
(Get-ZipEntries -ZipPath $zip) | Should -Contain "app.py"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
It "creates the destination directory when it does not exist" {
|
||||||
|
$root = New-TestTree -Paths @("app.py")
|
||||||
|
$sub = Join-Path ([IO.Path]::GetTempPath()) ("zarc-out-" + [guid]::NewGuid().ToString("N"))
|
||||||
|
$zip = Join-Path $sub "nested\deploy.zip"
|
||||||
|
try {
|
||||||
|
New-ProjectArchive -SourcePath $root -DestinationZip $zip 6>$null
|
||||||
|
Test-Path -LiteralPath $zip | Should -BeTrue
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $sub -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe "New-ProjectArchive - TopLevelExclude" {
|
||||||
|
It "drops an excluded top-level file" {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", ".env") -TopLevelExclude @(".env")
|
||||||
|
$e | Should -Contain "app.py"
|
||||||
|
$e | Should -Not -Contain ".env"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "drops an excluded top-level directory and everything under it" {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "uploads/photo.png", "uploads/deep/scan.png") -TopLevelExclude @("uploads")
|
||||||
|
$e | Should -Contain "app.py"
|
||||||
|
@($e | Where-Object { $_ -like "uploads/*" }).Count | Should -Be 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# This is the documented (and surprising) semantic: the exclude list matches
|
||||||
|
# TOP-LEVEL names only. A nested backend/.env is NOT caught by excluding
|
||||||
|
# ".env" - the server-side copy is what protects it on deploy (deploy.preserve).
|
||||||
|
# Pinning it here so a future change to that behaviour is a deliberate choice
|
||||||
|
# rather than an accident.
|
||||||
|
It "does NOT exclude a nested file that merely shares an excluded name" {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "backend/.env") -TopLevelExclude @(".env")
|
||||||
|
$e | Should -Contain "backend/.env"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "excludes several names at once" {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", ".env", "docs/guide.md", "archive/old.txt") `
|
||||||
|
-TopLevelExclude @(".env", "docs", "archive")
|
||||||
|
$e | Should -Be @("app.py")
|
||||||
|
}
|
||||||
|
|
||||||
|
It "is unaffected by an exclude naming something that isn't there" {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py") -TopLevelExclude @("nonexistent", "also-missing")
|
||||||
|
$e | Should -Contain "app.py"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe "New-ProjectArchive - recursive junk pruning" {
|
||||||
|
# NOTE: capture the -ForEach value into a named variable before any
|
||||||
|
# Where-Object. Inside a pipeline scriptblock $_ rebinds to the pipeline
|
||||||
|
# item, shadowing Pester's value - an assertion written as
|
||||||
|
# `$_ -like "*/$_/*"` silently matches nothing and the test passes while
|
||||||
|
# checking nothing at all.
|
||||||
|
It "prunes nested junk directory '<_>' (TopLevelExclude cannot reach it)" -ForEach @(".git", "node_modules", "__pycache__", ".pytest_cache", ".mypy_cache", ".tox", ".next", ".turbo", "coverage", "htmlcov", ".idea", ".vscode") {
|
||||||
|
$junkDir = $_
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "src/$junkDir/junk.txt")
|
||||||
|
$e | Should -Not -Contain "src/$junkDir/junk.txt"
|
||||||
|
@($e | Where-Object { $_ -like "*/$junkDir/*" }).Count | Should -Be 0
|
||||||
|
$e | Should -Contain "app.py"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "prunes top-level junk directory '<_>' even with no TopLevelExclude" -ForEach @(".git", "node_modules", "__pycache__", ".idea", ".vscode", "coverage") {
|
||||||
|
$junkDir = $_
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "$junkDir/junk.txt")
|
||||||
|
$e | Should -Not -Contain "$junkDir/junk.txt"
|
||||||
|
$e | Should -Contain "app.py"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "prunes a junk directory nested several levels deep" {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "a/b/c/node_modules/pkg/index.js")
|
||||||
|
@($e | Where-Object { $_ -like "*node_modules*" }).Count | Should -Be 0
|
||||||
|
$e | Should -Contain "app.py"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "keeps a file whose name merely contains a junk directory name" {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "src/node_modules_notes.md")
|
||||||
|
$e | Should -Contain "src/node_modules_notes.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "keeps a directory whose name merely starts with a junk directory name" {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "coverage-reports/summary.md")
|
||||||
|
$e | Should -Contain "coverage-reports/summary.md"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe "New-ProjectArchive - file filters" {
|
||||||
|
It "skips junk extension '<_>'" -ForEach @(".log", ".tmp", ".bak", ".old", ".orig", ".rej", ".swp", ".pyc", ".pyo", ".tsbuildinfo", ".db", ".sqlite", ".sqlite3") {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "artifact$_")
|
||||||
|
$e | Should -Not -Contain "artifact$_"
|
||||||
|
$e | Should -Contain "app.py"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "skips nested archive '<_>' (no zips inside zips)" -ForEach @(".zip", ".tar", ".gz", ".7z", ".rar", ".iso", ".jar") {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "bundle$_")
|
||||||
|
$e | Should -Not -Contain "bundle$_"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "skips OS junk file '<_>'" -ForEach @(".DS_Store", "Thumbs.db", "desktop.ini") {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "src/$_")
|
||||||
|
$e | Should -Not -Contain "src/$_"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "keeps a source file whose extension is not filtered" {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "styles.css", "index.html", "data.json")
|
||||||
|
foreach ($f in @("app.py", "styles.css", "index.html", "data.json")) { $e | Should -Contain $f }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe "New-ProjectArchive - IncludeScriptFiles" {
|
||||||
|
It "skips <_> by default (local scripts are not part of a deploy)" -ForEach @(".ps1", ".cmd", ".bat") {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "helper$_")
|
||||||
|
$e | Should -Not -Contain "helper$_"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "includes <_> when -IncludeScriptFiles is set (backing up the scripts folder itself)" -ForEach @(".ps1", ".cmd", ".bat") {
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py", "helper$_") -IncludeScriptFiles
|
||||||
|
$e | Should -Contain "helper$_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe "New-ProjectArchive - ExtraFiles" {
|
||||||
|
It "adds a file from outside the source tree at the archive root" {
|
||||||
|
$extraDir = Join-Path ([IO.Path]::GetTempPath()) ("zarc-extra-" + [guid]::NewGuid().ToString("N"))
|
||||||
|
New-Item -ItemType Directory -Path $extraDir -Force | Out-Null
|
||||||
|
$extra = Join-Path $extraDir "dump.sql"
|
||||||
|
"-- pg_dump output" | Set-Content -LiteralPath $extra -Encoding UTF8
|
||||||
|
try {
|
||||||
|
# This is how zbackup bundles a Postgres dump alongside the source.
|
||||||
|
$e = Get-ArchivedEntries -Paths @("app.py") -ExtraFiles @($extra)
|
||||||
|
$e | Should -Contain "dump.sql"
|
||||||
|
$e | Should -Contain "app.py"
|
||||||
|
}
|
||||||
|
finally { Remove-Item -LiteralPath $extraDir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||||
|
}
|
||||||
|
|
||||||
|
It "ignores an ExtraFiles entry that does not exist" {
|
||||||
|
{ Get-ArchivedEntries -Paths @("app.py") -ExtraFiles @("C:\definitely\not\here.sql") } | Should -Not -Throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe "New-ProjectArchive - realistic deploy shape" {
|
||||||
|
It "produces a clean python deploy zip: source in, secrets and cruft out" {
|
||||||
|
# The end-to-end shape of what zdeploy ships for a python project,
|
||||||
|
# using the real exclude list rather than a hand-written one.
|
||||||
|
$proj = [pscustomobject]@{
|
||||||
|
kind = "python"
|
||||||
|
deploy = [pscustomobject]@{ exclude = @("docs") }
|
||||||
|
}
|
||||||
|
$excl = Get-ArchiveExcludes -Project $proj
|
||||||
|
|
||||||
|
$e = Get-ArchivedEntries -TopLevelExclude $excl -Paths @(
|
||||||
|
"app/main.py",
|
||||||
|
"app/models.py",
|
||||||
|
"requirements.txt",
|
||||||
|
".env", # secret - excluded on deploy
|
||||||
|
".env.production", # secret - excluded on deploy
|
||||||
|
"uploads/user-file.png", # user data - excluded on deploy
|
||||||
|
"docs/design.md", # project's own deploy.exclude
|
||||||
|
".venv/Lib/site.py", # virtualenv
|
||||||
|
"__pycache__/main.pyc", # bytecode
|
||||||
|
"src/__pycache__/deep.pyc", # nested bytecode (recursive prune)
|
||||||
|
".git/config", # vcs
|
||||||
|
"debug.log" # junk extension
|
||||||
|
)
|
||||||
|
|
||||||
|
$e | Should -Contain "app/main.py"
|
||||||
|
$e | Should -Contain "app/models.py"
|
||||||
|
$e | Should -Contain "requirements.txt"
|
||||||
|
|
||||||
|
foreach ($unwanted in @(".env", ".env.production", "debug.log")) {
|
||||||
|
$e | Should -Not -Contain $unwanted
|
||||||
|
}
|
||||||
|
foreach ($prefix in @("uploads/", "docs/", ".venv/", "__pycache__/", ".git/")) {
|
||||||
|
@($e | Where-Object { $_ -like "$prefix*" }).Count | Should -Be 0
|
||||||
|
}
|
||||||
|
@($e | Where-Object { $_ -like "*__pycache__*" }).Count | Should -Be 0
|
||||||
|
}
|
||||||
|
|
||||||
|
It "keeps secrets and uploads in a BACKUP of the same tree" {
|
||||||
|
# Same source, -ForBackup excludes: a backup that dropped .env or
|
||||||
|
# uploads would not be a restore point.
|
||||||
|
$proj = [pscustomobject]@{ kind = "python" }
|
||||||
|
$excl = Get-ArchiveExcludes -Project $proj -ForBackup
|
||||||
|
|
||||||
|
$e = Get-ArchivedEntries -TopLevelExclude $excl -Paths @(
|
||||||
|
"app/main.py", ".env", "uploads/user-file.png", ".venv/Lib/site.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
$e | Should -Contain ".env"
|
||||||
|
$e | Should -Contain "uploads/user-file.png"
|
||||||
|
@($e | Where-Object { $_ -like ".venv/*" }).Count | Should -Be 0 # still no virtualenv
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user