# 黑盒测试:只依据 README 记录的对外契约,在**沙盒副本**里驱动 CLI。 # 不读实现细节;每个用例都断言"文档承诺的行为 vs 实际行为"。 param( [string]$OutJson = "$PSScriptRoot\blackbox_results.json" ) $ErrorActionPreference = 'Continue' $repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $host7 = (Get-Command pwsh).Source $host51 = (Get-Command powershell).Source # ---------------------------------------------------------------- 沙盒(绝不碰真实 Backups/ logs/) $sandbox = Join-Path $env:TEMP ('baknret-bb-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) $src = Join-Path $sandbox 'srcdocs' New-Item -ItemType Directory -Path (Join-Path $src 'nested') -Force | Out-Null Set-Content -LiteralPath (Join-Path $src 'a.txt') -Value 'alpha' -Encoding utf8 Set-Content -LiteralPath (Join-Path $src 'nested\b.txt') -Value 'beta' -Encoding utf8 Set-Content -LiteralPath (Join-Path $src 'nested\skipme.log') -Value 'noise' -Encoding utf8 $listPath = Join-Path $sandbox 'BackupList.txt' Set-Content -LiteralPath $listPath -Encoding utf8 -Value @( "$src :- 'nested\skipme.log'", '+ ' + (Join-Path $sandbox 'nonexistent') ) $backupDir = Join-Path $sandbox 'Backups' $cases = [System.Collections.Generic.List[object]]::new() function Add-Case { param([string]$Id, [string]$Module, [string]$Title, [string]$Priority, [string]$Precondition, [string]$Steps, [string]$Expected, [string]$Actual, [bool]$Pass, [string]$Severity = '') $cases.Add([pscustomobject]@{ Id = $Id; Module = $Module; Title = $Title; Priority = $Priority Precondition = $Precondition; Steps = $Steps; Expected = $Expected Actual = $Actual; Status = $(if ($Pass) { 'PASS' } else { 'FAIL' }) Severity = $(if ($Pass) { '' } else { $Severity }) }) } function Invoke-Bak { param([string]$HostName, [string[]]$Arguments, [string]$Script = 'Backup-Data.ps1') $exe = if ($HostName -eq '7') { $host7 } else { $host51 } $all = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $repo $Script)) + $Arguments $out = & $exe @all 2>&1 return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = @($out) } } function Get-ManifestHash { $p = Join-Path $backupDir 'manifest.json' if (-not (Test-Path -LiteralPath $p)) { return '' } return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash } # ================================================================ 用例 # BB-01 首次真实备份(核心流) $before = Get-ManifestHash $r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $listPath, '-BackupDir', $backupDir) $archives = @(Get-ChildItem -LiteralPath $backupDir -Filter '*.7z' -ErrorAction SilentlyContinue) Add-Case -Id 'BB-01' -Module '备份' -Title '文档承诺:备份成功返回 0,并产出 .7z 归档' -Priority 'P0' ` -Precondition '沙盒清单:1 个目录(含排除)+ 1 个不存在的源' ` -Steps 'Backup-Data.ps1(无 -DryRun)' ` -Expected '退出码 0;Backups\ 下出现 1 个 .7z;manifest.json 存在' ` -Actual ("退出码={0};归档={1} 个({2})" -f $r.ExitCode, $archives.Count, ($archives.Name -join ',')) ` -Pass ($r.ExitCode -eq 0 -and $archives.Count -ge 1 -and (Test-Path (Join-Path $backupDir 'manifest.json'))) ` -Severity '严重' # BB-02 排除规则真的生效(逐个归档内容核对) $zip = (Get-Command 7z -ErrorAction SilentlyContinue).Source if (-not $zip) { foreach ($p in 'C:\Programs\Scoop\shims\7z.exe') { if (Test-Path $p) { $zip = $p } } } $listing = if ($archives.Count -gt 0 -and $zip) { @(& $zip l -ba $archives[0].FullName 2>&1) } else { @() } $hasSkip = @($listing | Where-Object { $_ -match 'skipme\.log' }).Count -gt 0 $hasKeep = @($listing | Where-Object { $_ -match 'a\.txt|b\.txt' }).Count -gt 0 Add-Case -Id 'BB-02' -Module '排除' -Title '文档承诺::- 排除模式把内容挡在归档之外' -Priority 'P0' ` -Precondition '清单里对源目录写了 :- ''nested\skipme.log''' ` -Steps '7z l 列出归档内容' ` -Expected '归档里**没有** skipme.log,但有 a.txt 与 b.txt' ` -Actual ("skipme.log 命中={0};a/b.txt 命中={1}" -f $hasSkip, $hasKeep) ` -Pass ($hasKeep -and -not $hasSkip) -Severity '严重' # BB-03 只读模式不写盘(README 的强承诺) $h1 = Get-ManifestHash $r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) $h2 = Get-ManifestHash Add-Case -Id 'BB-03' -Module '干跑' -Title '文档承诺:-DryRun 一个字节都不写(含 manifest.json)' -Priority 'P0' ` -Precondition '已有 1 份归档与 manifest.json' ` -Steps '记录 SHA256 → 跑 -DryRun → 再记录 SHA256' ` -Expected '退出码 0;manifest.json 的 SHA256 前后完全一致' ` -Actual ("退出码={0};哈希 {1} → {2}" -f $r.ExitCode, $h1.Substring(0, 12), $h2.Substring(0, 12)) ` -Pass ($r.ExitCode -eq 0 -and $h1 -eq $h2) -Severity '致命' # BB-04 源不存在只算跳过、不算失败 Add-Case -Id 'BB-04' -Module '异常流' -Title '文档承诺:源路径不存在记 missing-source,不算失败' -Priority 'P1' ` -Precondition '清单第 2 行指向不存在的目录' ` -Steps '跑备份后读 manifest.json 的 action 字段' ` -Expected '该条目 action=missing-source,且整体退出码仍为 0' ` -Actual ("退出码={0};manifest action 分布={1}" -f $r.ExitCode, (@((Get-Content (Join-Path $backupDir 'manifest.json') -Raw | ConvertFrom-Json).items.PSObject.Properties.Value.action) -join ',')) ` -Pass ($r.ExitCode -eq 0 -and (@((Get-Content (Join-Path $backupDir 'manifest.json') -Raw | ConvertFrom-Json).items.PSObject.Properties.Value.action) -contains 'missing-source')) ` -Severity '一般' # BB-05 -Only 过滤 $r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-Only', 'srcdocs', '-BackupListPath', $listPath, '-BackupDir', $backupDir) $hitMissing = @($r.Output | Where-Object { $_ -match 'nonexistent' }).Count -gt 0 Add-Case -Id 'BB-05' -Module '干跑' -Title '文档承诺:-Only 只处理匹配的条目' -Priority 'P1' ` -Precondition '清单 2 条:srcdocs(目录)、nonexistent(不存在)' ` -Steps 'Backup-Data.ps1 -DryRun -Only ''srcdocs''' ` -Expected '退出码 0;输出里不出现未选中的 nonexistent 条目' ` -Actual ("退出码={0};输出提到 nonexistent={1}" -f $r.ExitCode, $hitMissing) ` -Pass ($r.ExitCode -eq 0 -and -not $hitMissing) -Severity '一般' # BB-06 非法参数(错误推测) $r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', (Join-Path $sandbox 'no-such-list.txt'), '-BackupDir', $backupDir) Add-Case -Id 'BB-06' -Module '异常流' -Title '边界值:清单文件不存在时的行为' -Priority 'P1' ` -Precondition '传入一个不存在的 -BackupListPath' ` -Steps 'Backup-Data.ps1 -BackupListPath <不存在>' ` -Expected '明确报错(非 0 退出码)或给出可读提示,**不得静默返回 0**' ` -Actual ("退出码={0};末行={1}" -f $r.ExitCode, (@($r.Output) | Select-Object -Last 1)) ` -Pass ($r.ExitCode -ne 0) -Severity '一般' # BB-07 旧名字垫片仍可用(文档承诺"只留一轮") $r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) -Script 'Backup.ps1' $saidRename = @($r.Output | Where-Object { $_ -match '已改名为' }).Count -gt 0 Add-Case -Id 'BB-07' -Module '兼容' -Title '文档承诺:旧名字 Backup.ps1 是转发垫片,退出码原样传递' -Priority 'P1' ` -Precondition '实现已改名为 Backup-Data.ps1' ` -Steps 'Backup.ps1 -DryRun(旧名字)' ` -Expected '打印改名提示;退出码与直接调用一致(0)' ` -Actual ("退出码={0};有改名提示={1}" -f $r.ExitCode, $saidRename) ` -Pass ($r.ExitCode -eq 0 -and $saidRename) -Severity '一般' # BB-08 双宿主一致性(5.1 是文档承诺支持的一半) $r7 = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) $r51 = Invoke-Bak -HostName '5.1' -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) Add-Case -Id 'BB-08' -Module '跨端一致性' -Title '文档承诺:Windows PowerShell 5.1 与 7.x 行为一致' -Priority 'P0' ` -Precondition '同一沙盒、同一清单' ` -Steps '两个宿主各跑一次 -DryRun,比较退出码' ` -Expected '两者退出码都是 0(GBK 控制台下的中文输出不崩)' ` -Actual ("7 退出码={0};5.1 退出码={1}" -f $r7.ExitCode, $r51.ExitCode) ` -Pass ($r7.ExitCode -eq 0 -and $r51.ExitCode -eq 0) -Severity '致命' # BB-09 特殊字符路径(错误推测) $sp = Join-Path $sandbox 'sp&chars#dir' New-Item -ItemType Directory -Path $sp -Force | Out-Null Set-Content -LiteralPath (Join-Path $sp 'x.txt') -Value 'special' -Encoding utf8 $list2 = Join-Path $sandbox 'BackupList2.txt' Set-Content -LiteralPath $list2 -Encoding utf8 -Value "`"$sp`"" $bd2 = Join-Path $sandbox 'Backups2' $r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $list2, '-BackupDir', $bd2) $ok9 = $r.ExitCode -eq 0 -and @(Get-ChildItem -LiteralPath $bd2 -Filter '*.7z' -ErrorAction SilentlyContinue).Count -ge 1 Add-Case -Id 'BB-09' -Module '边界值' -Title '特殊字符:含 & 与 # 的路径(整行引号写法)' -Priority 'P2' ` -Precondition '源目录名含 & 与 #;清单行整体加引号' ` -Steps '备份该条目' ` -Expected '退出码 0 且真的产出归档(# 不被当注释吃掉)' ` -Actual ("退出码={0};归档数={1}" -f $r.ExitCode, @(Get-ChildItem -LiteralPath $bd2 -Filter '*.7z' -ErrorAction SilentlyContinue).Count) ` -Pass $ok9 -Severity '一般' # BB-10 无控制台时的明确失败(README:绝不挂起) $pinfo = New-Object System.Diagnostics.ProcessStartInfo $pinfo.FileName = $host7 $pinfo.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$repo\Manage-Backup.ps1`"" $pinfo.RedirectStandardOutput = $true $pinfo.RedirectStandardError = $true $pinfo.UseShellExecute = $false $pinfo.CreateNoWindow = $true $proc = [System.Diagnostics.Process]::Start($pinfo) $finished = $proc.WaitForExit(60000) $stdout = if ($finished) { $proc.StandardOutput.ReadToEnd() } else { '' } $stderr = if ($finished) { $proc.StandardError.ReadToEnd() } else { '' } if (-not $finished) { try { $proc.Kill() } catch {} } Add-Case -Id 'BB-10' -Module '无头' -Title '文档承诺:没有控制台且没给 -InputScript 时报错退出,绝不挂起' -Priority 'P0' ` -Precondition 'stdout/stderr 被重定向(等价于计划任务/管道环境)' ` -Steps '不传参数直接运行 Manage-Backup.ps1,等待最多 60 秒' ` -Expected '60 秒内退出,退出码 2,并提示"没有可用的控制台"' ` -Actual ("60 秒内退出={0};退出码={1};输出片段={2}" -f $finished, $proc.ExitCode, (@($stdout, $stderr) -join ' ').Trim().Substring(0, [Math]::Min(90, (@($stdout, $stderr) -join ' ').Trim().Length))) ` -Pass ($finished -and $proc.ExitCode -eq 2) -Severity '致命' # BB-11 孤儿归档审计 $orphanDir = Join-Path $sandbox 'Backups3' New-Item -ItemType Directory -Path $orphanDir -Force | Out-Null Copy-Item -LiteralPath (Join-Path $backupDir 'manifest.json') -Destination (Join-Path $orphanDir 'manifest.json') -ErrorAction SilentlyContinue $fake = Join-Path $orphanDir 'GhostSoftware.7z' if ($archives.Count -gt 0) { Copy-Item -LiteralPath $archives[0].FullName -Destination $fake } $r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $orphanDir) $named = @($r.Output | Where-Object { $_ -match 'GhostSoftware' }).Count -gt 0 Add-Case -Id 'BB-11' -Module '审计' -Title '文档承诺:孤儿归档会被点名' -Priority 'P1' ` -Precondition 'Backups3\ 里放一个清单中不存在的 GhostSoftware.7z' ` -Steps '备份后看输出是否点名' ` -Expected '输出里出现该孤儿归档名' ` -Actual ("退出码={0};点名={1}" -f $r.ExitCode, $named) ` -Pass $named -Severity '一般' # ---------------------------------------------------------------- 汇总 $report = [ordered]@{ testedAt = (Get-Date).ToString('s') scope = '文档契约黑盒测试(README 为准)' sandbox = $sandbox total = $cases.Count passed = @($cases | Where-Object Status -eq 'PASS').Count failed = @($cases | Where-Object Status -eq 'FAIL').Count fatalOrSevere = @($cases | Where-Object { $_.Status -eq 'FAIL' -and $_.Severity -in '致命', '严重' }).Count cases = $cases } $report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutJson -Encoding utf8 $cases | Format-Table Id, Module, Priority, Status, Severity, Title -AutoSize Write-Host '' Write-Host ("合计 {0};通过 {1};失败 {2};致命/严重 {3}" -f $report.total, $report.passed, $report.failed, $report.fatalOrSevere) Write-Host ("结果写入 {0}" -f $OutJson) Write-Host ("沙盒(保留供排查): {0}" -f $sandbox)